Merge pull request #2343 from mikiher/caching

Simple API Caching for /libraries* requests
This commit is contained in:
advplyr 2023-11-26 12:33:54 -06:00 committed by GitHub
commit 5a8c60a8bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 357 additions and 17 deletions

208
package-lock.json generated
View File

@ -15,6 +15,7 @@
"express-session": "^1.17.3",
"graceful-fs": "^4.2.10",
"htmlparser2": "^8.0.1",
"lru-cache": "^10.0.3",
"node-tone": "^1.0.1",
"nodemailer": "^6.9.2",
"openid-client": "^5.6.1",
@ -603,6 +604,17 @@
"node-pre-gyp": "bin/node-pre-gyp"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/nopt": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
@ -641,6 +653,18 @@
"semver": "^7.3.5"
}
},
"node_modules/@npmcli/fs/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"optional": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@npmcli/fs/node_modules/semver": {
"version": "7.5.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz",
@ -1126,6 +1150,18 @@
"node": ">= 10"
}
},
"node_modules/cacache/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"optional": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/caching-transform": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz",
@ -2619,6 +2655,18 @@
"node": ">=8"
}
},
"node_modules/istanbul-lib-report/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-lib-report/node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
@ -2783,6 +2831,17 @@
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -2917,14 +2976,11 @@
}
},
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dependencies": {
"yallist": "^4.0.0"
},
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.3.tgz",
"integrity": "sha512-B7gr+F6MkqB3uzINHXNctGieGsRTMwIBgxkp0yq/5BwcuDzD4A8wQpHQW6vDAm1uKSLQghmRdD9sKqf2vJ1cEg==",
"engines": {
"node": ">=10"
"node": "14 || >=16.14"
}
},
"node_modules/make-dir": {
@ -2976,6 +3032,18 @@
"node": ">= 10"
}
},
"node_modules/make-fetch-happen/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"optional": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@ -3552,6 +3620,18 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/node-gyp/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"optional": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-gyp/node_modules/nopt": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
@ -3841,6 +3921,17 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/openid-client/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@ -4386,6 +4477,17 @@
}
}
},
"node_modules/sequelize/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/sequelize/node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@ -5777,6 +5879,14 @@
"tar": "^6.1.11"
},
"dependencies": {
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"requires": {
"yallist": "^4.0.0"
}
},
"nopt": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
@ -5805,6 +5915,15 @@
"semver": "^7.3.5"
},
"dependencies": {
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"optional": true,
"requires": {
"yallist": "^4.0.0"
}
},
"semver": {
"version": "7.5.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz",
@ -6192,6 +6311,17 @@
"ssri": "^8.0.1",
"tar": "^6.0.2",
"unique-filename": "^1.1.1"
},
"dependencies": {
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"optional": true,
"requires": {
"yallist": "^4.0.0"
}
}
}
},
"caching-transform": {
@ -7291,6 +7421,15 @@
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true
},
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"requires": {
"yallist": "^4.0.0"
}
},
"make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
@ -7408,6 +7547,14 @@
"semver": "^7.5.4"
},
"dependencies": {
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"requires": {
"yallist": "^4.0.0"
}
},
"ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -7529,12 +7676,9 @@
}
},
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"requires": {
"yallist": "^4.0.0"
}
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.3.tgz",
"integrity": "sha512-B7gr+F6MkqB3uzINHXNctGieGsRTMwIBgxkp0yq/5BwcuDzD4A8wQpHQW6vDAm1uKSLQghmRdD9sKqf2vJ1cEg=="
},
"make-dir": {
"version": "3.1.0",
@ -7573,6 +7717,17 @@
"promise-retry": "^2.0.1",
"socks-proxy-agent": "^6.0.0",
"ssri": "^8.0.0"
},
"dependencies": {
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"optional": true,
"requires": {
"yallist": "^4.0.0"
}
}
}
},
"media-typer": {
@ -8000,6 +8155,15 @@
"wide-align": "^1.1.5"
}
},
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"optional": true,
"requires": {
"yallist": "^4.0.0"
}
},
"nopt": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
@ -8220,6 +8384,16 @@
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
},
"dependencies": {
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"requires": {
"yallist": "^4.0.0"
}
}
}
},
"p-limit": {
@ -8588,6 +8762,14 @@
"ms": "2.1.2"
}
},
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"requires": {
"yallist": "^4.0.0"
}
},
"ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",

