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

Contentful/cache size limit #1642

Merged
merged 4 commits into from
Jul 8, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
TopContent,
TopContentType,
} from '../cms'
import { ContentfulOptions } from '../plugin'
import { ContentfulOptions, DEFAULT_FALLBACK_CACHE_LIMIT_KB } from '../plugin'
import { SearchCandidate } from '../search'
import { AssetDelivery } from './contents/asset'
import { ButtonDelivery } from './contents/button'
Expand Down Expand Up @@ -64,20 +64,24 @@ export class Contentful implements cms.CMS {
* https://www.contentful.com/developers/docs/javascript/tutorials/using-js-cda-sdk/
*/
constructor(options: ContentfulOptions) {
const logger = options.logger ?? console.error
const reporter: ClientApiErrorReporter = (
msg: string,
func: string,
args,
error: any
) => {
console.error(
`${msg}. '${func}(${String(args)})' threw '${String(error)}'`
)
logger(`${msg}. '${func}(${String(args)})' threw '${String(error)}'`)
return Promise.resolve()
}
let client: ReducedClientApi = createContentfulClientApi(options)
if (!options.disableFallbackCache) {
client = new FallbackCachedClientApi(client, reporter)
client = new FallbackCachedClientApi(
client,
options.fallbackCacheLimitKB ?? DEFAULT_FALLBACK_CACHE_LIMIT_KB,
reporter,
logger
)
}
if (!options.disableCache) {
client = new CachedClientApi(client, options.cacheTtlMs, reporter)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as contentful from 'contentful'
import { ContentType } from 'contentful'

import { InMemoryCache, LimitedCacheDecorator } from '../../util/cache'
import { fallbackStrategy, Memoizer } from '../../util/memoizer'
import {
ClientApiErrorReporter,
Expand All @@ -21,39 +22,58 @@ export class FallbackCachedClientApi implements ReducedClientApi {
readonly getEntries: GetEntriesType
readonly getEntry: GetEntryType
readonly getContentType: (id: string) => Promise<ContentType>
private static readonly NUM_APIS = 5
private numMemoizations = 0

constructor(
readonly client: ReducedClientApi,
reporter: ClientApiErrorReporter
client: ReducedClientApi,
cacheLimitKB: number,
reporter: ClientApiErrorReporter,
logger: (msg: string) => void = console.error
) {
// TODO share the same cache for all APIs to avoid reaching a Memoizer limit
// while others have empty space
const memoizerCache = () =>
new LimitedCacheDecorator(
new InMemoryCache<any>(),
cacheLimitKB / FallbackCachedClientApi.NUM_APIS,
logger
)
// We could maybe use a more optimal normalizer than jsonNormalizer
// (like they do in fast-json-stringify to avoid JSON.stringify for functions with a single nulls, numbers and booleans).
// But it's not worth since stringify will have a cost much lower than constructing/rendering a botonic component
// (and we're already optimizing the costly call to CMS)
this.memoizer = new Memoizer(
fallbackStrategy((f, args, e) =>
this.memoizer = new Memoizer({
cacheFactory: memoizerCache,
strategy: fallbackStrategy((f, args, e) =>
reporter(
`Successfully used cached fallback after Contentful API error`,
f,
args,
e
)
)
)
),
})
this.getAsset = this.memoize(client.getAsset.bind(client))
this.getAssets = this.memoize(client.getAssets.bind(client))
this.getEntries = this.memoize(
client.getEntries.bind(client)
) as GetEntriesType
this.getEntry = this.memoize(client.getEntry.bind(client)) as GetEntryType
this.getContentType = this.memoize(client.getContentType.bind(client))
if (this.numMemoizations != FallbackCachedClientApi.NUM_APIS) {
throw new Error(
'FallbackCachedClientApi.NUM_APIS must equal the number of memoized APIs'
)
}
}

memoize<
Args extends any[],
Return,
F extends (...args: Args) => Promise<Return>
>(func: F): F {
this.numMemoizations++
return this.memoizer.memoize<Args, Return, F>(func)
}
}
5 changes: 5 additions & 0 deletions packages/botonic-plugin-contentful/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface CmsOptions extends OptionsBase {

export const DEFAULT_TIMEOUT_MS = 30000
export const DEFAULT_CACHE_TTL_MS = 10000
export const DEFAULT_FALLBACK_CACHE_LIMIT_KB = 100 * 1024

export interface ContentfulCredentials {
spaceId: string
Expand All @@ -50,6 +51,10 @@ export interface ContentfulOptions extends OptionsBase, ContentfulCredentials {
* fail.
*/
disableFallbackCache?: boolean
/**
* {@link DEFAULT_FALLBACK_CACHE_LIMIT_KB} by default
*/
fallbackCacheLimitKB?: number

contentfulFactory?: (opts: ContentfulOptions) => cms.CMS

Expand Down
138 changes: 138 additions & 0 deletions packages/botonic-plugin-contentful/src/util/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { roughSizeOfObject } from './objects'

export const NOT_FOUND_IN_CACHE = Symbol('NOT_FOUND')

export interface Cache<V> {
set(id: string, val: V): void
get(id: string): V | typeof NOT_FOUND_IN_CACHE
has(id: string): boolean
del(id: string): void

/**
* @return size in KB
*/
size(): number

/**
* Provides the Cache keys in undefined order 1 by 1
* @param getMoreKeys will return true if the consumer of the keys wants another key
*/
keys(): Generator<string>
}

export class InMemoryCache<V> implements Cache<V> {
private cache: Record<string, V> = {}
private sizeBytes = 0

set(id: string, val: V): void {
this.del(id)
this.sizeBytes += roughSizeOfObject(id) + roughSizeOfObject(val)
this.cache[id] = val
}

del(id: string): void {
const val = this.get(id)
if (val == NOT_FOUND_IN_CACHE) {
return
}
delete this.cache[id]
this.sizeBytes -= roughSizeOfObject(id) + roughSizeOfObject(val)
}

get(id: string): V | typeof NOT_FOUND_IN_CACHE {
// Cache.has is only checked when undefined to avoid always searching twice in the object
const val = this.cache[id]
if (val === undefined && !this.has(id)) {
return NOT_FOUND_IN_CACHE
}
return val
}

has(id: string): boolean {
return id in this.cache
}

size(): number {
return this.sizeBytes / 1024
}

len(): number {
return Object.keys(this.cache).length
}

*keys(): Generator<string> {
for (const k of Object.keys(this.cache)) {
yield k
}
}
}

/**
* Decorates a cache by limiting its size.
*
* TODO Use an external library to have a LRU cache. However, it's not critical
* because typically data in CMS is small (we're not caching media, only their
* URLs)
*/
export class LimitedCacheDecorator<V> implements Cache<V> {
constructor(
private readonly cache: Cache<V>,
readonly limitKB: number,
private readonly logger: (msg: string) => void = console.error,
readonly sizeWarningsRatio = [0.5, 0.75, 0.9]
) {}

set(id: string, val: V): void {
const incBytes = roughSizeOfObject(id) + roughSizeOfObject(val)
const checkFit = () =>
this.cache.size() * 1024 + incBytes <= this.limitKB * 1024
let itFits = checkFit()

if (!itFits) {
for (const k of this.cache.keys()) {
if (itFits) {
break
}
this.cache.del(k)
itFits = checkFit()
}
if (!itFits) {
this.logger(
`Cannot add entry in cache because IT ALONE is larger than max capacity(${this.limitKB})`
)
return
}
}
this.cache.set(id, val)
this.warnSizeRatio(incBytes / 1024)
}

warnSizeRatio(incKB: number): void {
const sizeKB = this.size()
for (const warnRatio of this.sizeWarningsRatio.sort((a, b) => b - a)) {
if (sizeKB / this.limitKB >= warnRatio && sizeKB - incKB < warnRatio) {
this.logger(`Cache is now more than ${warnRatio * 100}% full`)
return
}
}
}

get(id: string): V | typeof NOT_FOUND_IN_CACHE {
return this.cache.get(id)
}

has(id: string): boolean {
return this.cache.has(id)
}
del(id: string): void {
return this.cache.del(id)
}

keys(): Generator<string> {
return this.cache.keys()
}

size(): number {
return this.cache.size()
}
}
51 changes: 24 additions & 27 deletions packages/botonic-plugin-contentful/src/util/memoizer.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,4 @@
export class Cache<V> {
static readonly NOT_FOUND = Symbol('NOT_FOUND')
cache: Record<string, V> = {}
set(id: string, val: V): void {
this.cache[id] = val
}
get(id: string): V | typeof Cache.NOT_FOUND {
// Cache.has is only checked when undefined to avoid always searching twice in the object
const val = this.cache[id]
if (val === undefined && !this.has(id)) {
return Cache.NOT_FOUND
}
return val
}
has(id: string): boolean {
return id in this.cache
}
}
import { Cache, InMemoryCache, NOT_FOUND_IN_CACHE } from './cache'

export type MemoizerNormalizer = (...args: any) => string

Expand All @@ -34,21 +17,35 @@ export type MemoizerStrategy = <Args extends any[], Return>(
...args: Args
) => Promise<Return>

export interface MemoizerOptions {
strategy: MemoizerStrategy
cacheFactory?: () => Cache<any>
normalizer?: MemoizerNormalizer
}

export class Memoizer {
constructor(
private readonly strategy: MemoizerStrategy,
private readonly cacheFactory = () => new Cache<any>(),
private readonly normalizer = jsonNormalizer
) {}
opts: Required<MemoizerOptions>
constructor(opts: MemoizerOptions) {
this.opts = {
strategy: opts.strategy,
normalizer: opts.normalizer || jsonNormalizer,
cacheFactory: opts.cacheFactory || (() => new InMemoryCache<any>()),
}
}

memoize<
Args extends any[],
Return,
F extends (...args: Args) => Promise<Return>
>(func: F): F {
const cache: Cache<Return> = this.cacheFactory()
const cache: Cache<Return> = this.opts.cacheFactory()
const f = (...args: Args) =>
this.strategy<Args, Return>(cache, this.normalizer, func, ...args)
this.opts.strategy<Args, Return>(
cache,
this.opts.normalizer,
func,
...args
)
return f as F
}
}
Expand All @@ -67,7 +64,7 @@ export const cacheForeverStrategy: MemoizerStrategy = async <
) => {
const id = normalizer(...args)
let val = cache.get(id)
if (val === Cache.NOT_FOUND) {
if (val === NOT_FOUND_IN_CACHE) {
val = await func(...args)
cache.set(id, val)
}
Expand All @@ -94,7 +91,7 @@ export function fallbackStrategy(
cache.set(id, newVal)
return newVal
} catch (e) {
if (oldVal !== Cache.NOT_FOUND) {
if (oldVal !== NOT_FOUND_IN_CACHE) {
await usingFallback(String(func.name), args, e)
return oldVal
}
Expand Down
34 changes: 34 additions & 0 deletions packages/botonic-plugin-contentful/src/util/objects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,37 @@ export interface Stringable {
}

export interface ValueObject extends Equatable, Stringable {}

/**
* It returns a ROUGH estimation, since V8 will greatly optimize anyway
* @return the number of bytes
* Not using https://www.npmjs.com/package/object-sizeof to minimize dependencies
*/
export function roughSizeOfObject(object: any): number {
const objectList: any[] = []

const recurse = function (value: any) {
let bytes = 0
if (typeof value === 'boolean') {
bytes = 4
} else if (typeof value === 'string') {
bytes = value.length * 2
} else if (typeof value === 'number') {
bytes = 8
} else if (value == null) {
// Required because typeof null == 'object'
bytes = 0
} else if (typeof value === 'object' && objectList.indexOf(value) === -1) {
objectList.push(value)
for (const [k, v] of Object.entries(value)) {
bytes += 8 // an assumed existence overhead
bytes += recurse(k)
bytes += recurse(v)
}
}

return bytes
}

return recurse(object)
}
Loading