Improve data caching persistence
This commit is contained in:
parent
a9e4e91e8d
commit
2a505ebd7a
15
README.md
15
README.md
|
@ -75,8 +75,19 @@ Read more:
|
||||||
- Server side rendered
|
- Server side rendered
|
||||||
- Reduced data transfer
|
- Reduced data transfer
|
||||||
- Reduced client-side complexity
|
- Reduced client-side complexity
|
||||||
- Would require edge computing to reduce latency
|
- Advanced caching
|
||||||
- Data caching to speed up response time
|
- Data caching
|
||||||
|
- Reponses from the CMS are cached to speed up response time.
|
||||||
|
- When there is a change on the CMS, the cache is alerted through webhooks.
|
||||||
|
- The impacted responses are then invalidated, fetched again, and cached
|
||||||
|
- All possible reponses from the CMS are precached when the server loads.
|
||||||
|
- The cache is also saved on disk after any change, and is loading at startup if available.
|
||||||
|
- Page caching
|
||||||
|
- The pages themselves are cached to further speed up response time.
|
||||||
|
- When responses in the Data caching are invalidated, cached pages that depend on those reponses are also invalidated. They are then regenerated and cached again.
|
||||||
|
- Similarly to the data cache, all pages are precached when the server loads and the cache is saved on disk and loaded at startup.
|
||||||
|
- The server use the `Last-Modified` header and serve a `304 Not Modified` response when appropriate.
|
||||||
|
- The pages themselves use the `Cache-Control` header to allow local caching on the visitors' web browsers.
|
||||||
|
|
||||||
- SEO
|
- SEO
|
||||||
|
|
||||||
|
|
|
@ -5,17 +5,19 @@ import { existsSync } from "fs";
|
||||||
|
|
||||||
const ON_DISK_ROOT = `.cache/dataCache`;
|
const ON_DISK_ROOT = `.cache/dataCache`;
|
||||||
const ON_DISK_RESPONSE_CACHE_FILE = `${ON_DISK_ROOT}/responseCache.json`;
|
const ON_DISK_RESPONSE_CACHE_FILE = `${ON_DISK_ROOT}/responseCache.json`;
|
||||||
const ON_DISK_INVALIDATION_MAP_FILE = `${ON_DISK_ROOT}/invalidationMap.json`;
|
|
||||||
|
|
||||||
export class DataCache {
|
export class DataCache {
|
||||||
private readonly logger = getLogger("[DataCache]");
|
private readonly logger = getLogger("[DataCache]");
|
||||||
private initialized = false;
|
private initialized = false;
|
||||||
|
|
||||||
private responseCache = new Map<string, any>();
|
private readonly responseCache = new Map<string, any>();
|
||||||
private invalidationMap = new Map<string, Set<string>>();
|
private readonly invalidationMap = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
private scheduleSaveTimeout: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly payload: PayloadSDK,
|
private readonly payload: PayloadSDK,
|
||||||
|
private readonly uncachedPayload: PayloadSDK,
|
||||||
private readonly onInvalidate: (urls: string[]) => Promise<void>
|
private readonly onInvalidate: (urls: string[]) => Promise<void>
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@ -30,29 +32,26 @@ export class DataCache {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async precache() {
|
private async precache() {
|
||||||
if (existsSync(ON_DISK_RESPONSE_CACHE_FILE) && existsSync(ON_DISK_INVALIDATION_MAP_FILE)) {
|
// Get all keys from CMS
|
||||||
|
const allSDKUrls = (await this.uncachedPayload.getAllSdkUrls()).data.urls;
|
||||||
|
|
||||||
|
// Load cache from disk if available
|
||||||
|
if (existsSync(ON_DISK_RESPONSE_CACHE_FILE)) {
|
||||||
this.logger.log("Loading cache from disk...");
|
this.logger.log("Loading cache from disk...");
|
||||||
// Handle RESPONSE_CACHE_FILE
|
|
||||||
{
|
|
||||||
const buffer = await readFile(ON_DISK_RESPONSE_CACHE_FILE);
|
const buffer = await readFile(ON_DISK_RESPONSE_CACHE_FILE);
|
||||||
const data = JSON.parse(buffer.toString());
|
const data = JSON.parse(buffer.toString()) as [string, any][];
|
||||||
this.responseCache = new Map(data);
|
for (const [key, value] of data) {
|
||||||
|
// Do not include cache where the key is no longer in the CMS
|
||||||
|
if (!allSDKUrls.includes(key)) continue;
|
||||||
|
this.set(key, value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle INVALIDATION_MAP_FILE
|
const cacheSizeBeforePrecaching = this.responseCache.size;
|
||||||
{
|
|
||||||
const buffer = await readFile(ON_DISK_INVALIDATION_MAP_FILE);
|
|
||||||
const data = JSON.parse(buffer.toString()) as [string, string[]][];
|
|
||||||
const deserialize = data.map<[string, Set<string>]>(([key, value]) => [
|
|
||||||
key,
|
|
||||||
new Set(value),
|
|
||||||
]);
|
|
||||||
this.invalidationMap = new Map(deserialize);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const { data } = await this.payload.getAllSdkUrls();
|
|
||||||
|
|
||||||
for (const url of data.urls) {
|
for (const url of allSDKUrls) {
|
||||||
|
// Do not precache response if already included in the loaded cache from disk
|
||||||
|
if (this.responseCache.has(url)) continue;
|
||||||
try {
|
try {
|
||||||
await this.payload.request(url);
|
await this.payload.request(url);
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -60,8 +59,10 @@ export class DataCache {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.save();
|
if (cacheSizeBeforePrecaching !== this.responseCache.size) {
|
||||||
|
this.scheduleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log("Precaching completed!", this.responseCache.size, "responses cached");
|
this.logger.log("Precaching completed!", this.responseCache.size, "responses cached");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -91,7 +92,7 @@ export class DataCache {
|
||||||
this.responseCache.set(url, response);
|
this.responseCache.set(url, response);
|
||||||
this.logger.log("Cached response for", url);
|
this.logger.log("Cached response for", url);
|
||||||
if (this.initialized) {
|
if (this.initialized) {
|
||||||
this.save();
|
this.scheduleSave();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -118,10 +119,19 @@ export class DataCache {
|
||||||
this.onInvalidate([...urlsToInvalidate]);
|
this.onInvalidate([...urlsToInvalidate]);
|
||||||
this.logger.log("There are currently", this.responseCache.size, "responses in cache.");
|
this.logger.log("There are currently", this.responseCache.size, "responses in cache.");
|
||||||
if (this.initialized) {
|
if (this.initialized) {
|
||||||
this.save();
|
this.scheduleSave();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scheduleSave() {
|
||||||
|
if (this.scheduleSaveTimeout) {
|
||||||
|
clearTimeout(this.scheduleSaveTimeout);
|
||||||
|
}
|
||||||
|
this.scheduleSaveTimeout = setTimeout(() => {
|
||||||
|
this.save();
|
||||||
|
}, 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
private async save() {
|
private async save() {
|
||||||
if (!existsSync(ON_DISK_ROOT)) {
|
if (!existsSync(ON_DISK_ROOT)) {
|
||||||
await mkdir(ON_DISK_ROOT, { recursive: true });
|
await mkdir(ON_DISK_ROOT, { recursive: true });
|
||||||
|
@ -132,13 +142,5 @@ export class DataCache {
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
});
|
});
|
||||||
this.logger.log("Saved", ON_DISK_RESPONSE_CACHE_FILE);
|
this.logger.log("Saved", ON_DISK_RESPONSE_CACHE_FILE);
|
||||||
|
|
||||||
const serializedIdsCache = JSON.stringify(
|
|
||||||
[...this.invalidationMap].map(([key, value]) => [key, [...value]])
|
|
||||||
);
|
|
||||||
await writeFile(ON_DISK_INVALIDATION_MAP_FILE, serializedIdsCache, {
|
|
||||||
encoding: "utf-8",
|
|
||||||
});
|
|
||||||
this.logger.log("Saved", ON_DISK_INVALIDATION_MAP_FILE);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,9 @@ export class PageCache {
|
||||||
private responseCache = new Map<string, Response>();
|
private responseCache = new Map<string, Response>();
|
||||||
private invalidationMap = new Map<string, Set<string>>();
|
private invalidationMap = new Map<string, Set<string>>();
|
||||||
|
|
||||||
constructor(private readonly payload: PayloadSDK) {}
|
private scheduleSaveTimeout: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
|
constructor(private readonly uncachedPayload: PayloadSDK) {}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
if (this.initialized) return;
|
if (this.initialized) return;
|
||||||
|
@ -32,16 +34,36 @@ export class PageCache {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async precacheAll() {
|
private async precacheAll() {
|
||||||
|
const { data: languages } = await this.uncachedPayload.getLanguages();
|
||||||
|
const locales = languages.map(({ id }) => id);
|
||||||
|
|
||||||
|
// Get all pages urls from CMS
|
||||||
|
const allPagesUrls = [
|
||||||
|
"/",
|
||||||
|
"/settings",
|
||||||
|
"/timeline",
|
||||||
|
...(await this.uncachedPayload.getFolderSlugs()).data.map((slug) => `/folders/${slug}`),
|
||||||
|
...(await this.uncachedPayload.getPageSlugs()).data.map((slug) => `/pages/${slug}`),
|
||||||
|
...(await this.uncachedPayload.getCollectibleSlugs()).data.map(
|
||||||
|
(slug) => `/collectibles/${slug}`
|
||||||
|
),
|
||||||
|
].flatMap((url) => locales.map((id) => `/${id}${url}`));
|
||||||
|
|
||||||
|
// Load cache from disk if available
|
||||||
if (existsSync(ON_DISK_RESPONSE_CACHE_FILE) && existsSync(ON_DISK_INVALIDATION_MAP_FILE)) {
|
if (existsSync(ON_DISK_RESPONSE_CACHE_FILE) && existsSync(ON_DISK_INVALIDATION_MAP_FILE)) {
|
||||||
this.logger.log("Loading cache from disk...");
|
this.logger.log("Loading cache from disk...");
|
||||||
// Handle RESPONSE_CACHE_FILE
|
// Handle RESPONSE_CACHE_FILE
|
||||||
{
|
{
|
||||||
const buffer = await readFile(ON_DISK_RESPONSE_CACHE_FILE);
|
const buffer = await readFile(ON_DISK_RESPONSE_CACHE_FILE);
|
||||||
const data = JSON.parse(buffer.toString()) as [string, SerializableResponse][];
|
const data = JSON.parse(buffer.toString()) as [string, SerializableResponse][];
|
||||||
const deserializedData = data.map<[string, Response]>(([key, value]) => [
|
let deserializedData = data.map<[string, Response]>(([key, value]) => [
|
||||||
key,
|
key,
|
||||||
deserializeResponse(value),
|
deserializeResponse(value),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Do not include cache where the key is no longer in the CMS
|
||||||
|
deserializedData = deserializedData.filter(([key]) => allPagesUrls.includes(key));
|
||||||
|
|
||||||
this.responseCache = new Map(deserializedData);
|
this.responseCache = new Map(deserializedData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -55,48 +77,27 @@ export class PageCache {
|
||||||
]);
|
]);
|
||||||
this.invalidationMap = new Map(deserialize);
|
this.invalidationMap = new Map(deserialize);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
const { data: languages } = await this.payload.getLanguages();
|
|
||||||
const locales = languages.map(({ id }) => id);
|
|
||||||
|
|
||||||
await this.precache("/", locales);
|
|
||||||
await this.precache("/settings", locales);
|
|
||||||
await this.precache("/timeline", locales);
|
|
||||||
|
|
||||||
const { data: folders } = await this.payload.getFolderSlugs();
|
|
||||||
for (const slug of folders) {
|
|
||||||
await this.precache(`/folders/${slug}`, locales);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: pages } = await this.payload.getPageSlugs();
|
const cacheSizeBeforePrecaching = this.responseCache.size;
|
||||||
for (const slug of pages) {
|
|
||||||
await this.precache(`/pages/${slug}`, locales);
|
for (const url of allPagesUrls) {
|
||||||
|
// Do not precache response if already included in the loaded cache from disk
|
||||||
|
if (this.responseCache.has(url)) continue;
|
||||||
|
try {
|
||||||
|
await fetch(`http://${import.meta.env.ASTRO_HOST}:${import.meta.env.ASTRO_PORT}${url}`);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn("Precaching failed for page", url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: collectibles } = await this.payload.getCollectibleSlugs();
|
if (cacheSizeBeforePrecaching !== this.responseCache.size) {
|
||||||
for (const slug of collectibles) {
|
this.scheduleSave();
|
||||||
await this.precache(`/collectibles/${slug}`, locales);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.save();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log("Precaching completed!", this.responseCache.size, "responses cached");
|
this.logger.log("Precaching completed!", this.responseCache.size, "responses cached");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async precache(pathname: string, locales: string[]) {
|
|
||||||
try {
|
|
||||||
await Promise.all(
|
|
||||||
locales.map((locale) => {
|
|
||||||
const url = `http://${import.meta.env.ASTRO_HOST}:${import.meta.env.ASTRO_PORT}/${locale}${pathname}`;
|
|
||||||
return fetch(url);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
this.logger.warn("Precaching failed for page", pathname);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get(url: string): Response | undefined {
|
get(url: string): Response | undefined {
|
||||||
const cachedPage = this.responseCache.get(url);
|
const cachedPage = this.responseCache.get(url);
|
||||||
if (cachedPage) {
|
if (cachedPage) {
|
||||||
|
@ -119,7 +120,7 @@ export class PageCache {
|
||||||
this.responseCache.set(url, response.clone());
|
this.responseCache.set(url, response.clone());
|
||||||
this.logger.log("Cached response for", url);
|
this.logger.log("Cached response for", url);
|
||||||
if (this.initialized) {
|
if (this.initialized) {
|
||||||
this.save();
|
this.scheduleSave();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -145,10 +146,19 @@ export class PageCache {
|
||||||
|
|
||||||
this.logger.log("There are currently", this.responseCache.size, "responses in cache.");
|
this.logger.log("There are currently", this.responseCache.size, "responses in cache.");
|
||||||
if (this.initialized) {
|
if (this.initialized) {
|
||||||
this.save();
|
this.scheduleSave();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scheduleSave() {
|
||||||
|
if (this.scheduleSaveTimeout) {
|
||||||
|
clearTimeout(this.scheduleSaveTimeout);
|
||||||
|
}
|
||||||
|
this.scheduleSaveTimeout = setTimeout(() => {
|
||||||
|
this.save();
|
||||||
|
}, 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
private async save() {
|
private async save() {
|
||||||
if (!existsSync(ON_DISK_ROOT)) {
|
if (!existsSync(ON_DISK_ROOT)) {
|
||||||
await mkdir(ON_DISK_ROOT, { recursive: true });
|
await mkdir(ON_DISK_ROOT, { recursive: true });
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
import { contextCache, dataCache, pageCache } from "src/utils/payload";
|
import { dataCache, pageCache } from "src/utils/payload";
|
||||||
|
|
||||||
export const GET: APIRoute = async () => {
|
export const GET: APIRoute = async () => {
|
||||||
await dataCache.init();
|
await dataCache.init();
|
||||||
await contextCache.init();
|
|
||||||
await pageCache.init();
|
await pageCache.init();
|
||||||
return new Response(null, { status: 200, statusText: "Ok" });
|
return new Response(null, { status: 200, statusText: "Ok" });
|
||||||
};
|
};
|
||||||
|
|
|
@ -4,17 +4,28 @@ import { PageCache } from "src/cache/pageCache";
|
||||||
import { TokenCache } from "src/cache/tokenCache";
|
import { TokenCache } from "src/cache/tokenCache";
|
||||||
import { PayloadSDK } from "src/shared/payload/payload-sdk";
|
import { PayloadSDK } from "src/shared/payload/payload-sdk";
|
||||||
|
|
||||||
|
const tokenCache = new TokenCache();
|
||||||
|
|
||||||
const payload = new PayloadSDK(
|
const payload = new PayloadSDK(
|
||||||
import.meta.env.PAYLOAD_API_URL,
|
import.meta.env.PAYLOAD_API_URL,
|
||||||
import.meta.env.PAYLOAD_USER,
|
import.meta.env.PAYLOAD_USER,
|
||||||
import.meta.env.PAYLOAD_PASSWORD
|
import.meta.env.PAYLOAD_PASSWORD
|
||||||
);
|
);
|
||||||
|
payload.addTokenCache(tokenCache);
|
||||||
|
|
||||||
|
const uncachedPayload = new PayloadSDK(
|
||||||
|
import.meta.env.PAYLOAD_API_URL,
|
||||||
|
import.meta.env.PAYLOAD_USER,
|
||||||
|
import.meta.env.PAYLOAD_PASSWORD
|
||||||
|
);
|
||||||
|
uncachedPayload.addTokenCache(tokenCache);
|
||||||
|
const pageCache = new PageCache(uncachedPayload);
|
||||||
|
|
||||||
|
// Loading context cache first so that the server can still serve responses while precaching.
|
||||||
const contextCache = new ContextCache(payload);
|
const contextCache = new ContextCache(payload);
|
||||||
const pageCache = new PageCache(payload);
|
await contextCache.init();
|
||||||
const dataCache = new DataCache(payload, (urls) => pageCache.invalidate(urls));
|
|
||||||
|
|
||||||
payload.addTokenCache(new TokenCache());
|
const dataCache = new DataCache(payload, uncachedPayload, (urls) => pageCache.invalidate(urls));
|
||||||
payload.addDataCache(dataCache);
|
payload.addDataCache(dataCache);
|
||||||
|
|
||||||
export { payload, contextCache, pageCache, dataCache };
|
export { payload, contextCache, pageCache, dataCache };
|
||||||
|
|
Loading…
Reference in New Issue