View File

@ -42,6 +42,7 @@
"express-session": "^1.17.3",
"graceful-fs": "^4.2.10",
"htmlparser2": "^8.0.1",
"lru-cache": "^10.0.3",
"node-tone": "^1.0.1",
"nodemailer": "^6.9.2",
"openid-client": "^5.6.1",

View File

@ -32,13 +32,13 @@ const PodcastManager = require('./managers/PodcastManager')
const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
const RssFeedManager = require('./managers/RssFeedManager')
const CronManager = require('./managers/CronManager')
const ApiCacheManager = require('./managers/ApiCacheManager')
const LibraryScanner = require('./scanner/LibraryScanner')
//Import the main Passport and Express-Session library
const passport = require('passport')
const expressSession = require('express-session')
class Server {
constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) {
this.Port = PORT
@ -73,6 +73,7 @@ class Server {
this.audioMetadataManager = new AudioMetadataMangaer()
this.rssFeedManager = new RssFeedManager()
this.cronManager = new CronManager(this.podcastManager)
this.apiCacheManager = new ApiCacheManager()
// Routers
this.apiRouter = new ApiRouter(this)
@ -117,6 +118,7 @@ class Server {
const libraries = await Database.libraryModel.getAllOldLibraries()
await this.cronManager.init(libraries)
this.apiCacheManager.init()
if (Database.serverSettings.scannerDisableWatcher) {
Logger.info(`[Server] Watcher is disabled`)

View File

@ -192,9 +192,9 @@ class SocketAuthority {
this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
// Update user lastSeen
// Update user lastSeen without firing sequelize bulk update hooks
user.lastSeen = Date.now()
await Database.updateUser(user)
await Database.userModel.updateFromOld(user, false)
const initialPayload = {
userId: client.user.id,

View File

@ -0,0 +1,54 @@
const { LRUCache } = require('lru-cache')
const Logger = require('../Logger')
const Database = require('../Database')
class ApiCacheManager {
defaultCacheOptions = { max: 1000, maxSize: 10 * 1000 * 1000, sizeCalculation: item => (item.body.length + JSON.stringify(item.headers).length) }
defaultTtlOptions = { ttl: 30 * 60 * 1000 }
constructor(cache = new LRUCache(this.defaultCacheOptions), ttlOptions = this.defaultTtlOptions) {
this.cache = cache
this.ttlOptions = ttlOptions
}
init(database = Database) {
let hooks = ['afterCreate', 'afterUpdate', 'afterDestroy', 'afterBulkCreate', 'afterBulkUpdate', 'afterBulkDestroy']
hooks.forEach(hook => database.sequelize.addHook(hook, (model) => this.clear(model, hook)))
}
clear(model, hook) {
Logger.debug(`[ApiCacheManager] ${model.constructor.name}.${hook}: Clearing cache`)
this.cache.clear()
}
get middleware() {
return (req, res, next) => {
const key = { user: req.user.username, url: req.url }
const stringifiedKey = JSON.stringify(key)
Logger.debug(`[ApiCacheManager] count: ${this.cache.size} size: ${this.cache.calculatedSize}`)
const cached = this.cache.get(stringifiedKey)
if (cached) {
Logger.debug(`[ApiCacheManager] Cache hit: ${stringifiedKey}`)
res.set(cached.headers)
res.status(cached.statusCode)
res.send(cached.body)
return
}
res.originalSend = res.send
res.send = (body) => {
Logger.debug(`[ApiCacheManager] Cache miss: ${stringifiedKey}`)
const cached = { body, headers: res.getHeaders(), statusCode: res.statusCode }
if (key.url.search(/^\/libraries\/.*?\/personalized/) !== -1) {
Logger.debug(`[ApiCacheManager] Caching with ${this.ttlOptions.ttl} ms TTL`)
this.cache.set(stringifiedKey, cached, this.ttlOptions)
} else {
this.cache.set(stringifiedKey, cached)
}
res.originalSend(body)
}
next()
}
}
}
module.exports = ApiCacheManager

View File

@ -99,11 +99,13 @@ class User extends Model {
* Update User from old user model
*
* @param {oldUser} oldUser
* @param {boolean} [hooks=true] Run before / after bulk update hooks?
* @returns {Promise<boolean>}
*/
static updateFromOld(oldUser) {
static updateFromOld(oldUser, hooks = true) {
const user = this.getFromOld(oldUser)
return this.update(user, {
hooks: !!hooks,
where: {
id: user.id
}

View File

@ -48,6 +48,7 @@ class ApiRouter {
this.cronManager = Server.cronManager
this.notificationManager = Server.notificationManager
this.emailManager = Server.emailManager
this.apiCacheManager = Server.apiCacheManager
this.router = express()
this.router.disable('x-powered-by')
@ -58,6 +59,7 @@ class ApiRouter {
//
// Library Routes
//
this.router.get(/^\/libraries/, this.apiCacheManager.middleware)
this.router.post('/libraries', LibraryController.create.bind(this))
this.router.get('/libraries', LibraryController.findAll.bind(this))
this.router.get('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.findOne.bind(this))

View File

@ -0,0 +1,97 @@
// Import dependencies and modules for testing
const { expect } = require('chai')
const sinon = require('sinon')
const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
describe('ApiCacheManager', () => {
let cache
let req
let res
let next
let manager
beforeEach(() => {
cache = { get: sinon.stub(), set: sinon.spy() }
req = { user: { username: 'testUser' }, url: '/test-url' }
res = { send: sinon.spy(), getHeaders: sinon.stub(), statusCode: 200, status: sinon.spy(), set: sinon.spy() }
next = sinon.spy()
})
describe('middleware', () => {
it('should send cached data if available', () => {
// Arrange
const cachedData = { body: 'cached data', headers: { 'content-type': 'application/json' }, statusCode: 200 }
cache.get.returns(cachedData)
const key = JSON.stringify({ user: req.user.username, url: req.url })
manager = new ApiCacheManager(cache)
// Act
manager.middleware(req, res, next)
// Assert
expect(cache.get.calledOnce).to.be.true
expect(cache.get.calledWith(key)).to.be.true
expect(res.set.calledOnce).to.be.true
expect(res.set.calledWith(cachedData.headers)).to.be.true
expect(res.status.calledOnce).to.be.true
expect(res.status.calledWith(cachedData.statusCode)).to.be.true
expect(res.send.calledOnce).to.be.true
expect(res.send.calledWith(cachedData.body)).to.be.true
expect(res.originalSend).to.be.undefined
expect(next.called).to.be.false
expect(cache.set.called).to.be.false
})
it('should cache and send response if data is not cached', () => {
// Arrange
cache.get.returns(null)
const headers = { 'content-type': 'application/json' }
res.getHeaders.returns(headers)
const body = 'response data'
const statusCode = 200
const responseData = { body, headers, statusCode }
const key = JSON.stringify({ user: req.user.username, url: req.url })
manager = new ApiCacheManager(cache)
// Act
manager.middleware(req, res, next)
res.send(body)
// Assert
expect(cache.get.calledOnce).to.be.true
expect(cache.get.calledWith(key)).to.be.true
expect(next.calledOnce).to.be.true
expect(cache.set.calledOnce).to.be.true
expect(cache.set.calledWith(key, responseData)).to.be.true
expect(res.originalSend.calledOnce).to.be.true
expect(res.originalSend.calledWith(body)).to.be.true
})
it('should cache personalized response with 30 minutes TTL', () => {
// Arrange
cache.get.returns(null)
const headers = { 'content-type': 'application/json' }
res.getHeaders.returns(headers)
const body = 'personalized data'
const statusCode = 200
const responseData = { body, headers, statusCode }
req.url = '/libraries/id/personalized'
const key = JSON.stringify({ user: req.user.username, url: req.url })
const ttlOptions = { ttl: 30 * 60 * 1000 }
manager = new ApiCacheManager(cache, ttlOptions)
// Act
manager.middleware(req, res, next)
res.send(body)
// Assert
expect(cache.get.calledOnce).to.be.true
expect(cache.get.calledWith(key)).to.be.true
expect(next.calledOnce).to.be.true
expect(cache.set.calledOnce).to.be.true
expect(cache.set.calledWith(key, responseData, ttlOptions)).to.be.true
expect(res.originalSend.calledOnce).to.be.true
expect(res.originalSend.calledWith(body)).to.be.true
})
})
})