mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-03-11 02:12:21 -05:00
Public tags repository caching
refs https://github.com/TryGhost/Toolbox/issues/515 - There are a lot of repeated cacheable tag-related queries coming from {{get}} helpers in themes that can be cached. - Having a repository layer deal with very specific type of query allows to add extra functionality, like caching, on top of the database query - This commit is wiring code that addds a default in-memory cache to all db queries. Note, it lasts forever and has no "reset" listeners. The production cache is mean to have a short time-to-live (TTL) - removes a need to keep the cache always fresh. - Kept the cache key shortened. Without a "context" and any other non-model options the cache-key can store more variations of queries. For example, there is no member-specific or integration-specific query results, so having those in the cache key would only partition the cache and use up more memory.
This commit is contained in:
parent
b48262b5d8
commit
5216220541
13 changed files with 260 additions and 1 deletions
|
@ -294,6 +294,7 @@ async function initServices({config}) {
|
||||||
const emailService = require('./server/services/email-service');
|
const emailService = require('./server/services/email-service');
|
||||||
const emailAnalytics = require('./server/services/email-analytics');
|
const emailAnalytics = require('./server/services/email-analytics');
|
||||||
const mentionsService = require('./server/services/mentions');
|
const mentionsService = require('./server/services/mentions');
|
||||||
|
const tagsPublic = require('./server/services/tags-public');
|
||||||
|
|
||||||
const urlUtils = require('./shared/url-utils');
|
const urlUtils = require('./shared/url-utils');
|
||||||
|
|
||||||
|
@ -311,6 +312,7 @@ async function initServices({config}) {
|
||||||
staffService.init(),
|
staffService.init(),
|
||||||
members.init(),
|
members.init(),
|
||||||
tiers.init(),
|
tiers.init(),
|
||||||
|
tagsPublic.init(),
|
||||||
membersEvents.init(),
|
membersEvents.init(),
|
||||||
permissions.init(),
|
permissions.init(),
|
||||||
xmlrpc.listen(),
|
xmlrpc.listen(),
|
||||||
|
|
|
@ -2,6 +2,7 @@ const Promise = require('bluebird');
|
||||||
const tpl = require('@tryghost/tpl');
|
const tpl = require('@tryghost/tpl');
|
||||||
const errors = require('@tryghost/errors');
|
const errors = require('@tryghost/errors');
|
||||||
const models = require('../../models');
|
const models = require('../../models');
|
||||||
|
const tagsPublicService = require('../../services/tags-public');
|
||||||
|
|
||||||
const ALLOWED_INCLUDES = ['count.posts'];
|
const ALLOWED_INCLUDES = ['count.posts'];
|
||||||
|
|
||||||
|
@ -31,7 +32,7 @@ module.exports = {
|
||||||
},
|
},
|
||||||
permissions: true,
|
permissions: true,
|
||||||
query(frame) {
|
query(frame) {
|
||||||
return models.TagPublic.findPage(frame.options);
|
return tagsPublicService.api.browse(frame.options);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
1
ghost/core/core/server/services/tags-public/index.js
Normal file
1
ghost/core/core/server/services/tags-public/index.js
Normal file
|
@ -0,0 +1 @@
|
||||||
|
module.exports = require('./service');
|
26
ghost/core/core/server/services/tags-public/service.js
Normal file
26
ghost/core/core/server/services/tags-public/service.js
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
class TagsPublicServiceWrapper {
|
||||||
|
async init() {
|
||||||
|
if (this.api) {
|
||||||
|
// Already done
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up all the dependencies
|
||||||
|
const models = require('../../models');
|
||||||
|
const adapterManager = require('../adapter-manager');
|
||||||
|
|
||||||
|
const tagsCache = adapterManager.getAdapter('cache:tagsPublic');
|
||||||
|
const {TagsPublicRepository} = require('@tryghost/tags-public');
|
||||||
|
|
||||||
|
this.linkRedirectRepository = new TagsPublicRepository({
|
||||||
|
Tag: models.TagPublic,
|
||||||
|
cache: tagsCache
|
||||||
|
});
|
||||||
|
|
||||||
|
this.api = {
|
||||||
|
browse: this.linkRedirectRepository.getAll.bind(this.linkRedirectRepository)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new TagsPublicServiceWrapper();
|
|
@ -131,6 +131,7 @@
|
||||||
"@tryghost/staff-service": "0.0.0",
|
"@tryghost/staff-service": "0.0.0",
|
||||||
"@tryghost/stats-service": "0.0.0",
|
"@tryghost/stats-service": "0.0.0",
|
||||||
"@tryghost/string": "0.2.2",
|
"@tryghost/string": "0.2.2",
|
||||||
|
"@tryghost/tags-public": "0.0.0",
|
||||||
"@tryghost/tiers": "0.0.0",
|
"@tryghost/tiers": "0.0.0",
|
||||||
"@tryghost/tpl": "0.1.21",
|
"@tryghost/tpl": "0.1.21",
|
||||||
"@tryghost/update-check-service": "0.0.0",
|
"@tryghost/update-check-service": "0.0.0",
|
||||||
|
|
6
ghost/tags-public/.eslintrc.js
Normal file
6
ghost/tags-public/.eslintrc.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: ['ghost'],
|
||||||
|
extends: [
|
||||||
|
'plugin:ghost/node'
|
||||||
|
]
|
||||||
|
};
|
23
ghost/tags-public/README.md
Normal file
23
ghost/tags-public/README.md
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
# Tags Public
|
||||||
|
|
||||||
|
services and repositories serving public tags endpoints
|
||||||
|
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
This is a monorepo package.
|
||||||
|
|
||||||
|
Follow the instructions for the top-level repo.
|
||||||
|
1. `git clone` this repo & `cd` into it as usual
|
||||||
|
2. Run `yarn` to install top-level dependencies.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
- `yarn lint` run just eslint
|
||||||
|
- `yarn test` run lint and tests
|
||||||
|
|
1
ghost/tags-public/index.js
Normal file
1
ghost/tags-public/index.js
Normal file
|
@ -0,0 +1 @@
|
||||||
|
module.exports = require('./lib/tags-public');
|
61
ghost/tags-public/lib/TagsPublicRepository.js
Normal file
61
ghost/tags-public/lib/TagsPublicRepository.js
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
const _ = require('lodash');
|
||||||
|
|
||||||
|
module.exports = class TagsPublicRepository {
|
||||||
|
/** @type {object} */
|
||||||
|
#Tag;
|
||||||
|
|
||||||
|
/** @type {object} */
|
||||||
|
#cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} deps
|
||||||
|
* @param {object} deps.Tag Bookshelf Model instance of TagPublic
|
||||||
|
* @param {object} deps.cache cache instance
|
||||||
|
*/
|
||||||
|
constructor(deps) {
|
||||||
|
this.#Tag = deps.Tag;
|
||||||
|
this.#cache = deps.cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queries the database for Tag records
|
||||||
|
* @param {Object} options model options
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
async #getAllDB(options) {
|
||||||
|
return this.#Tag.findPage(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all tags and caches the returned results
|
||||||
|
* @param {Object} options
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
async getAll(options) {
|
||||||
|
let cacheKey;
|
||||||
|
|
||||||
|
if (this.#cache) {
|
||||||
|
// make the cache key smaller and don't include context
|
||||||
|
const permittedOptions = this.#Tag.permittedOptions('findPage')
|
||||||
|
.filter(option => (option !== 'context'));
|
||||||
|
const optionsForCacheKey = _.pick(options, permittedOptions);
|
||||||
|
|
||||||
|
// TODO: filter options, for example do we care make a distinction about
|
||||||
|
// logged in member on the tags level?
|
||||||
|
cacheKey = `get-all-${JSON.stringify(optionsForCacheKey)}`;
|
||||||
|
const cachedResult = this.#cache.get(cacheKey);
|
||||||
|
|
||||||
|
if (cachedResult) {
|
||||||
|
return cachedResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbResult = await this.#getAllDB(options);
|
||||||
|
|
||||||
|
if (this.#cache) {
|
||||||
|
this.#cache.set(cacheKey, dbResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dbResult;
|
||||||
|
}
|
||||||
|
};
|
1
ghost/tags-public/lib/tags-public.js
Normal file
1
ghost/tags-public/lib/tags-public.js
Normal file
|
@ -0,0 +1 @@
|
||||||
|
module.exports.TagsPublicRepository = require('./TagsPublicRepository');
|
26
ghost/tags-public/package.json
Normal file
26
ghost/tags-public/package.json
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "@tryghost/tags-public",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"repository": "https://github.com/TryGhost/Ghost/tree/main/packages/tags-public",
|
||||||
|
"author": "Ghost Foundation",
|
||||||
|
"private": true,
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "echo \"Implement me!\"",
|
||||||
|
"test:unit": "NODE_ENV=testing c8 --all --check-coverage --100 --reporter text --reporter cobertura mocha './test/**/*.test.js'",
|
||||||
|
"test": "yarn test:unit",
|
||||||
|
"lint:code": "eslint *.js lib/ --ext .js --cache",
|
||||||
|
"lint": "yarn lint:code && yarn lint:test",
|
||||||
|
"lint:test": "eslint -c test/.eslintrc.js test/ --ext .js --cache"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"index.js",
|
||||||
|
"lib"
|
||||||
|
],
|
||||||
|
"devDependencies": {
|
||||||
|
"c8": "7.12.0",
|
||||||
|
"mocha": "10.2.0",
|
||||||
|
"sinon": "15.0.1"
|
||||||
|
},
|
||||||
|
"dependencies": {}
|
||||||
|
}
|
6
ghost/tags-public/test/.eslintrc.js
Normal file
6
ghost/tags-public/test/.eslintrc.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: ['ghost'],
|
||||||
|
extends: [
|
||||||
|
'plugin:ghost/test'
|
||||||
|
]
|
||||||
|
};
|
104
ghost/tags-public/test/TagsPublicRepository.test.js
Normal file
104
ghost/tags-public/test/TagsPublicRepository.test.js
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
const assert = require('assert');
|
||||||
|
const sinon = require('sinon');
|
||||||
|
|
||||||
|
const {TagsPublicRepository} = require('../index');
|
||||||
|
// @NOTE: This is a dirty import from the Ghost "core"!
|
||||||
|
// extract it to it's own package and import here as require('@tryghost/adapter-base-cache-memory');
|
||||||
|
const MemoryCache = require('../../core/core/server/adapters/cache/Memory');
|
||||||
|
|
||||||
|
describe('TagsPublicRepository', function () {
|
||||||
|
afterEach(function () {
|
||||||
|
sinon.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAll', function () {
|
||||||
|
it('calls findPage on the model multiple times when cache NOT present', async function () {
|
||||||
|
const tagStub = {
|
||||||
|
findPage: sinon.stub().resolves(),
|
||||||
|
permittedOptions: sinon.stub()
|
||||||
|
};
|
||||||
|
const repo = new TagsPublicRepository({
|
||||||
|
Tag: tagStub
|
||||||
|
});
|
||||||
|
|
||||||
|
// first call
|
||||||
|
await repo.getAll({
|
||||||
|
limit: 'all'
|
||||||
|
});
|
||||||
|
// second call
|
||||||
|
await repo.getAll({
|
||||||
|
limit: 'all'
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(tagStub.findPage.callCount, 2, 'should be called same amount of times as getAll');
|
||||||
|
assert.ok(tagStub.findPage.calledWith({limit: 'all'}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls findPage once and uses the cached value on subsequent calls', async function () {
|
||||||
|
const tagStub = {
|
||||||
|
findPage: sinon.stub().resolves({
|
||||||
|
data: [{
|
||||||
|
get(key) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
meta: {}
|
||||||
|
}),
|
||||||
|
permittedOptions: sinon.stub().returns(['limit'])
|
||||||
|
};
|
||||||
|
const repo = new TagsPublicRepository({
|
||||||
|
Tag: tagStub,
|
||||||
|
cache: new MemoryCache()
|
||||||
|
});
|
||||||
|
|
||||||
|
// first call
|
||||||
|
const dbTags = await repo.getAll({
|
||||||
|
limit: 'all'
|
||||||
|
});
|
||||||
|
// second call
|
||||||
|
const cacheTags = await repo.getAll({
|
||||||
|
limit: 'all'
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(tagStub.findPage.callCount, 1, 'should be called once when cache is present');
|
||||||
|
assert.ok(tagStub.findPage.calledWith({limit: 'all'}));
|
||||||
|
|
||||||
|
assert.equal(dbTags, cacheTags, 'should return the same value from the cache');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls findPage multiple times if the record is not present in the cache', async function () {
|
||||||
|
const tagStub = {
|
||||||
|
findPage: sinon.stub().resolves({
|
||||||
|
data: [{
|
||||||
|
get(key) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
meta: {}
|
||||||
|
}),
|
||||||
|
permittedOptions: sinon.stub().returns(['limit'])
|
||||||
|
};
|
||||||
|
const cache = new MemoryCache();
|
||||||
|
const repo = new TagsPublicRepository({
|
||||||
|
Tag: tagStub,
|
||||||
|
cache: cache
|
||||||
|
});
|
||||||
|
|
||||||
|
// first call
|
||||||
|
await repo.getAll({
|
||||||
|
limit: 'all'
|
||||||
|
});
|
||||||
|
|
||||||
|
// clear the cache
|
||||||
|
cache.reset();
|
||||||
|
|
||||||
|
// second call
|
||||||
|
await repo.getAll({
|
||||||
|
limit: 'all'
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(tagStub.findPage.callCount, 2, 'should be called every time the item is not in the cache');
|
||||||
|
assert.ok(tagStub.findPage.calledWith({limit: 'all'}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Add table
Reference in a new issue