├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── jest.config.js ├── package.json ├── src ├── builder │ ├── CacheBuilder.ts │ └── index.ts ├── cache │ ├── AbstractCache.ts │ ├── Cache.ts │ ├── CacheNode.ts │ ├── CacheSPI.ts │ ├── CommonCacheOptions.ts │ ├── KeyType.ts │ ├── RemovalListener.ts │ ├── RemovalReason.ts │ ├── Weigher.ts │ ├── WrappedCache.ts │ ├── bounded │ │ ├── BoundedCache.ts │ │ ├── CountMinSketch.ts │ │ ├── hashcode.ts │ │ └── index.ts │ ├── boundless │ │ ├── BoundlessCache.ts │ │ └── index.ts │ ├── expiration │ │ ├── Expirable.ts │ │ ├── ExpirationCache.ts │ │ ├── MaxAgeDecider.ts │ │ ├── TimerWheel.ts │ │ └── index.ts │ ├── index.ts │ ├── loading │ │ ├── DefaultLoadingCache.ts │ │ ├── Loader.ts │ │ ├── LoadingCache.ts │ │ └── index.ts │ ├── metrics │ │ ├── Metrics.ts │ │ ├── MetricsCache.ts │ │ └── index.ts │ └── symbols.ts ├── index.ts └── utils │ └── memoryEstimator.ts ├── test ├── bounded.test.ts ├── boundless.test.ts ├── builder.test.ts ├── expiration.test.ts ├── hashcode.test.ts ├── loading.test.ts ├── memoryEstimator.test.ts ├── metrics.test.ts ├── removal-helper.ts ├── sketch.test.ts └── timer-wheel.test.ts ├── tsconfig.eslint.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [package.json] 16 | indent_style = space 17 | indent_size = 2 18 | 19 | [*.yml] 20 | indent_style = space 21 | indent_size = 2 22 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: './tsconfig.eslint.json' 5 | }, 6 | plugins: [ 7 | '@typescript-eslint', 8 | 'eslint-plugin-tsdoc', 9 | 'eslint-plugin-jsdoc', 10 | 'eslint-plugin-import' 11 | ], 12 | extends: [ 13 | 'eslint:recommended', 14 | 'plugin:@typescript-eslint/eslint-recommended', 15 | 'plugin:import/typescript', 16 | ], 17 | rules: { 18 | 'no-irregular-whitespace': 'error', 19 | 'quotes': [ 'error', 'single' ], 20 | 'no-unused-vars': [ 'off' ], 21 | 'eqeqeq': [ 'error' ], 22 | 'no-throw-literal': [ 'error' ], 23 | 'no-shadow': [ 'off' ], 24 | 'no-console': [ 'warn' ], 25 | 'no-debugger': [ 'error' ], 26 | 'no-alert': [ 'error' ], 27 | 28 | 'semi-spacing': [ 'warn', { 29 | before: false, 30 | after: true 31 | } ], 32 | 'no-multi-spaces': [ 'warn' ], 33 | 'space-unary-ops': [ 'warn', { 34 | words: true, 35 | nonwords: false, 36 | overrides: { 37 | '!': true, 38 | '!!': true 39 | } 40 | } ], 41 | 42 | '@typescript-eslint/adjacent-overload-signatures': [ 'warn' ], 43 | '@typescript-eslint/await-thenable': [ 'warn' ], 44 | '@typescript-eslint/ban-types': [ 45 | 'error', 46 | { 47 | extendDefaults: true, 48 | types: { 49 | object: false 50 | } 51 | } 52 | ], 53 | '@typescript-eslint/consistent-type-assertions': [ 'error' ], 54 | '@typescript-eslint/consistent-type-definitions': [ 'error' ], 55 | '@typescript-eslint/explicit-member-accessibility': [ 56 | 'error', 57 | { 58 | 'accessibility': 'explicit', 59 | 'overrides': { 60 | 'properties': 'off' 61 | } 62 | } 63 | ], 64 | '@typescript-eslint/indent': [ 'error', 'tab', { 65 | SwitchCase: 1, 66 | ignoredNodes: [ 67 | 'TSTypeLiteral', 68 | 'TSUnionType' 69 | ] 70 | } ], 71 | '@typescript-eslint/member-delimiter-style': [ 72 | 'error', 73 | { 74 | 'multiline': { 75 | 'delimiter': 'semi', 76 | 'requireLast': true 77 | }, 78 | 'singleline': { 79 | 'delimiter': 'semi', 80 | 'requireLast': false 81 | } 82 | } 83 | ], 84 | '@typescript-eslint/member-ordering': [ 'off' ], 85 | '@typescript-eslint/no-array-constructor': [ 'warn' ], 86 | '@typescript-eslint/no-empty-function': [ 'warn' ], 87 | '@typescript-eslint/no-empty-interface': [ 'warn' ], 88 | '@typescript-eslint/no-explicit-any': [ 'off' ], 89 | '@typescript-eslint/no-misused-new': [ 'error' ], 90 | '@typescript-eslint/no-namespace': [ 'error' ], 91 | '@typescript-eslint/no-non-null-assertion': [ 'error' ], 92 | '@typescript-eslint/no-parameter-properties': [ 'error' ], 93 | '@typescript-eslint/no-shadow': [ 'warn' ], 94 | '@typescript-eslint/no-var-requires': [ 'error' ], 95 | '@typescript-eslint/no-unnecessary-type-assertion': [ 'warn' ], 96 | '@typescript-eslint/no-unused-vars': [ 'warn', { 97 | 'vars': 'all', 98 | 'args': 'after-used', 99 | 'ignoreRestSiblings': false 100 | } ], 101 | '@typescript-eslint/prefer-function-type': [ 'error' ], 102 | '@typescript-eslint/prefer-namespace-keyword': [ 'error' ], 103 | '@typescript-eslint/quotes': [ 104 | 'error', 105 | 'single', 106 | { 107 | 'avoidEscape': true 108 | } 109 | ], 110 | '@typescript-eslint/semi': [ 'error', 'always' ], 111 | '@typescript-eslint/triple-slash-reference': [ 'error' ], 112 | '@typescript-eslint/unified-signatures': [ 'off' ], 113 | '@typescript-eslint/no-floating-promises': [ 'error' ], 114 | 115 | 'prefer-const': [ 'warn' ], 116 | 'no-var': [ 'error' ], 117 | 'no-param-reassign': [ 'warn' ], 118 | 'no-multi-assign': [ 'warn' ], 119 | 'no-unneeded-ternary': [ 'warn' ], 120 | 'no-mixed-operators': [ 'warn' ], 121 | 'nonblock-statement-body-position': [ 'warn' ], 122 | 123 | 'no-extra-semi': [ 'off' ], 124 | '@typescript-eslint/no-extra-semi': [ 'error' ], 125 | 'brace-style': [ 'off' ], 126 | '@typescript-eslint/brace-style': [ 'warn', '1tbs' ], 127 | 'comma-spacing': [ 'off' ], 128 | '@typescript-eslint/comma-spacing': [ 'warn', { 129 | before: false, 130 | after: true 131 | } ], 132 | 'comma-style': [ 'warn', 'last' ], 133 | 'func-call-spacing': [ 'off' ], 134 | '@typescript-eslint/func-call-spacing': [ 'warn' ], 135 | 'space-before-function-paren': [ 'off' ], 136 | '@typescript-eslint/space-before-function-paren': [ 'warn', { 137 | 'anonymous': 'never', 138 | 'named': 'never', 139 | 'asyncArrow': 'always' 140 | } ], 141 | 'quote-props': [ 'warn', 'as-needed' ], 142 | 'arrow-spacing': [ 'warn', { 143 | before: true, 144 | after: true 145 | } ], 146 | 'arrow-parens': [ 'warn', 'as-needed' ], 147 | 'generator-star-spacing': [ 'warn', { 148 | before: true, 149 | after: false 150 | } ], 151 | 'dot-notation': [ 'warn' ], 152 | 'spaced-comment': [ 'warn' ], 153 | 'space-before-blocks': [ 'warn' ], 154 | 'keyword-spacing': [ 'warn', { 155 | before: true, 156 | after: true, 157 | overrides: { 158 | 'if': { after: false }, 159 | 'for': { after: false }, 160 | 'while': { after: false }, 161 | 'switch': { after: false }, 162 | 'catch': { after: false }, 163 | } 164 | } ], 165 | 'space-infix-ops': [ 'warn' ], 166 | 'padded-blocks': [ 'warn', 'never' ], 167 | 'space-in-parens': [ 'warn', 'never' ], 168 | 'no-multiple-empty-lines': [ 'warn' ], 169 | 'array-bracket-spacing': [ 'warn', 'always' ], 170 | 'object-curly-spacing': [ 'warn', 'always' ], 171 | 'block-spacing': [ 'warn', 'always' ], 172 | 'computed-property-spacing': [ 'warn', 'never' ], 173 | 'key-spacing': [ 'warn', { 174 | beforeColon: false, 175 | afterColon: true, 176 | mode: 'strict' 177 | } ], 178 | 179 | 'tsdoc/syntax': [ 'warn' ], 180 | 'jsdoc/check-alignment': [ 'warn' ], 181 | 'jsdoc/check-param-names': [ 'warn', { 182 | checkDestructured: false 183 | } ], 184 | 'jsdoc/check-syntax': [ 'warn' ], 185 | 'jsdoc/empty-tags': [ 'warn' ], 186 | 'jsdoc/newline-after-description': [ 'warn' ], 187 | 'jsdoc/no-types': [ 'warn' ], 188 | 'jsdoc/require-description': [ 'warn', { 189 | contexts: [ 'any' ] 190 | } ], 191 | 'jsdoc/require-jsdoc': [ 'warn' ], 192 | 'jsdoc/require-param': [ 'warn', { 193 | checkDestructured: false 194 | } ], 195 | 'jsdoc/require-param-description': [ 'warn' ], 196 | 'jsdoc/require-param-name': [ 'warn' ], 197 | 'jsdoc/require-returns': [ 'warn' ], 198 | 'jsdoc/require-returns-check': [ 'warn' ], 199 | 'jsdoc/require-returns-description': [ 'warn' ], 200 | 'jsdoc/check-tag-names': [ 'error', { 201 | definedTags: [ 'remarks', 'typeParam' ] 202 | } ], 203 | 204 | 'import/extensions': [ 'error' ], 205 | 'import/first': [ 'warn' ], 206 | 'import/no-self-import': [ 'error' ], 207 | 208 | 'import/newline-after-import': [ 'warn' ], 209 | 'import/no-dynamic-require': [ 'warn' ], 210 | 'import/no-useless-path-segments': [ 'warn' ], 211 | 'import/no-default-export': [ 'warn' ], 212 | 'import/no-namespace': [ 'warn' ], 213 | 'import/no-duplicates': [ 'warn' ], 214 | 'import/order': [ 'warn', { 215 | 'newlines-between': 'always', 216 | groups: [ 'builtin', 'external', 'internal', 'parent', 'sibling', 'index' ], 217 | pathGroupsExcludedImportTypes: [ 'builtin' ], 218 | alphabetize: { 219 | order: 'asc', 220 | caseInsensitive: true 221 | } 222 | } ], 223 | }, 224 | 225 | settings: { 226 | jsdoc: { 227 | mode: 'typescript' 228 | } 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node: [ 14.x, 16.x, 18.x ] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v1 22 | with: 23 | node-version: ${{ matrix.node }} 24 | - run: npm install 25 | - run: npm run lint 26 | - run: npm run build 27 | - run: npm run test 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | coverage 4 | npm-debug.log 5 | package-lock.json 6 | /yarn.lock 7 | .vscode/ 8 | .history/ 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | coverage 2 | examples 3 | test 4 | .* 5 | docs 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Andreas Holstenson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Transitory 2 | 3 | [![npm version](https://badge.fury.io/js/transitory.svg)](https://badge.fury.io/js/transitory) 4 | [![Build Status](https://travis-ci.org/aholstenson/transitory.svg?branch=master)](https://travis-ci.org/aholstenson/transitory) 5 | [![Coverage Status](https://coveralls.io/repos/aholstenson/transitory/badge.svg)](https://coveralls.io/github/aholstenson/transitory) 6 | [![Dependencies](https://david-dm.org/aholstenson/transitory.svg)](https://david-dm.org/aholstenson/transitory) 7 | 8 | Transitory is an in-memory cache for Node and browsers, with high hit rates 9 | using eviction based on frequency and recency. Additional cache layers support 10 | time-based expiration, automatic loading and metrics. 11 | 12 | ```javascript 13 | import { newCache } from 'transitory'; 14 | 15 | const cache = newCache() 16 | .maxSize(1000) 17 | .expireAfterWrite(60000) // 60 seconds 18 | .build(); 19 | 20 | cache.set('key', { value: 10 }); 21 | cache.set(1234, 'any value'); 22 | 23 | const value = cache.getIfPresent('key'); 24 | ``` 25 | 26 | Using TypeScript: 27 | 28 | ```typescript 29 | import { newCache, BoundlessCache } from 'transitory'; 30 | 31 | const cache: Cache = newCache() 32 | .maxSize(1000) 33 | .build(); 34 | 35 | const cacheWithoutBuilder = new BoundlessCache({}); 36 | ``` 37 | 38 | ## Supported features 39 | 40 | * Limiting cache size to a total number of items 41 | * Limiting cache size based on the weight of items 42 | * LFU (least-frequently used) eviction of items 43 | * Listener for evicted and removed items 44 | * Expiration of items a certain time after they were stored in the cache 45 | * Expiration of items based on if they haven't been read for a certain time 46 | * Automatic loading if a value is not cached 47 | * Collection of metrics about hit rates 48 | 49 | ## Performance 50 | 51 | The caches in this library are designed to have a high hit rate by evicting 52 | entries in the cache that are not frequently used. Transitory implements 53 | [W-TinyLFU](https://arxiv.org/abs/1512.00727) as its eviction policy which is 54 | a LFU policy that provides good hit rates for many use cases. 55 | 56 | See [Performance](https://github.com/aholstenson/transitory/wiki/Performance) 57 | in the wiki for comparisons of the hit rate of Transitory to other libraries. 58 | 59 | ## Cache API 60 | 61 | There are a few basic things that all caches support. All caches support 62 | strings, numbers and booleans as their `KeyType`. 63 | 64 | * `cache.set(key: KeyType, value: ValueType): ValueType | null` 65 | 66 | Store a value tied to the specified key. Returns the previous value or 67 | `null` if no value currently exists for the given key. 68 | 69 | * `cache.getIfPresent(key: KeyType): ValueType | null` 70 | 71 | Get the cached value for the specified key if it exists. Will return 72 | the value or `null` if no cached value exist. Updates the usage of the 73 | key. This is the main way to get cached items, unless the cache is a 74 | loading cache. 75 | 76 | * `cache.get(key: KeyType, loader?): Promise` 77 | 78 | _For loading caches:_ Get a value loading it if it is not cached. Can 79 | optionally take a `loader` function that loads the value. 80 | 81 | * `cache.peek(key: KeyType): ValueType | null` 82 | 83 | Peek to see if a key is present without updating the usage of the 84 | key. Returns the value associated with the key or `null` if the key 85 | is not present. 86 | 87 | * `cache.has(key: KeyType): boolean` 88 | 89 | Check if the given key exists in the cache. 90 | 91 | * `cache.delete(key: KeyType): ValueType | null` 92 | 93 | Delete a value in the cache. Returns the removed value or `null` if there 94 | was no value associated with the key in the cache. 95 | 96 | * `cache.clear()` 97 | 98 | Clear the cache removing all of the entries cached. 99 | 100 | * `cache.keys(): KeyType[]` 101 | 102 | Get all of the keys in the cache as an `Array`. Can be used to iterate 103 | over all of the values in the cache, but be sure to protect against values 104 | being removed during iteration due to time-based expiration if used. 105 | 106 | * `cache.maxSize: number` 107 | 108 | The maximum size of the cache or `-1` if boundless. This size represents the 109 | weighted size of the cache. 110 | 111 | * `cache.size: number` 112 | 113 | The number of entries stored in the cache. This is the actual number of entries 114 | and not the weighted size of all of the entries in the cache. 115 | 116 | * `cache.weightedSize: number` 117 | 118 | Get the weighted size of the cache. This is the weight of all entries that 119 | are currently in the cache. 120 | 121 | * `cache.cleanUp()` 122 | 123 | _Advanced:_ Request clean up of the cache by removing expired entries and 124 | old data. Clean up is done automatically a short time after sets and 125 | deletes, but if your cache uses time-based expiration and has very 126 | sporadic updates it might be a good idea to call `cleanUp()` at times. 127 | A good starting point would be to call `cleanUp()` in a `setInterval` 128 | with a delay of at least a few minutes. 129 | 130 | * `cache.metrics: Metrics` 131 | 132 | _For metric enabled caches:_ Get metrics for this cache. Returns an object 133 | with the keys `hits`, `misses` and `hitRate`. For caches that do not have 134 | metrics enabled trying to access metrics will throw an error. 135 | 136 | ## Building a cache 137 | 138 | Caches are created via a builder that helps with adding on all requested 139 | functionality and returning a cache. 140 | 141 | A builder is created by calling the imported function: 142 | 143 | ```javascript 144 | import { newCache } from 'transitory'; 145 | const builder = newCache(); 146 | ``` 147 | 148 | Calls on the builder can be chained: 149 | 150 | ```javascript 151 | newCache().maxSize(100).loading().build(); 152 | ``` 153 | 154 | Or using caches directly for tree-shaking and better bundle sizes: 155 | 156 | ```javascript 157 | import { BoundedCache, ExpirationCache } from 'transitory'; 158 | 159 | const cache = new ExpirationCache({ 160 | maxWriteAge: 60000, 161 | parent: new BoundedCache({ 162 | maxSize: 1000 163 | }) 164 | }); 165 | ``` 166 | 167 | ## Unlimited size cache 168 | 169 | It's possible to create a cache without any limits, in which it acts like a 170 | standard `Map`. 171 | 172 | ```javascript 173 | // Using the builder 174 | const cache = newCache() 175 | .build(); 176 | 177 | // Using caches directly 178 | import { BoundlessCache } from 'transitory'; 179 | 180 | const cache = new BoundlessCache({}); 181 | ``` 182 | 183 | This is mostly useful if you have another layer of logic on top of it or if 184 | you're creating caches without the builder. 185 | 186 | ## Limiting the size of a cache 187 | 188 | Caches can be limited to a certain size. This type of cache will evict the 189 | least frequently used items when it reaches its maximum size. 190 | 191 | ```javascript 192 | // Using the builder 193 | const cache = newCache() 194 | .maxSize(100) 195 | .build(); 196 | 197 | // Using caches directly 198 | import { BoundedCache } from 'transitory'; 199 | 200 | const cache = new BoundedCache({ 201 | maxSize: 100 202 | }); 203 | ``` 204 | 205 | It is also possible to change how the size of each entry in the cache is 206 | calculated. This can be used to create a better cache if your entries vary in 207 | their size in memory. 208 | 209 | ```javascript 210 | // Using the builder 211 | const cache = newCache() 212 | .maxSize(2000) 213 | .withWeigher((key, value) => value.length) 214 | .build(); 215 | 216 | // Using caches directly 217 | import { BoundedCache } from 'transitory'; 218 | 219 | const cache = new BoundedCache({ 220 | maxSize: 2000, 221 | weigher: (key, value) => value.length 222 | }); 223 | ``` 224 | 225 | The size of an entry is evaluated when it is added to the cache so weighing 226 | works best with immutable data. Transitory includes a weigher for estimated 227 | memory: 228 | 229 | ```javascript 230 | import { memoryUsageWeigher } from 'transitory'; 231 | 232 | const cache = newCache() 233 | .maxSize(50000000) 234 | .withWeigher(memoryUsageWeigher) 235 | .build(); 236 | ``` 237 | 238 | ## Automatic expiry 239 | 240 | Limiting the maximum amount of time an entry can exist in the cache can be done 241 | by using `expireAfterWrite(time)` or `expireAfterRead(time)`. Entries 242 | are lazy evaluated and will be removed when the values are set or deleted from 243 | the cache. 244 | 245 | ```javascript 246 | // Using the builder 247 | const cache = newCache() 248 | .expireAfterWrite(5000) // 5 seconds 249 | .expireAfterRead(1000) // Values need to be read at least once a second 250 | .build(); 251 | ``` 252 | 253 | Both methods can also take a function that should return the maximum age 254 | of the entry in milliseconds: 255 | 256 | ```javascript 257 | // Using the builder 258 | const cache = newCache() 259 | .expireAfterWrite((key, value) => 5000) 260 | .expireAfterRead((key, value) => 5000 / key.length) 261 | .build(); 262 | ``` 263 | 264 | Using caches directly requires a parent cache and that functions are always 265 | passed: 266 | 267 | ```javascript 268 | import { BoundlessCache } from 'transitory'; 269 | 270 | const cache = new ExpirationCache({ 271 | maxWriteAge: () => 5000, 272 | maxNoReadAge: () => 1000, 273 | 274 | parent: new BoundlessCache({}); 275 | }); 276 | ``` 277 | 278 | ## Loading caches 279 | 280 | Caches can be made to automatically load values if they are not in the cache. 281 | This type of caches relies heavily on the use of promises. 282 | 283 | With a global loader: 284 | 285 | ```javascript 286 | // Using the builder 287 | const cache = newCache() 288 | .withLoader(key => loadSlowData(key)) 289 | .build(); 290 | 291 | // Using caches directly 292 | import { DefaultLoadingCache } from 'transitory'; 293 | 294 | const cache = new DefaultLoadingCache({ 295 | loader: key => loadSlowData(key), 296 | parent: new BoundlessCache({}) // or any other cache 297 | }); 298 | ``` 299 | 300 | Using a global loader is done by calling `cache.get(key)`, which returns a 301 | promise: 302 | 303 | ```javascript 304 | cache.get(781) 305 | .then(data => handleLoadedData(data)) 306 | .catch(err => handleError(err)); 307 | 308 | cache.get(1234, specialLoadingFunction) 309 | ``` 310 | 311 | Without a global loader: 312 | 313 | ```javascript 314 | // Using the builder 315 | const cache = newCache() 316 | .loading() 317 | .build(); 318 | 319 | // Using caches directly 320 | import { DefaultLoadingCache } from 'transitory'; 321 | 322 | const cache = new DefaultLoadingCache({ 323 | parent: new BoundlessCache({}) // or any other cache 324 | }); 325 | ``` 326 | 327 | Use via `cache.get(key, functionToLoadData)`: 328 | 329 | ```javascript 330 | cache.get(781, key => loadSlowData(key)) 331 | .then(data => handleLoadedData(data)) 332 | .catch(err => handleError(err)); 333 | ``` 334 | 335 | Loading caches can be combined with other things such as `maxSize`. 336 | 337 | ## Metrics 338 | 339 | You can track the hit rate of the cache by activating support for metrics: 340 | 341 | ```javascript 342 | // Using the builder 343 | const cache = newCache() 344 | .metrics() 345 | .build(); 346 | 347 | // Using caches directly 348 | import { MetricsCache } from 'transitory'; 349 | 350 | const cache = new MetricsCache({ 351 | parent: new BoundlessCache({}) 352 | }); 353 | ``` 354 | 355 | Fetching metrics: 356 | 357 | ```javascript 358 | const metrics = cache.metrics; 359 | 360 | console.log('hitRate=', metrics.hitRate); 361 | console.log('hits=', metrics.hits); 362 | console.log('misses=', metrics.misses); 363 | ``` 364 | 365 | ## Removal listener 366 | 367 | Caches support a single removal listener that will be notified when items in 368 | the cache are removed. 369 | 370 | ```javascript 371 | import { RemovalReason } from 'transitory'; 372 | 373 | const cache = newCache() 374 | .withRemovalListener((key, value, reason) => { 375 | switch(reason) { 376 | case RemovalReason.EXPLICIT: 377 | // The user of the cache requested something to be removed 378 | break; 379 | case RemovalReason.REPLACED: 380 | // A new value was loaded and this value was replaced 381 | break; 382 | case RemovalReason.SIZE: 383 | // A value was evicted from the cache because the max size has been reached 384 | break; 385 | case RemovalReason.EXPIRED: 386 | // A value was removed because it expired due to its max age 387 | break; 388 | } 389 | }) 390 | .build(); 391 | ``` 392 | 393 | When using caches directly the removal listener should go on the final cache: 394 | 395 | ```javascript 396 | import { LoadingCache, BoundlessCache } from 'transitory'; 397 | 398 | const cache = new LoadingCache({ 399 | removalListener: listenerFunction, 400 | 401 | parent: new BoundlessCache({}) 402 | }); 403 | ``` 404 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | ".(ts|tsx)": "ts-jest" 4 | }, 5 | "testEnvironment": "node", 6 | "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$", 7 | "moduleFileExtensions": [ 8 | "ts", 9 | "tsx", 10 | "js" 11 | ], 12 | "coveragePathIgnorePatterns": [ 13 | "/node_modules/", 14 | "/test/" 15 | ], 16 | "collectCoverageFrom": [ 17 | "src/**/*.{js,ts}" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "transitory", 3 | "version": "2.2.0", 4 | "description": "In-memory cache with high hit rates via LFU eviction. Supports time-based expiration, automatic loading and metrics.", 5 | "license": "MIT", 6 | "repository": "aholstenson/transitory", 7 | "scripts": { 8 | "test": "jest", 9 | "ci": "npm run coverage && npm run lint", 10 | "coverage": "jest --coverage", 11 | "lint": "eslint --ext .ts,.tsx .", 12 | "build": "tsc --module commonjs --target es5 --outDir dist/cjs && tsc --module es6 --target es6 --outDir dist/esm", 13 | "prebuild": "rimraf dist", 14 | "prepublishOnly": "npm run build" 15 | }, 16 | "main": "./dist/cjs/index.js", 17 | "module": "./dist/esm/index.js", 18 | "types": "./dist/types/index.d.ts", 19 | "engines": { 20 | "node": ">=8.0.0" 21 | }, 22 | "keywords": [ 23 | "cache", 24 | "caching", 25 | "lfu", 26 | "lru" 27 | ], 28 | "devDependencies": { 29 | "@types/jest": "^28.1.6", 30 | "@types/node": "^18.7.2", 31 | "@typescript-eslint/eslint-plugin": "^5.33.0", 32 | "@typescript-eslint/parser": "^5.33.0", 33 | "coveralls": "^3.1.1", 34 | "eslint": "^8.21.0", 35 | "eslint-plugin-import": "^2.26.0", 36 | "eslint-plugin-jsdoc": "^39.3.6", 37 | "eslint-plugin-tsdoc": "^0.2.16", 38 | "jest": "^28.1.3", 39 | "jest-config": "^28.1.3", 40 | "rimraf": "^3.0.2", 41 | "ts-jest": "^28.0.7", 42 | "typescript": "^4.7.4" 43 | }, 44 | "dependencies": {} 45 | } 46 | -------------------------------------------------------------------------------- /src/builder/CacheBuilder.ts: -------------------------------------------------------------------------------- 1 | import { BoundedCache } from '../cache/bounded/BoundedCache'; 2 | import { BoundlessCache } from '../cache/boundless/BoundlessCache'; 3 | import { Cache } from '../cache/Cache'; 4 | import { Expirable } from '../cache/expiration/Expirable'; 5 | import { ExpirationCache } from '../cache/expiration/ExpirationCache'; 6 | import { MaxAgeDecider } from '../cache/expiration/MaxAgeDecider'; 7 | import { KeyType } from '../cache/KeyType'; 8 | import { DefaultLoadingCache } from '../cache/loading/DefaultLoadingCache'; 9 | import { Loader } from '../cache/loading/Loader'; 10 | import { LoadingCache } from '../cache/loading/LoadingCache'; 11 | import { MetricsCache } from '../cache/metrics/MetricsCache'; 12 | import { RemovalListener } from '../cache/RemovalListener'; 13 | import { Weigher } from '../cache/Weigher'; 14 | 15 | export interface CacheBuilder { 16 | /** 17 | * Set a listener that will be called every time something is removed 18 | * from the cache. 19 | */ 20 | withRemovalListener(listener: RemovalListener): this; 21 | 22 | /** 23 | * Set the maximum number of items to keep in the cache before evicting 24 | * something. 25 | */ 26 | maxSize(size: number): this; 27 | 28 | /** 29 | * Set a function to use to determine the size of a cached object. 30 | */ 31 | withWeigher(weigher: Weigher): this; 32 | 33 | /** 34 | * Change to a cache where get can also resolve values if provided with 35 | * a function as the second argument. 36 | */ 37 | loading(): LoadingCacheBuilder; 38 | 39 | /** 40 | * Change to a loading cache, where the get-method will return instances 41 | * of Promise and automatically load unknown values. 42 | */ 43 | withLoader(loader: Loader): LoadingCacheBuilder; 44 | 45 | /** 46 | * Set that the cache should expire items some time after they have been 47 | * written to the cache. 48 | */ 49 | expireAfterWrite(time: number | MaxAgeDecider): this; 50 | 51 | /** 52 | * Set that the cache should expire items some time after they have been 53 | * read from the cache. 54 | */ 55 | expireAfterRead(time: number | MaxAgeDecider): this; 56 | 57 | /** 58 | * Activate tracking of metrics for this cache. 59 | */ 60 | metrics(): this; 61 | 62 | /** 63 | * Build the cache. 64 | */ 65 | build(): Cache; 66 | } 67 | 68 | export interface LoadingCacheBuilder extends CacheBuilder { 69 | /** 70 | * Build the cache. 71 | */ 72 | build(): LoadingCache; 73 | } 74 | 75 | /** 76 | * Builder for cache instances. 77 | */ 78 | export class CacheBuilderImpl implements CacheBuilder { 79 | private optRemovalListener?: RemovalListener; 80 | private optMaxSize?: number; 81 | private optWeigher?: Weigher; 82 | private optMaxWriteAge?: MaxAgeDecider; 83 | private optMaxNoReadAge?: MaxAgeDecider; 84 | private optMetrics: boolean = false; 85 | 86 | /** 87 | * Set a listener that will be called every time something is removed 88 | * from the cache. 89 | * 90 | * @param listener - 91 | * removal listener to use 92 | * @returns self 93 | */ 94 | public withRemovalListener(listener: RemovalListener) { 95 | this.optRemovalListener = listener; 96 | return this; 97 | } 98 | 99 | /** 100 | * Set the maximum number of items to keep in the cache before evicting 101 | * something. 102 | * 103 | * @param size - 104 | * number of items to keep 105 | * @returns self 106 | */ 107 | public maxSize(size: number) { 108 | this.optMaxSize = size; 109 | return this; 110 | } 111 | 112 | /** 113 | * Set a function to use to determine the size of a cached object. 114 | * 115 | * @param weigher - 116 | * function used to weight objects 117 | * @returns self 118 | */ 119 | public withWeigher(weigher: Weigher) { 120 | if(typeof weigher !== 'function') { 121 | throw new Error('Weigher should be a function that takes a key and value and returns a number'); 122 | } 123 | this.optWeigher = weigher; 124 | return this; 125 | } 126 | 127 | /** 128 | * Change to a cache where get can also resolve values if provided with 129 | * a function as the second argument. 130 | * 131 | * @returns self 132 | */ 133 | public loading(): LoadingCacheBuilder { 134 | return new LoadingCacheBuilderImpl(this, null); 135 | } 136 | 137 | /** 138 | * Change to a loading cache, where the get-method will return instances 139 | * of Promise and automatically load unknown values. 140 | * 141 | * @param loader - 142 | * function used to load objects 143 | * @returns self 144 | */ 145 | public withLoader(loader: Loader): LoadingCacheBuilder { 146 | if(typeof loader !== 'function') { 147 | throw new Error('Loader should be a function that takes a key and returns a value or a promise that resolves to a value'); 148 | } 149 | return new LoadingCacheBuilderImpl(this, loader); 150 | } 151 | 152 | /** 153 | * Set that the cache should expire items after some time. 154 | * 155 | * @param time - 156 | * max time in milliseconds, or function that will be asked per key/value 157 | * for expiration time 158 | * @returns self 159 | */ 160 | public expireAfterWrite(time: number | MaxAgeDecider) { 161 | let evaluator; 162 | if(typeof time === 'function') { 163 | evaluator = time; 164 | } else if(typeof time === 'number') { 165 | evaluator = () => time; 166 | } else { 167 | throw new Error('expireAfterWrite needs either a maximum age as a number or a function that returns a number'); 168 | } 169 | this.optMaxWriteAge = evaluator; 170 | return this; 171 | } 172 | 173 | /** 174 | * Set that the cache should expire items some time after they have been read. 175 | * 176 | * @param time - 177 | * max time in milliseconds, or function will be asked per key/value 178 | * for expiration time 179 | * @returns self 180 | */ 181 | public expireAfterRead(time: number | MaxAgeDecider): this { 182 | let evaluator; 183 | if(typeof time === 'function') { 184 | evaluator = time; 185 | } else if(typeof time === 'number') { 186 | evaluator = () => time; 187 | } else { 188 | throw new Error('expireAfterRead needs either a maximum age as a number or a function that returns a number'); 189 | } 190 | this.optMaxNoReadAge = evaluator; 191 | return this; 192 | } 193 | 194 | /** 195 | * Activate tracking of metrics for this cache. 196 | * 197 | * @returns self 198 | */ 199 | public metrics(): this { 200 | this.optMetrics = true; 201 | return this; 202 | } 203 | 204 | /** 205 | * Build and return the cache. 206 | * 207 | * @returns cache 208 | */ 209 | public build() { 210 | let cache: Cache; 211 | if(typeof this.optMaxWriteAge !== 'undefined' || typeof this.optMaxNoReadAge !== 'undefined') { 212 | /* 213 | * Requested expiration - wrap the base cache a bit as it needs 214 | * custom types, a custom weigher if used and removal listeners 215 | * are added on the expiration cache instead. 216 | */ 217 | let parentCache: Cache>; 218 | if(this.optMaxSize) { 219 | parentCache = new BoundedCache({ 220 | maxSize: this.optMaxSize, 221 | weigher: createExpirableWeigher(this.optWeigher) 222 | }); 223 | } else { 224 | parentCache = new BoundlessCache({}); 225 | } 226 | 227 | cache = new ExpirationCache({ 228 | maxNoReadAge: this.optMaxNoReadAge, 229 | maxWriteAge: this.optMaxWriteAge, 230 | 231 | removalListener: this.optRemovalListener, 232 | 233 | parent: parentCache 234 | }); 235 | } else { 236 | if(this.optMaxSize) { 237 | cache = new BoundedCache({ 238 | maxSize: this.optMaxSize, 239 | weigher: this.optWeigher, 240 | removalListener: this.optRemovalListener 241 | }); 242 | } else { 243 | cache = new BoundlessCache({ 244 | removalListener: this.optRemovalListener 245 | }); 246 | } 247 | } 248 | 249 | if(this.optMetrics) { 250 | // Collect metrics if requested 251 | cache = new MetricsCache({ 252 | parent: cache 253 | }); 254 | } 255 | 256 | return cache; 257 | } 258 | } 259 | 260 | class LoadingCacheBuilderImpl implements LoadingCacheBuilder { 261 | private parent: CacheBuilder; 262 | private loader: Loader | null; 263 | 264 | public constructor(parent: CacheBuilder, loader: Loader | null) { 265 | this.parent = parent; 266 | this.loader = loader; 267 | } 268 | 269 | public withRemovalListener(listener: RemovalListener): this { 270 | this.parent.withRemovalListener(listener); 271 | return this; 272 | } 273 | 274 | public maxSize(size: number): this { 275 | this.parent.maxSize(size); 276 | return this; 277 | } 278 | 279 | public withWeigher(weigher: Weigher): this { 280 | this.parent.withWeigher(weigher); 281 | return this; 282 | } 283 | 284 | public loading(): LoadingCacheBuilder { 285 | throw new Error('Already building a loading cache'); 286 | } 287 | 288 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 289 | public withLoader(loader: Loader): LoadingCacheBuilder { 290 | throw new Error('Already building a loading cache'); 291 | } 292 | 293 | public expireAfterWrite(time: number | MaxAgeDecider): this { 294 | this.parent.expireAfterWrite(time); 295 | return this; 296 | } 297 | 298 | public expireAfterRead(time: number | MaxAgeDecider): this { 299 | this.parent.expireAfterRead(time); 300 | return this; 301 | } 302 | 303 | public metrics(): this { 304 | this.parent.metrics(); 305 | return this; 306 | } 307 | 308 | public build(): LoadingCache { 309 | return new DefaultLoadingCache({ 310 | loader: this.loader, 311 | 312 | parent: this.parent.build() 313 | }); 314 | } 315 | } 316 | 317 | /** 318 | * Helper function to create a weigher that uses an Expirable object. 319 | * 320 | * @param w - 321 | * @returns weigher 322 | */ 323 | function createExpirableWeigher(w: Weigher | undefined): Weigher> | null { 324 | if(! w) return null; 325 | 326 | return (key, node) => w(key, node.value as V); 327 | } 328 | -------------------------------------------------------------------------------- /src/builder/index.ts: -------------------------------------------------------------------------------- 1 | import { KeyType } from '../cache/KeyType'; 2 | 3 | import { CacheBuilder, CacheBuilderImpl } from './CacheBuilder'; 4 | 5 | export { CacheBuilder, LoadingCacheBuilder } from './CacheBuilder'; 6 | 7 | /** 8 | * Create a new cache via a builder. 9 | * 10 | * @returns 11 | * builder of a cache 12 | */ 13 | export function newCache(): CacheBuilder { 14 | return new CacheBuilderImpl(); 15 | } 16 | -------------------------------------------------------------------------------- /src/cache/AbstractCache.ts: -------------------------------------------------------------------------------- 1 | import { Cache } from './Cache'; 2 | import { KeyType } from './KeyType'; 3 | import { Metrics } from './metrics/Metrics'; 4 | 5 | /** 6 | * Abstract class for all cache implementations. This exists so that its 7 | * possible to use instanceof with caches like: `obj instanceof AbstractCache`. 8 | */ 9 | export abstract class AbstractCache implements Cache { 10 | public abstract maxSize: number; 11 | public abstract size: number; 12 | public abstract weightedSize: number; 13 | 14 | public abstract set(key: K, value: V): V | null; 15 | public abstract getIfPresent(key: K): V | null; 16 | public abstract peek(key: K): V | null; 17 | public abstract has(key: K): boolean; 18 | public abstract delete(key: K): V | null; 19 | public abstract clear(): void; 20 | public abstract keys(): K[]; 21 | public abstract cleanUp(): void; 22 | public abstract metrics: Metrics; 23 | } 24 | -------------------------------------------------------------------------------- /src/cache/Cache.ts: -------------------------------------------------------------------------------- 1 | import { KeyType } from './KeyType'; 2 | import { Metrics } from './metrics/Metrics'; 3 | 4 | /** 5 | * Cache for a mapping between keys and values. 6 | */ 7 | export interface Cache { 8 | 9 | /** 10 | * The maximum size the cache can be. Will be -1 if the cache is unbounded. 11 | */ 12 | readonly maxSize: number; 13 | 14 | /** 15 | * The current size of the cache. 16 | */ 17 | readonly size: number; 18 | 19 | /** 20 | * The size of the cache weighted via the activate estimator. 21 | */ 22 | readonly weightedSize: number; 23 | 24 | /** 25 | * Store a value tied to the specified key. Returns the previous value or 26 | * `null` if no value currently exists for the given key. 27 | * 28 | * @param key - 29 | * key to store value under 30 | * @param value - 31 | * value to store 32 | * @returns 33 | * current value or `null` 34 | */ 35 | set(key: K, value: V): V | null; 36 | 37 | /** 38 | * Get the cached value for the specified key if it exists. Will return 39 | * the value or `null` if no cached value exist. Updates the usage of the 40 | * key. 41 | * 42 | * @param key - 43 | * key to get 44 | * @returns 45 | * current value or `null` 46 | */ 47 | getIfPresent(key: K): V | null; 48 | 49 | /** 50 | * Peek to see if a key is present without updating the usage of the 51 | * key. Returns the value associated with the key or `null` if the key 52 | * is not present. 53 | * 54 | * In many cases `has(key)` is a better option to see if a key is present. 55 | * 56 | * @param key - 57 | * the key to check 58 | * @returns 59 | * value associated with key or `null` 60 | */ 61 | peek(key: K): V | null; 62 | 63 | /** 64 | * Check if the given key exists in the cache. 65 | * 66 | * @param key - 67 | * key to check 68 | * @returns 69 | * `true` if value currently exists, `false` otherwise 70 | */ 71 | has(key: K): boolean; 72 | 73 | /** 74 | * Delete a value in the cache. Returns the deleted value or `null` if 75 | * there was no value associated with the key in the cache. 76 | * 77 | * @param key - 78 | * the key to delete 79 | * @returns 80 | * deleted value or `null` 81 | */ 82 | delete(key: K): V | null; 83 | 84 | /** 85 | * Clear the cache removing all of the entries cached. 86 | */ 87 | clear(): void; 88 | 89 | /** 90 | * Get all of the keys in the cache as an array. Can be used to iterate 91 | * over all of the values in the cache, but be sure to protect against 92 | * values being removed during iteration due to time-based expiration if 93 | * used. 94 | * 95 | * @returns 96 | * snapshot of keys 97 | */ 98 | keys(): K[]; 99 | 100 | /** 101 | * Request clean up of the cache by removing expired entries and 102 | * old data. Clean up is done automatically a short time after sets and 103 | * deletes, but if your cache uses time-based expiration and has very 104 | * sporadic updates it might be a good idea to call `cleanUp()` at times. 105 | * 106 | * A good starting point would be to call `cleanUp()` in a `setInterval` 107 | * with a delay of at least a few minutes. 108 | */ 109 | cleanUp(): void; 110 | 111 | /** 112 | * Get metrics for this cache. Returns an object with the keys `hits`, 113 | * `misses` and `hitRate`. For caches that do not have metrics enabled 114 | * trying to access metrics will throw an error. 115 | */ 116 | readonly metrics: Metrics; 117 | } 118 | -------------------------------------------------------------------------------- /src/cache/CacheNode.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Node in a double-linked list. 3 | */ 4 | export class CacheNode { 5 | public key: K | null; 6 | public value: V | null; 7 | 8 | public next: this; 9 | public previous: this; 10 | 11 | public constructor(key: K | null, value: V | null) { 12 | this.key = key; 13 | this.value = value; 14 | 15 | this.previous = this; 16 | this.next = this; 17 | } 18 | 19 | public remove() { 20 | this.previous.next = this.next; 21 | this.next.previous = this.previous; 22 | this.next = this; 23 | this.previous = this; 24 | } 25 | 26 | public appendToTail(head: this) { 27 | const tail = head.previous; 28 | head.previous = this; 29 | tail.next = this; 30 | this.next = head; 31 | this.previous = tail; 32 | } 33 | 34 | public moveToTail(head: this) { 35 | this.remove(); 36 | this.appendToTail(head); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/cache/CacheSPI.ts: -------------------------------------------------------------------------------- 1 | import { KeyType } from './KeyType'; 2 | import { RemovalListener } from './RemovalListener'; 3 | import { ON_REMOVE, ON_MAINTENANCE } from './symbols'; 4 | 5 | /** 6 | * Type not part of the public API, used by caches and their layers as their 7 | * "base". 8 | */ 9 | export interface CacheSPI { 10 | /** 11 | * Called when a key is removed from the cache. Intended to be overridden 12 | * so that the value and removal reason can be modified. 13 | */ 14 | [ON_REMOVE]?: RemovalListener; 15 | 16 | /** 17 | * Called when maintenance occurs in the cache. Can be used by layers to 18 | * perform extra tasks during maintenance windows, such as expiring items. 19 | */ 20 | [ON_MAINTENANCE]?: () => void; 21 | } 22 | -------------------------------------------------------------------------------- /src/cache/CommonCacheOptions.ts: -------------------------------------------------------------------------------- 1 | import { KeyType } from './KeyType'; 2 | import { RemovalListener } from './RemovalListener'; 3 | 4 | /** 5 | * Common options for caches. 6 | */ 7 | export interface CommonCacheOptions { 8 | removalListener?: RemovalListener | null; 9 | } 10 | -------------------------------------------------------------------------------- /src/cache/KeyType.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Types that are valid as keys in caches. 3 | */ 4 | export type KeyType = number | string | boolean; 5 | -------------------------------------------------------------------------------- /src/cache/RemovalListener.ts: -------------------------------------------------------------------------------- 1 | import { KeyType } from './KeyType'; 2 | import { RemovalReason } from './RemovalReason'; 3 | 4 | /** 5 | * Listener for removal events. Receives the key, value and the reason it 6 | * was removed from the cache. 7 | */ 8 | export type RemovalListener = (key: K, value: V, reason: RemovalReason) => void; 9 | -------------------------------------------------------------------------------- /src/cache/RemovalReason.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The reason something was removed from a cache. 3 | */ 4 | export enum RemovalReason { 5 | EXPLICIT = 'explicit', 6 | REPLACED = 'replaced', 7 | SIZE = 'size', 8 | EXPIRED = 'expired' 9 | } 10 | -------------------------------------------------------------------------------- /src/cache/Weigher.ts: -------------------------------------------------------------------------------- 1 | import { KeyType } from './KeyType'; 2 | 3 | /** 4 | * Weigher that evaluates a key and value and returns a size. 5 | */ 6 | export type Weigher = (key: K, value: V) => number; 7 | -------------------------------------------------------------------------------- /src/cache/WrappedCache.ts: -------------------------------------------------------------------------------- 1 | import { AbstractCache } from './AbstractCache'; 2 | import { Cache } from './Cache'; 3 | import { CacheSPI } from './CacheSPI'; 4 | import { KeyType } from './KeyType'; 5 | import { Metrics } from './metrics/Metrics'; 6 | import { RemovalListener } from './RemovalListener'; 7 | import { RemovalReason } from './RemovalReason'; 8 | import { ON_REMOVE, ON_MAINTENANCE, TRIGGER_REMOVE } from './symbols'; 9 | 10 | const PARENT = Symbol('parent'); 11 | const REMOVAL_LISTENER = Symbol('removalListener'); 12 | 13 | /** 14 | * Wrapper for another cache, used to extend that cache with new behavior, 15 | * like for loading things or collecting metrics. 16 | */ 17 | export abstract class WrappedCache extends AbstractCache implements Cache, CacheSPI { 18 | private [PARENT]: Cache & CacheSPI; 19 | 20 | public [ON_REMOVE]?: RemovalListener; 21 | private [REMOVAL_LISTENER]: RemovalListener | null; 22 | 23 | public constructor(parent: Cache & CacheSPI, removalListener: RemovalListener | null) { 24 | super(); 25 | 26 | this[PARENT] = parent; 27 | this[REMOVAL_LISTENER] = removalListener; 28 | 29 | // Custom onRemove handler for the parent cache 30 | this[PARENT][ON_REMOVE] = this[TRIGGER_REMOVE].bind(this); 31 | } 32 | 33 | /** 34 | * The maximum size the cache can be. Will be -1 if the cache is unbounded. 35 | * 36 | * @returns 37 | * maximum size 38 | */ 39 | public get maxSize(): number { 40 | return this[PARENT].maxSize; 41 | } 42 | 43 | /** 44 | * The current size of the cache. 45 | * 46 | * @returns 47 | * current size 48 | */ 49 | public get size(): number { 50 | return this[PARENT].size; 51 | } 52 | 53 | /** 54 | * The size of the cache weighted via the activate estimator. 55 | * 56 | * @returns 57 | * current weighted size 58 | */ 59 | public get weightedSize(): number { 60 | return this[PARENT].weightedSize; 61 | } 62 | 63 | /** 64 | * Store a value tied to the specified key. Returns the previous value or 65 | * `null` if no value currently exists for the given key. 66 | * 67 | * @param key - 68 | * key to store value under 69 | * @param value - 70 | * value to store 71 | * @returns 72 | * current value or `null` 73 | */ 74 | public set(key: K, value: V): V | null { 75 | return this[PARENT].set(key, value); 76 | } 77 | 78 | /** 79 | * Get the cached value for the specified key if it exists. Will return 80 | * the value or `null` if no cached value exist. Updates the usage of the 81 | * key. 82 | * 83 | * @param key - 84 | * key to get 85 | * @returns 86 | * current value or `null` 87 | */ 88 | public getIfPresent(key: K): V | null { 89 | return this[PARENT].getIfPresent(key); 90 | } 91 | 92 | /** 93 | * Peek to see if a key is present without updating the usage of the 94 | * key. Returns the value associated with the key or `null` if the key 95 | * is not present. 96 | * 97 | * In many cases `has(key)` is a better option to see if a key is present. 98 | * 99 | * @param key - 100 | * the key to check 101 | * @returns 102 | * value associated with key or `null` 103 | */ 104 | public peek(key: K): V | null { 105 | return this[PARENT].peek(key); 106 | } 107 | 108 | /** 109 | * Check if the given key exists in the cache. 110 | * 111 | * @param key - 112 | * key to check 113 | * @returns 114 | * `true` if value currently exists, `false` otherwise 115 | */ 116 | public has(key: K): boolean { 117 | return this[PARENT].has(key); 118 | } 119 | 120 | /** 121 | * Delete a value in the cache. Returns the deleted value or `null` if 122 | * there was no value associated with the key in the cache. 123 | * 124 | * @param key - 125 | * the key to delete 126 | * @returns 127 | * deleted value or `null` 128 | */ 129 | public delete(key: K): V | null { 130 | return this[PARENT].delete(key); 131 | } 132 | 133 | /** 134 | * Clear the cache removing all of the entries cached. 135 | */ 136 | public clear(): void { 137 | this[PARENT].clear(); 138 | } 139 | 140 | /** 141 | * Get all of the keys in the cache as an array. Can be used to iterate 142 | * over all of the values in the cache, but be sure to protect against 143 | * values being removed during iteration due to time-based expiration if 144 | * used. 145 | * 146 | * @returns 147 | * snapshot of keys 148 | */ 149 | public keys(): K[] { 150 | return this[PARENT].keys(); 151 | } 152 | 153 | /** 154 | * Request clean up of the cache by removing expired entries and 155 | * old data. Clean up is done automatically a short time after sets and 156 | * deletes, but if your cache uses time-based expiration and has very 157 | * sporadic updates it might be a good idea to call `cleanUp()` at times. 158 | * 159 | * A good starting point would be to call `cleanUp()` in a `setInterval` 160 | * with a delay of at least a few minutes. 161 | */ 162 | public cleanUp(): void { 163 | this[PARENT].cleanUp(); 164 | } 165 | 166 | /** 167 | * Get metrics for this cache. Returns an object with the keys `hits`, 168 | * `misses` and `hitRate`. For caches that do not have metrics enabled 169 | * trying to access metrics will throw an error. 170 | * 171 | * @returns 172 | * metrics 173 | */ 174 | public get metrics(): Metrics { 175 | return this[PARENT].metrics; 176 | } 177 | 178 | public get [ON_MAINTENANCE](): (() => void) | undefined { 179 | return this[PARENT][ON_MAINTENANCE]; 180 | } 181 | 182 | public set [ON_MAINTENANCE](listener: (() => void) | undefined) { 183 | this[PARENT][ON_MAINTENANCE] = listener; 184 | } 185 | 186 | private [TRIGGER_REMOVE](key: K, value: V, reason: RemovalReason) { 187 | // Trigger any extended remove listeners 188 | const onRemove = this[ON_REMOVE]; 189 | if(onRemove) { 190 | onRemove(key, value, reason); 191 | } 192 | 193 | // Trigger the removal listener 194 | const listener = this[REMOVAL_LISTENER]; 195 | if(listener) { 196 | listener(key, value, reason); 197 | } 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/cache/bounded/BoundedCache.ts: -------------------------------------------------------------------------------- 1 | import { AbstractCache } from '../AbstractCache'; 2 | import { Cache } from '../Cache'; 3 | import { CacheNode } from '../CacheNode'; 4 | import { CacheSPI } from '../CacheSPI'; 5 | import { KeyType } from '../KeyType'; 6 | import { Metrics } from '../metrics/Metrics'; 7 | import { RemovalListener } from '../RemovalListener'; 8 | import { RemovalReason } from '../RemovalReason'; 9 | import { ON_REMOVE, ON_MAINTENANCE, TRIGGER_REMOVE, MAINTENANCE } from '../symbols'; 10 | import { Weigher } from '../Weigher'; 11 | 12 | import { CountMinSketch } from './CountMinSketch'; 13 | 14 | 15 | const percentInMain = 0.99; 16 | const percentProtected = 0.8; 17 | const percentOverflow = 0.01; 18 | 19 | const adaptiveRestartThreshold = 0.05; 20 | const adaptiveStepPercent = 0.0625; 21 | const adaptiveStepDecayRate = 0.98; 22 | 23 | const DATA = Symbol('boundedData'); 24 | 25 | /** 26 | * Options usable with a BoundedCache. 27 | */ 28 | export interface BoundedCacheOptions { 29 | /** 30 | * The maximum size of the cache. For unweighed caches this is the maximum 31 | * number of entries in the cache, for weighed caches this is the maximum 32 | * weight of the cache. 33 | */ 34 | maxSize: number; 35 | 36 | /** 37 | * Weigher function to use. If this is specified the cache turns into 38 | * a weighted cache and the function is called when cached data is stored 39 | * to determine its weight. 40 | */ 41 | weigher?: Weigher | null; 42 | 43 | /** 44 | * Listener to call whenever something is removed from the cache. 45 | */ 46 | removalListener?: RemovalListener | null; 47 | } 48 | 49 | /** 50 | * Data as used by the bounded cache. 51 | */ 52 | interface BoundedCacheData { 53 | /** 54 | * Values within the cache. 55 | */ 56 | values: Map>; 57 | 58 | /** 59 | * The maximum size of the cache or -1 if the cache uses weighing. 60 | */ 61 | maxSize: number; 62 | 63 | /** 64 | * Weigher being used for this cache. Invoked to determine the weight of 65 | * an item being cached. 66 | */ 67 | weigher: Weigher | null; 68 | /** 69 | * Maximum size of the cache as a weight. 70 | */ 71 | weightedMaxSize: number; 72 | /** 73 | * The current weight of all items in the cache. 74 | */ 75 | weightedSize: number; 76 | 77 | /** 78 | * Listener to invoke when removals occur. 79 | */ 80 | removalListener: RemovalListener | null; 81 | 82 | /** 83 | * Sketch used to keep track of the frequency of which items are used. 84 | */ 85 | sketch: CountMinSketch; 86 | /** 87 | * The limit at which to grow the sketch. 88 | */ 89 | sketchGrowLimit: number; 90 | 91 | /** 92 | * Timeout holder for performing maintenance. When this is set it means 93 | * that a maintenance is queued for later. 94 | */ 95 | maintenanceTimeout: any; 96 | /** 97 | * The maximum size the cache can grow without an eviction being applied 98 | * directly. 99 | */ 100 | forceEvictionLimit: number; 101 | /** 102 | * The time in milliseconds to delay maintenance. 103 | */ 104 | maintenanceInterval: number; 105 | 106 | /** 107 | * Adaptive data used to adjust the size of the window. 108 | */ 109 | adaptiveData: AdaptiveData; 110 | 111 | /** 112 | * Tracking of the window cache, starts at around 1% of the total cache. 113 | */ 114 | window: CacheSection; 115 | 116 | /** 117 | * SLRU protected segment, 80% * (100% - windowSize) of the total cache 118 | */ 119 | protected: CacheSection; 120 | 121 | /** 122 | * SLRU probation segment, 20% * (100% - windowSize) of the total cache 123 | */ 124 | probation: ProbationSection; 125 | } 126 | 127 | /** 128 | * Node in a double-linked list used for the segments within the cache. 129 | */ 130 | class BoundedNode extends CacheNode { 131 | public readonly hashCode: number; 132 | public weight: number; 133 | public location: Location; 134 | 135 | public constructor(key: K | null, value: V | null) { 136 | super(key, value); 137 | 138 | this.hashCode = key === null ? 0 : CountMinSketch.hash(key); 139 | this.weight = 1; 140 | this.location = Location.WINDOW; 141 | } 142 | } 143 | 144 | /** 145 | * Location of a node within the caches segments. 146 | */ 147 | const enum Location { 148 | WINDOW = 0, 149 | PROTECTED = 1, 150 | PROBATION = 2 151 | } 152 | 153 | /** 154 | * Segment within the cache including a tracker for the current size and 155 | * the maximum size it can be. 156 | */ 157 | interface CacheSection { 158 | /** 159 | * Head of the linked list containing nodes for this segment. 160 | */ 161 | head: BoundedNode; 162 | 163 | /** 164 | * Current size of the segment. Updated whenever something is added or 165 | * removed from the segment. 166 | */ 167 | size: number; 168 | 169 | /** 170 | * The maximum size of the segment. Set on creation and can then be moved 171 | * around using the adaptive adjustment. 172 | */ 173 | maxSize: number; 174 | } 175 | 176 | /** 177 | * Special type for the probation segment that doesn't track its size. 178 | */ 179 | interface ProbationSection { 180 | /** 181 | * Head of the linked list containing nodes for this segment. 182 | */ 183 | head: BoundedNode; 184 | } 185 | 186 | /** 187 | * Data used for adaptive adjustment of the window segment. 188 | */ 189 | interface AdaptiveData { 190 | /** 191 | * The adjustment left to perform, a positive number indicates that the 192 | * window size should be increased. 193 | */ 194 | adjustment: any; 195 | 196 | /** 197 | * The current step size for the hill climbing. 198 | */ 199 | stepSize: any; 200 | 201 | /** 202 | * The hit rate of the previous sample. 203 | */ 204 | previousHitRate: number; 205 | 206 | /** 207 | * The number of this in the current sample. 208 | */ 209 | misses: number; 210 | 211 | /** 212 | * The number of misses in the current sample. 213 | */ 214 | hits: number; 215 | } 216 | 217 | /** 218 | * Bounded cache implementation using W-TinyLFU to keep track of data. 219 | * 220 | * See https://arxiv.org/pdf/1512.00727.pdf for details about TinyLFU and 221 | * the W-TinyLFU optimization. 222 | */ 223 | export class BoundedCache extends AbstractCache implements Cache, CacheSPI { 224 | private [DATA]: BoundedCacheData; 225 | 226 | public [ON_REMOVE]?: RemovalListener; 227 | public [ON_MAINTENANCE]?: () => void; 228 | 229 | public constructor(options: BoundedCacheOptions) { 230 | super(); 231 | 232 | const maxMain = Math.floor(percentInMain * options.maxSize); 233 | 234 | /* 235 | * For weighted caches use an initial sketch size of 256. It will 236 | * grow when the size of the cache approaches that size. 237 | * 238 | * Otherwise set it to a minimum of 128 or the maximum requested size 239 | * of the graph. 240 | */ 241 | const sketchWidth = options.weigher ? 256 : Math.max(options.maxSize, 128); 242 | 243 | this[MAINTENANCE] = this[MAINTENANCE].bind(this); 244 | 245 | this[DATA] = { 246 | maxSize: options.weigher ? -1 : options.maxSize, 247 | removalListener: options.removalListener || null, 248 | 249 | weigher: options.weigher || null, 250 | weightedMaxSize: options.maxSize, 251 | weightedSize: 0, 252 | 253 | sketch: CountMinSketch.uint8(sketchWidth, 4), 254 | sketchGrowLimit: sketchWidth, 255 | 256 | values: new Map(), 257 | 258 | adaptiveData: { 259 | hits: 0, 260 | misses: 0, 261 | 262 | adjustment: 0, 263 | 264 | previousHitRate: 0, 265 | stepSize: -adaptiveStepPercent * options.maxSize 266 | }, 267 | 268 | window: { 269 | head: new BoundedNode(null, null), 270 | size: 0, 271 | maxSize: options.maxSize - maxMain 272 | }, 273 | 274 | protected: { 275 | head: new BoundedNode(null, null), 276 | size: 0, 277 | maxSize: Math.floor(maxMain * percentProtected) 278 | }, 279 | 280 | probation: { 281 | head: new BoundedNode(null, null), 282 | }, 283 | 284 | maintenanceTimeout: null, 285 | forceEvictionLimit: options.maxSize + Math.max(Math.floor(options.maxSize * percentOverflow), 5), 286 | maintenanceInterval: 5000 287 | }; 288 | } 289 | 290 | /** 291 | * Get the maximum size this cache can be. 292 | * 293 | * @returns 294 | * maximum size of the cache 295 | */ 296 | public get maxSize() { 297 | return this[DATA].maxSize; 298 | } 299 | 300 | /** 301 | * Get the current size of the cache. 302 | * 303 | * @returns 304 | * items currently in the cache 305 | */ 306 | public get size() { 307 | return this[DATA].values.size; 308 | } 309 | 310 | /** 311 | * Get the weighted size of all items in the cache. 312 | * 313 | * @returns 314 | * the weighted size of all items in the cache 315 | */ 316 | public get weightedSize() { 317 | return this[DATA].weightedSize; 318 | } 319 | 320 | /** 321 | * Store a value tied to the specified key. Returns the previous value or 322 | * `null` if no value currently exists for the given key. 323 | * 324 | * @param key - 325 | * key to store value under 326 | * @param value - 327 | * value to store 328 | * @returns 329 | * current value or `null` 330 | */ 331 | public set(key: K, value: V): V | null { 332 | const data = this[DATA]; 333 | 334 | const old = data.values.get(key); 335 | 336 | // Create a node and add it to the backing map 337 | const node = new BoundedNode(key, value); 338 | data.values.set(key, node); 339 | 340 | if(data.weigher) { 341 | node.weight = data.weigher(key, value); 342 | } 343 | 344 | // Update our weight 345 | data.weightedSize += node.weight; 346 | if(old) { 347 | // Remove the old node 348 | old.remove(); 349 | 350 | // Adjust weight 351 | data.weightedSize -= old.weight; 352 | 353 | // Update weights of where the node belonged 354 | switch(old.location) { 355 | case Location.PROTECTED: 356 | // Node was protected, reduce the size 357 | data.protected.size -= old.weight; 358 | break; 359 | case Location.WINDOW: 360 | // Node was in window, reduce window size 361 | data.window.size -= old.weight; 362 | break; 363 | } 364 | } 365 | 366 | // Check if we reached the grow limit of the sketch 367 | if(data.weigher && data.values.size >= data.sketchGrowLimit) { 368 | const sketchWidth = data.values.size * 2; 369 | data.sketch = CountMinSketch.uint8(sketchWidth, 4); 370 | data.sketchGrowLimit = sketchWidth; 371 | } 372 | 373 | // Append the new node to the window space 374 | node.appendToTail(data.window.head); 375 | data.window.size += node.weight; 376 | 377 | // Register access to the key 378 | data.sketch.update(node.hashCode); 379 | 380 | // Schedule eviction 381 | if(data.weightedSize >= data.forceEvictionLimit) { 382 | this[MAINTENANCE](); 383 | } else if(! data.maintenanceTimeout) { 384 | data.maintenanceTimeout = setTimeout(this[MAINTENANCE], data.maintenanceInterval); 385 | } 386 | 387 | // Return the value we replaced 388 | if(old) { 389 | this[TRIGGER_REMOVE](key, old.value, RemovalReason.REPLACED); 390 | return old.value; 391 | } else { 392 | return null; 393 | } 394 | } 395 | 396 | /** 397 | * Get the cached value for the specified key if it exists. Will return 398 | * the value or `null` if no cached value exist. Updates the usage of the 399 | * key. 400 | * 401 | * @param key - 402 | * key to get 403 | * @returns 404 | * current value or `null` 405 | */ 406 | public getIfPresent(key: K) { 407 | const data = this[DATA]; 408 | 409 | const node = data.values.get(key); 410 | if(! node) { 411 | // This value does not exist in the cache 412 | data.adaptiveData.misses++; 413 | return null; 414 | } 415 | 416 | // Keep track of the hit 417 | data.adaptiveData.hits++; 418 | 419 | // Register access to the key 420 | data.sketch.update(node.hashCode); 421 | 422 | switch(node.location) { 423 | case Location.WINDOW: 424 | // In window cache, mark as most recently used 425 | node.moveToTail(data.window.head); 426 | break; 427 | case Location.PROBATION: 428 | // In SLRU probation segment, move to protected 429 | node.location = Location.PROTECTED; 430 | node.moveToTail(data.protected.head); 431 | 432 | // Plenty of room, keep track of the size 433 | data.protected.size += node.weight; 434 | 435 | while(data.protected.size > data.protected.maxSize) { 436 | /* 437 | * There is now too many nodes in the protected segment 438 | * so demote the least recently used. 439 | */ 440 | const lru = data.protected.head.next; 441 | lru.location = Location.PROBATION; 442 | lru.moveToTail(data.probation.head); 443 | data.protected.size -= lru.weight; 444 | } 445 | 446 | break; 447 | case Location.PROTECTED: 448 | // SLRU protected segment, mark as most recently used 449 | node.moveToTail(data.protected.head); 450 | break; 451 | } 452 | 453 | return node.value; 454 | } 455 | 456 | /** 457 | * Peek to see if a key is present without updating the usage of the 458 | * key. Returns the value associated with the key or `null` if the key 459 | * is not present. 460 | * 461 | * In many cases `has(key)` is a better option to see if a key is present. 462 | * 463 | * @param key - 464 | * the key to check 465 | * @returns 466 | * value associated with key or `null` 467 | */ 468 | public peek(key: K) { 469 | const data = this[DATA]; 470 | const node = data.values.get(key); 471 | return node ? node.value : null; 472 | } 473 | 474 | /** 475 | * Delete a value in the cache. Returns the deleted value or `null` if 476 | * there was no value associated with the key in the cache. 477 | * 478 | * @param key - 479 | * the key to delete 480 | * @returns 481 | * deleted value or `null` 482 | */ 483 | public delete(key: K) { 484 | const data = this[DATA]; 485 | 486 | const node = data.values.get(key); 487 | if(node) { 488 | // Remove the node from its current list 489 | node.remove(); 490 | 491 | switch(node.location) { 492 | case Location.PROTECTED: 493 | // Node was protected, reduce the size 494 | data.protected.size -= node.weight; 495 | break; 496 | case Location.WINDOW: 497 | // Node was in window, reduce window size 498 | data.window.size -= node.weight; 499 | break; 500 | } 501 | 502 | // Reduce overall weight 503 | data.weightedSize -= node.weight; 504 | 505 | // Remove from main value storage 506 | data.values.delete(key); 507 | 508 | this[TRIGGER_REMOVE](key, node.value, RemovalReason.EXPLICIT); 509 | 510 | if(! data.maintenanceTimeout) { 511 | data.maintenanceTimeout = setTimeout(this[MAINTENANCE], data.maintenanceInterval); 512 | } 513 | 514 | return node.value; 515 | } 516 | 517 | return null; 518 | } 519 | 520 | /** 521 | * Check if the given key exists in the cache. 522 | * 523 | * @param key - 524 | * key to check 525 | * @returns 526 | * `true` if value currently exists, `false` otherwise 527 | */ 528 | public has(key: K) { 529 | const data = this[DATA]; 530 | return data.values.has(key); 531 | } 532 | 533 | /** 534 | * Clear the cache removing all of the entries cached. 535 | */ 536 | public clear() { 537 | const data = this[DATA]; 538 | 539 | const oldValues = data.values; 540 | data.values = new Map(); 541 | for(const [ key, node ] of oldValues) { 542 | this[TRIGGER_REMOVE](key, node.value, RemovalReason.EXPLICIT); 543 | } 544 | data.weightedSize = 0; 545 | 546 | data.window.head.remove(); 547 | data.window.size = 0; 548 | 549 | data.probation.head.remove(); 550 | 551 | data.protected.head.remove(); 552 | data.protected.size = 0; 553 | 554 | if(data.maintenanceTimeout) { 555 | clearTimeout(data.maintenanceTimeout); 556 | data.maintenanceTimeout = null; 557 | } 558 | } 559 | 560 | /** 561 | * Get all of the keys in the cache as an array. Can be used to iterate 562 | * over all of the values in the cache, but be sure to protect against 563 | * values being removed during iteration due to time-based expiration if 564 | * used. 565 | * 566 | * @returns 567 | * snapshot of keys 568 | */ 569 | public keys(): K[] { 570 | this[MAINTENANCE](); 571 | return Array.from(this[DATA].values.keys()); 572 | } 573 | 574 | /** 575 | * Request clean up of the cache by removing expired entries and 576 | * old data. Clean up is done automatically a short time after sets and 577 | * deletes, but if your cache uses time-based expiration and has very 578 | * sporadic updates it might be a good idea to call `cleanUp()` at times. 579 | * 580 | * A good starting point would be to call `cleanUp()` in a `setInterval` 581 | * with a delay of at least a few minutes. 582 | */ 583 | public cleanUp() { 584 | this[MAINTENANCE](); 585 | } 586 | 587 | /** 588 | * Get metrics for this cache. Returns an object with the keys `hits`, 589 | * `misses` and `hitRate`. For caches that do not have metrics enabled 590 | * trying to access metrics will throw an error. 591 | */ 592 | public get metrics(): Metrics { 593 | throw new Error('Metrics are not supported by this cache'); 594 | } 595 | 596 | private [TRIGGER_REMOVE](key: K, value: any, cause: RemovalReason) { 597 | const data = this[DATA]; 598 | 599 | // Trigger any extended remove listeners 600 | const onRemove = this[ON_REMOVE]; 601 | if(onRemove) { 602 | onRemove(key, value, cause); 603 | } 604 | 605 | // Trigger the removal listener 606 | if(data.removalListener) { 607 | data.removalListener(key, value, cause); 608 | } 609 | } 610 | 611 | private [MAINTENANCE]() { 612 | /* 613 | * Trigger the onMaintenance listener if one exists. This is done 614 | * before eviction occurs so that extra layers have a chance to 615 | * apply their own eviction rules. 616 | * 617 | * This can be things such as things being removed because they have 618 | * been expired which in turn might cause eviction to be unnecessary. 619 | */ 620 | const onMaintenance = this[ON_MAINTENANCE]; 621 | if(onMaintenance) { 622 | onMaintenance(); 623 | } 624 | 625 | const data = this[DATA]; 626 | 627 | /* 628 | * Evict the least recently used node in the window space to the 629 | * probation segment until we are below the maximum size. 630 | */ 631 | let evictedToProbation = 0; 632 | while(data.window.size > data.window.maxSize) { 633 | const first = data.window.head.next; 634 | 635 | first.moveToTail(data.probation.head); 636 | first.location = Location.PROBATION; 637 | 638 | data.window.size -= first.weight; 639 | 640 | evictedToProbation++; 641 | } 642 | 643 | /* 644 | * Evict nodes for real until we are below our maximum size. 645 | */ 646 | while(data.weightedSize > data.weightedMaxSize) { 647 | const probation = data.probation.head.next; 648 | const evictedCandidate = evictedToProbation === 0 ? data.probation.head : data.probation.head.previous; 649 | 650 | const hasProbation = probation !== data.probation.head; 651 | const hasEvicted = evictedCandidate !== data.probation.head; 652 | 653 | let toRemove: BoundedNode; 654 | if(! hasProbation && ! hasEvicted) { 655 | // TODO: Probation queue is empty, how is this handled? 656 | break; 657 | } else if(! hasEvicted) { 658 | toRemove = probation; 659 | } else if(! hasProbation) { 660 | toRemove = evictedCandidate; 661 | 662 | evictedToProbation--; 663 | } else { 664 | /* 665 | * Estimate how often the two nodes have been accessed to 666 | * determine which of the keys should actually be evicted. 667 | * 668 | * Also protect against hash collision attacks where the 669 | * frequency of an node in the cache is raised causing the 670 | * candidate to never be admitted into the cache. 671 | */ 672 | let removeCandidate; 673 | 674 | const freqEvictedCandidate = data.sketch.estimate(evictedCandidate.hashCode); 675 | const freqProbation = data.sketch.estimate(probation.hashCode); 676 | 677 | if(freqEvictedCandidate > freqProbation) { 678 | removeCandidate = false; 679 | } else if(freqEvictedCandidate < data.sketch.slightlyLessThanHalfMaxSize) { 680 | /* 681 | * If the frequency of the candidate is slightly less than 682 | * half it can be admitted without going through randomness 683 | * checks. 684 | * 685 | * The idea here is that will reduce the number of random 686 | * admittances. 687 | */ 688 | removeCandidate = true; 689 | } else { 690 | /* 691 | * Make it a 1 in 1000 chance that the candidate is not 692 | * removed. 693 | * 694 | * TODO: Should this be lower or higher? Please open an issue if you have thoughts on this 695 | */ 696 | removeCandidate = Math.floor(Math.random() * 1000) >= 1; 697 | } 698 | 699 | toRemove = removeCandidate ? evictedCandidate : probation; 700 | evictedToProbation--; 701 | } 702 | 703 | if(toRemove.key === null) { 704 | throw new Error('Cache issue, problem with removal'); 705 | } 706 | 707 | data.values.delete(toRemove.key); 708 | toRemove.remove(); 709 | data.weightedSize -= toRemove.weight; 710 | 711 | this[TRIGGER_REMOVE](toRemove.key, toRemove.value, RemovalReason.SIZE); 712 | } 713 | 714 | // Perform adaptive adjustment of size of window cache 715 | adaptiveAdjustment(data); 716 | 717 | if(data.maintenanceTimeout) { 718 | clearTimeout(data.maintenanceTimeout); 719 | data.maintenanceTimeout = null; 720 | } 721 | } 722 | } 723 | 724 | /** 725 | * Perform adaptive adjustment. This will do a simple hill climb and attempt 726 | * to find the best balance between the recency and frequency parts of the 727 | * cache. 728 | * 729 | * This is based on the work done in Caffeine and the paper Adaptive Software 730 | * Cache Management by Gil Einziger, Ohad Eytan, Roy Friedman and Ben Manes. 731 | * 732 | * This implementation does work in chunks so that not too many nodes are 733 | * moved around at once. At every maintenance interval it: 734 | * 735 | * 1) Checks if there are enough samples to calculate a new adjustment. 736 | * 2) 737 | * Takes the current adjustment and increases or decreases the window in 738 | * chunks. At every invocation it currently moves a maximum of 1000 nodes 739 | * around. 740 | * 741 | * @param data - 742 | */ 743 | function adaptiveAdjustment(data: BoundedCacheData) { 744 | /* 745 | * Calculate the new adaptive adjustment. This might result in a 746 | * recalculation or it may skip touching the adjustment. 747 | */ 748 | calculateAdaptiveAdjustment(data); 749 | 750 | const a = data.adaptiveData.adjustment; 751 | if(a > 0) { 752 | // Increase the window size if the adjustment is positive 753 | increaseWindowSegmentSize(data); 754 | } else if(a < 0) { 755 | // Decrease the window size if the adjustment is negative 756 | decreaseWindowSegmentSize(data); 757 | } 758 | } 759 | 760 | /** 761 | * Evict nodes from the protected segment to the probation segment if there 762 | * are too many nodes in the protected segment. 763 | * 764 | * @param data - 765 | */ 766 | function evictProtectedToProbation(data: BoundedCacheData) { 767 | /* 768 | * Move up to 1000 nodes from the protected segment to the probation one 769 | * if the segment is over max size. 770 | */ 771 | let i = 0; 772 | while(i++ < 1000 && data.protected.size > data.protected.maxSize) { 773 | const lru = data.protected.head.next; 774 | if(lru === data.protected.head) break; 775 | 776 | lru.location = Location.PROBATION; 777 | lru.moveToTail(data.probation.head); 778 | data.protected.size -= lru.weight; 779 | } 780 | } 781 | 782 | /** 783 | * Calculate the adjustment to the window size. This will check if there is 784 | * enough samples to do a step and if so perform a simple hill climbing to 785 | * find the new adjustment. 786 | * 787 | * @param data - 788 | * @returns 789 | * `true` if an adjustment occurred, `false` otherwise 790 | */ 791 | function calculateAdaptiveAdjustment(data: BoundedCacheData): boolean { 792 | const adaptiveData = data.adaptiveData; 793 | const requestCount = adaptiveData.hits + adaptiveData.misses; 794 | if(requestCount < data.sketch.resetAfter) { 795 | /* 796 | * Skip adjustment if the number of gets in the cache has not reached 797 | * the same size as the sketch reset. 798 | */ 799 | return false; 800 | } 801 | 802 | const hitRate = adaptiveData.hits / requestCount; 803 | const hitRateDiff = hitRate - adaptiveData.previousHitRate; 804 | const amount = hitRateDiff >= 0 ? adaptiveData.stepSize : -adaptiveData.stepSize; 805 | 806 | let nextStep; 807 | if(Math.abs(hitRateDiff) >= adaptiveRestartThreshold) { 808 | nextStep = adaptiveStepPercent * data.weightedMaxSize * (amount >= 0 ? 1 : -1); 809 | } else { 810 | nextStep = adaptiveStepDecayRate * amount; 811 | } 812 | 813 | // Store the adjustment, step size and previous hit rate for the next step 814 | adaptiveData.adjustment = Math.floor(amount); 815 | adaptiveData.stepSize = nextStep; 816 | adaptiveData.previousHitRate = hitRate; 817 | 818 | // Reset the sample data 819 | adaptiveData.misses = 0; 820 | adaptiveData.hits = 0; 821 | 822 | return true; 823 | } 824 | 825 | /** 826 | * Increase the size of the window segment. This will change increase the max 827 | * size of the window segment and decrease the max size of the protected 828 | * segment. The method will then move nodes from the probation and protected 829 | * segment the window segment. 830 | * 831 | * @param data - 832 | */ 833 | function increaseWindowSegmentSize(data: BoundedCacheData) { 834 | if(data.protected.maxSize === 0) { 835 | // Can't increase the window size anymore 836 | return; 837 | } 838 | 839 | let amountLeftToAdjust = Math.min(data.adaptiveData.adjustment, data.protected.maxSize); 840 | data.protected.maxSize -= amountLeftToAdjust; 841 | data.window.maxSize += amountLeftToAdjust; 842 | 843 | /* 844 | * Evict nodes from the protected are to the probation area now that it 845 | * is smaller. 846 | */ 847 | evictProtectedToProbation(data); 848 | 849 | /* 850 | * Transfer up to 1000 node into the window segment. 851 | */ 852 | for(let i = 0; i < 1000; i++) { 853 | let lru = data.probation.head.next; 854 | if(lru === data.probation.head || lru.weight > amountLeftToAdjust) { 855 | /* 856 | * Either got the probation head or the node was to big to fit. 857 | * Move on and check in the protected area. 858 | */ 859 | lru = data.protected.head.next; 860 | if(lru === data.protected.head) { 861 | // No more values to remove 862 | break; 863 | } 864 | } 865 | 866 | if(lru.weight > amountLeftToAdjust) { 867 | /* 868 | * The node weight exceeds what is left of the adjustment. 869 | */ 870 | break; 871 | } 872 | 873 | amountLeftToAdjust -= lru.weight; 874 | 875 | // Remove node from its current segment 876 | if(lru.location === Location.PROTECTED) { 877 | // If its protected reduce the size 878 | data.protected.size -= lru.weight; 879 | } 880 | 881 | // Move to the window segment 882 | lru.moveToTail(data.window.head); 883 | data.window.size += lru.weight; 884 | lru.location = Location.WINDOW; 885 | } 886 | 887 | /* 888 | * Keep track of the adjustment amount that is left. The next maintenance 889 | * invocation will look at this and attempt to adjust for it. 890 | */ 891 | data.protected.maxSize += amountLeftToAdjust; 892 | data.window.maxSize -= amountLeftToAdjust; 893 | data.adaptiveData.adjustment = amountLeftToAdjust; 894 | } 895 | 896 | /** 897 | * Decrease the size of the window. This will increase the size of the 898 | * protected segment while decreasing the size of the window segment. Nodes 899 | * will be moved from the window segment into the probation segment, where 900 | * they are later moved to the protected segment when they are accessed. 901 | * 902 | * @param data - 903 | */ 904 | function decreaseWindowSegmentSize(data: BoundedCacheData) { 905 | if(data.window.maxSize <= 1) { 906 | // Can't decrease the size of the window anymore 907 | return; 908 | } 909 | 910 | let amountLeftToAdjust = Math.min(-data.adaptiveData.adjustment, Math.max(data.window.maxSize - 1, 0)); 911 | data.window.maxSize -= amountLeftToAdjust; 912 | data.protected.maxSize += amountLeftToAdjust; 913 | 914 | /* 915 | * Transfer upp to 1000 nodes from the window segment into the probation 916 | * segment. 917 | */ 918 | for(let i = 0; i < 1000; i++) { 919 | const lru = data.window.head.next; 920 | if(lru === data.window.head) { 921 | // No more nodes in the window segment, can't adjust anymore 922 | break; 923 | } 924 | 925 | if(lru.weight > amountLeftToAdjust) { 926 | /* 927 | * The node weight exceeds what is left of the change. Can't move 928 | * it around. 929 | */ 930 | break; 931 | } 932 | 933 | amountLeftToAdjust -= lru.weight; 934 | 935 | // Remove node from the window 936 | lru.moveToTail(data.probation.head); 937 | lru.location = Location.PROBATION; 938 | data.window.size -= lru.weight; 939 | } 940 | 941 | /* 942 | * Keep track of the adjustment amount that is left. The next maintenance 943 | * invocation will look at this and attempt to adjust for it. 944 | */ 945 | data.window.maxSize += amountLeftToAdjust; 946 | data.protected.maxSize -= amountLeftToAdjust; 947 | data.adaptiveData.adjustment = -amountLeftToAdjust; 948 | } 949 | -------------------------------------------------------------------------------- /src/cache/bounded/CountMinSketch.ts: -------------------------------------------------------------------------------- 1 | import { KeyType } from '../KeyType'; 2 | 3 | import { hashcode } from './hashcode'; 4 | 5 | /** 6 | * Helper function to calculate the closest power of two to N. 7 | * 8 | * @param n - 9 | * input 10 | * @returns 11 | * closest power of two to `n` 12 | */ 13 | function toPowerOfN(n: number) { 14 | return Math.pow(2, Math.ceil(Math.log(n) / Math.LN2)); 15 | } 16 | 17 | /** 18 | * Calculates a component of the hash. 19 | * 20 | * @param a0 - 21 | * @returns 22 | * hash 23 | */ 24 | function hash2(a0: number) { 25 | let a = (a0 ^ 61) ^ (a0 >>> 16); 26 | a = a + (a << 3); 27 | a = a ^ (a >>> 4); 28 | a = safeishMultiply(a, 0x27d4eb2d); 29 | a = a ^ (a >>> 15); 30 | return a; 31 | } 32 | 33 | /** 34 | * Tiny helper to perform a multiply that's slightly safer to use for hashing. 35 | * 36 | * @param a - 37 | * @param b - 38 | * @returns a * b 39 | */ 40 | function safeishMultiply(a: number, b: number) { 41 | return ((a & 0xffff) * b) + ((((a >>> 16) * b) & 0xffff) << 16); 42 | } 43 | 44 | /** 45 | * Count-min sketch suitable for use with W-TinyLFU. Similiar to a regular 46 | * count-min sketch but with a few important differences to achieve better 47 | * estimations: 48 | * 49 | * 1) Enforces that the width of the sketch is a power of 2. 50 | * 2) Uses a reset that decays all values by half when width * 10 additions 51 | * have been made. 52 | */ 53 | export class CountMinSketch { 54 | private readonly width: number; 55 | private readonly depth: number; 56 | 57 | public readonly maxSize: number; 58 | public readonly halfMaxSize: number; 59 | public readonly slightlyLessThanHalfMaxSize: number; 60 | 61 | private additions: number; 62 | public readonly resetAfter: number; 63 | 64 | private table: Uint8Array; 65 | 66 | public constructor(width: number, depth: number, decay: boolean) { 67 | this.width = toPowerOfN(width); 68 | this.depth = depth; 69 | 70 | // Get the maximum size of values, assuming unsigned ints 71 | this.maxSize = Math.pow(2, Uint8Array.BYTES_PER_ELEMENT * 8) - 1; 72 | this.halfMaxSize = this.maxSize / 2; 73 | this.slightlyLessThanHalfMaxSize = this.halfMaxSize - Math.max(this.halfMaxSize / 4, 1); 74 | 75 | // Track additions and when to reset 76 | this.additions = 0; 77 | this.resetAfter = decay ? width * 10 : -1; 78 | 79 | // Create the table to store data in 80 | this.table = new Uint8Array(this.width * depth); 81 | } 82 | 83 | private findIndex(h1: number, h2: number, d: number) { 84 | const h = h1 + safeishMultiply(h2, d); 85 | return (d * this.width) + (h & (this.width - 1)); 86 | } 87 | 88 | public update(hashCode: number) { 89 | const table = this.table; 90 | const maxSize = this.maxSize; 91 | 92 | const estimate = this.estimate(hashCode); 93 | 94 | const h2 = hash2(hashCode); 95 | let added = false; 96 | for(let i = 0, n = this.depth; i < n; i++) { 97 | const idx = this.findIndex(hashCode, h2, i); 98 | const v = table[idx]; 99 | if(v + 1 < maxSize && v <= estimate) { 100 | table[idx] = v + 1; 101 | added = true; 102 | } 103 | } 104 | 105 | if(added && ++this.additions === this.resetAfter) { 106 | this.performReset(); 107 | } 108 | } 109 | 110 | public estimate(hashCode: number) { 111 | const table = this.table; 112 | const h2 = hash2(hashCode); 113 | 114 | let result = this.maxSize; 115 | for(let i = 0, n = this.depth; i < n; i++) { 116 | const value = table[this.findIndex(hashCode, h2, i)]; 117 | if(value < result) { 118 | result = value; 119 | } 120 | } 121 | 122 | return result; 123 | } 124 | 125 | private performReset() { 126 | const table = this.table; 127 | this.additions /= 2; 128 | for(let i = 0, n = table.length; i < n; i++) { 129 | this.additions -= table[i] & 1; 130 | table[i] = Math.floor(table[i] >>> 1); 131 | } 132 | } 133 | 134 | public static hash(key: KeyType) { 135 | return hashcode(key); 136 | } 137 | 138 | public static uint8(width: number, depth: number, decay = true) { 139 | return new CountMinSketch(width, depth, decay); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/cache/bounded/hashcode.ts: -------------------------------------------------------------------------------- 1 | const C1 = 0xcc9e2d51; 2 | const C2 = 0x1b873593; 3 | 4 | /** 5 | * Tiny helper to perform a multiply that's slightly safer to use for hashing. 6 | * 7 | * @param a - 8 | * @param b - 9 | * @returns a * b 10 | */ 11 | function safeishMultiply(a: number, b: number) { 12 | return ((a & 0xffff) * b) + ((((a >>> 16) * b) & 0xffff) << 16); 13 | } 14 | 15 | /** 16 | * Utility for calculating stable hashcodes for keys used in a cache. 17 | * 18 | * @param obj - 19 | * object to hash 20 | * @param seed - 21 | * seed to hash with 22 | * @returns 23 | * hash code 24 | */ 25 | export function hashcode(obj: string | number | boolean | null, seed = 0) { 26 | switch(typeof obj) { 27 | case 'string': 28 | { 29 | let hash = seed; 30 | const n = obj.length & ~0x3; 31 | for(let i = 0; i < n; i += 4) { 32 | let k1 = ((obj.charCodeAt(i) & 0xffff)) | 33 | ((obj.charCodeAt(i + 1) & 0xffff) << 8) | 34 | ((obj.charCodeAt(i + 2) & 0xffff) << 16) | 35 | ((obj.charCodeAt(i + 3) & 0xffff) << 24); 36 | 37 | k1 = safeishMultiply(k1, C1); 38 | k1 = (k1 << 15) | (k1 >>> 17); 39 | k1 = safeishMultiply(k1, C2); 40 | 41 | hash ^= k1; 42 | hash = (hash << 13) | (hash >>> 19); 43 | hash = (hash * 5) + 0xe6546b64; 44 | } 45 | 46 | { 47 | let k1 = 0; 48 | switch(obj.length & 3) { 49 | case 3: 50 | k1 ^= (obj.charCodeAt(n + 2) & 0xffff) << 16; 51 | // eslint-disable-next-line no-fallthrough 52 | case 2: 53 | k1 ^= (obj.charCodeAt(n + 1) & 0xffff) << 8; 54 | // eslint-disable-next-line no-fallthrough 55 | case 1: 56 | k1 ^= (obj.charCodeAt(n) & 0xffff); 57 | 58 | k1 = safeishMultiply(k1, C1); 59 | k1 = (k1 << 15) | (k1 >>> 17); 60 | k1 = safeishMultiply(k1, C2); 61 | 62 | hash ^= k1; 63 | } 64 | } 65 | 66 | hash ^= obj.length; 67 | 68 | hash ^= hash >>> 16; 69 | hash = safeishMultiply(hash, 0x85ebca6b); 70 | hash ^= hash >>> 13; 71 | hash = safeishMultiply(hash, 0xc2b2ae35); 72 | hash ^= hash >>> 16; 73 | 74 | return hash >>> 0; 75 | } 76 | case 'number': 77 | { 78 | let hash = obj; 79 | 80 | hash = safeishMultiply(hash, C1); 81 | hash = (hash << 15) | (hash >>> 17); 82 | hash = safeishMultiply(hash, C2); 83 | 84 | hash = (hash << 13) | (hash >>> 19); 85 | hash = (hash * 5) + 0xe6546b64; 86 | 87 | hash ^= hash >>> 16; 88 | hash = safeishMultiply(hash, 0x85ebca6b); 89 | hash ^= hash >>> 13; 90 | hash = safeishMultiply(hash, 0xc2b2ae35); 91 | hash ^= hash >>> 16; 92 | 93 | hash ^= 1; 94 | 95 | return hash >>> 0; 96 | } 97 | case 'boolean': 98 | { 99 | return obj ? 1231 : 1237; 100 | } 101 | case 'undefined': 102 | return 0; 103 | default: 104 | throw new Error('The given value can not be used as a key in a cache, value was: ' + String(obj)); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/cache/bounded/index.ts: -------------------------------------------------------------------------------- 1 | export * from './BoundedCache'; 2 | -------------------------------------------------------------------------------- /src/cache/boundless/BoundlessCache.ts: -------------------------------------------------------------------------------- 1 | import { AbstractCache } from '../AbstractCache'; 2 | import { Cache } from '../Cache'; 3 | import { CacheSPI } from '../CacheSPI'; 4 | import { KeyType } from '../KeyType'; 5 | import { Metrics } from '../metrics/Metrics'; 6 | import { RemovalListener } from '../RemovalListener'; 7 | import { RemovalReason } from '../RemovalReason'; 8 | import { ON_REMOVE, ON_MAINTENANCE, TRIGGER_REMOVE, MAINTENANCE } from '../symbols'; 9 | 10 | const DATA = Symbol('boundlessData'); 11 | 12 | const EVICTION_DELAY = 5000; 13 | 14 | /** 15 | * Options for a boundless cache. 16 | */ 17 | export interface BoundlessCacheOptions { 18 | /** 19 | * Listener that triggers when a cached value is removed. 20 | */ 21 | removalListener?: RemovalListener | undefined | null; 22 | } 23 | 24 | /** 25 | * Data as used by the boundless cache. 26 | */ 27 | interface BoundlessCacheData { 28 | values: Map; 29 | 30 | removalListener: RemovalListener | null; 31 | 32 | evictionTimeout: any; 33 | } 34 | 35 | /** 36 | * Boundless cache. 37 | */ 38 | export class BoundlessCache extends AbstractCache implements Cache, CacheSPI { 39 | private [DATA]: BoundlessCacheData; 40 | 41 | public [ON_REMOVE]?: RemovalListener; 42 | public [ON_MAINTENANCE]?: () => void; 43 | 44 | public constructor(options: BoundlessCacheOptions) { 45 | super(); 46 | 47 | this[DATA] = { 48 | values: new Map(), 49 | 50 | removalListener: options.removalListener || null, 51 | 52 | evictionTimeout: null 53 | }; 54 | } 55 | 56 | /** 57 | * The maximum size the cache can be. Will be -1 if the cache is unbounded. 58 | * 59 | * @returns 60 | * maximum size, always `-1` 61 | */ 62 | public get maxSize() { 63 | return -1; 64 | } 65 | 66 | /** 67 | * The current size of the cache. 68 | * 69 | * @returns 70 | * entries in the cache 71 | */ 72 | public get size() { 73 | return this[DATA].values.size; 74 | } 75 | 76 | /** 77 | * The size of the cache weighted via the activate estimator. 78 | * 79 | * @returns 80 | * entries in the cache 81 | */ 82 | public get weightedSize() { 83 | return this.size; 84 | } 85 | 86 | /** 87 | * Store a value tied to the specified key. Returns the previous value or 88 | * `null` if no value currently exists for the given key. 89 | * 90 | * @param key - 91 | * key to store value under 92 | * @param value - 93 | * value to store 94 | * @returns 95 | * current value or `null` 96 | */ 97 | public set(key: K, value: V): V | null { 98 | const data = this[DATA]; 99 | 100 | const old = data.values.get(key); 101 | 102 | // Update with the new value 103 | data.values.set(key, value); 104 | 105 | // Schedule an eviction 106 | if(! data.evictionTimeout) { 107 | data.evictionTimeout = setTimeout(() => this[MAINTENANCE](), EVICTION_DELAY); 108 | } 109 | 110 | // Return the value we replaced 111 | if(old !== undefined) { 112 | this[TRIGGER_REMOVE](key, old, RemovalReason.REPLACED); 113 | return old; 114 | } else { 115 | return null; 116 | } 117 | } 118 | 119 | /** 120 | * Get the cached value for the specified key if it exists. Will return 121 | * the value or `null` if no cached value exist. Updates the usage of the 122 | * key. 123 | * 124 | * @param key - 125 | * key to get 126 | * @returns 127 | * current value or `null` 128 | */ 129 | public getIfPresent(key: K): V | null { 130 | const data = this[DATA]; 131 | const value = data.values.get(key); 132 | return value === undefined ? null : value; 133 | } 134 | 135 | /** 136 | * Peek to see if a key is present without updating the usage of the 137 | * key. Returns the value associated with the key or `null` if the key 138 | * is not present. 139 | * 140 | * In many cases `has(key)` is a better option to see if a key is present. 141 | * 142 | * @param key - 143 | * the key to check 144 | * @returns 145 | * value associated with key or `null` 146 | */ 147 | public peek(key: K): V | null { 148 | const data = this[DATA]; 149 | const value = data.values.get(key); 150 | return value === undefined ? null : value; 151 | } 152 | 153 | /** 154 | * Delete a value in the cache. Returns the deleted value or `null` if 155 | * there was no value associated with the key in the cache. 156 | * 157 | * @param key - 158 | * the key to delete 159 | * @returns 160 | * deleted value or `null` 161 | */ 162 | public delete(key: K): V | null { 163 | const data = this[DATA]; 164 | 165 | const old = data.values.get(key); 166 | data.values.delete(key); 167 | 168 | if(old !== undefined) { 169 | // Trigger removal events 170 | this[TRIGGER_REMOVE](key, old, RemovalReason.EXPLICIT); 171 | 172 | // Queue an eviction event if one is not set 173 | if(! data.evictionTimeout) { 174 | data.evictionTimeout = setTimeout(() => this[MAINTENANCE](), EVICTION_DELAY); 175 | } 176 | 177 | return old; 178 | } else { 179 | return null; 180 | } 181 | } 182 | 183 | /** 184 | * Check if the given key exists in the cache. 185 | * 186 | * @param key - 187 | * key to check 188 | * @returns 189 | * `true` if value currently exists, `false` otherwise 190 | */ 191 | public has(key: K) { 192 | const data = this[DATA]; 193 | return data.values.has(key); 194 | } 195 | 196 | /** 197 | * Clear all of the cached data. 198 | */ 199 | public clear() { 200 | const data = this[DATA]; 201 | const oldValues = data.values; 202 | 203 | // Simply replace the value map new data 204 | data.values = new Map(); 205 | 206 | // Trigger removal events for all of the content in the cache 207 | for(const [ key, value ] of oldValues.entries()) { 208 | this[TRIGGER_REMOVE](key, value, RemovalReason.EXPLICIT); 209 | } 210 | } 211 | 212 | /** 213 | * Get all of the keys in the cache as an array. Can be used to iterate 214 | * over all of the values in the cache, but be sure to protect against 215 | * values being removed during iteration due to time-based expiration if 216 | * used. 217 | * 218 | * @returns 219 | * snapshot of keys 220 | */ 221 | public keys() { 222 | this[MAINTENANCE](); 223 | return Array.from(this[DATA].values.keys()); 224 | } 225 | 226 | /** 227 | * Request clean up of the cache by removing expired entries and 228 | * old data. Clean up is done automatically a short time after sets and 229 | * deletes, but if your cache uses time-based expiration and has very 230 | * sporadic updates it might be a good idea to call `cleanUp()` at times. 231 | * 232 | * A good starting point would be to call `cleanUp()` in a `setInterval` 233 | * with a delay of at least a few minutes. 234 | */ 235 | public cleanUp() { 236 | // Simply request eviction so extra layers can handle this 237 | this[MAINTENANCE](); 238 | } 239 | 240 | /** 241 | * Get metrics for this cache. Returns an object with the keys `hits`, 242 | * `misses` and `hitRate`. For caches that do not have metrics enabled 243 | * trying to access metrics will throw an error. 244 | */ 245 | public get metrics(): Metrics { 246 | throw new Error('Metrics are not supported by this cache'); 247 | } 248 | 249 | private [TRIGGER_REMOVE](key: K, value: any, reason: RemovalReason) { 250 | const data = this[DATA]; 251 | 252 | // Trigger any extended remove listeners 253 | const onRemove = this[ON_REMOVE]; 254 | if(onRemove) { 255 | onRemove(key, value, reason); 256 | } 257 | 258 | if(data.removalListener) { 259 | data.removalListener(key, value as V, reason); 260 | } 261 | } 262 | 263 | private [MAINTENANCE]() { 264 | // Trigger the onEvict listener if one exists 265 | const onEvict = this[ON_MAINTENANCE]; 266 | if(onEvict) { 267 | onEvict(); 268 | } 269 | 270 | const data = this[DATA]; 271 | if(data.evictionTimeout) { 272 | clearTimeout(data.evictionTimeout); 273 | data.evictionTimeout = null; 274 | } 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /src/cache/boundless/index.ts: -------------------------------------------------------------------------------- 1 | export * from './BoundlessCache'; 2 | -------------------------------------------------------------------------------- /src/cache/expiration/Expirable.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Expirable object. 3 | */ 4 | export interface Expirable { 5 | /** 6 | * Get the current value. 7 | */ 8 | readonly value: V | null; 9 | 10 | /** 11 | * Get if this expirable object is considered expired. 12 | */ 13 | isExpired(): boolean; 14 | } 15 | -------------------------------------------------------------------------------- /src/cache/expiration/ExpirationCache.ts: -------------------------------------------------------------------------------- 1 | 2 | import { AbstractCache } from '../AbstractCache'; 3 | import { Cache } from '../Cache'; 4 | import { CacheSPI } from '../CacheSPI'; 5 | import { CommonCacheOptions } from '../CommonCacheOptions'; 6 | import { KeyType } from '../KeyType'; 7 | import { Metrics } from '../metrics/Metrics'; 8 | import { RemovalListener } from '../RemovalListener'; 9 | import { RemovalReason } from '../RemovalReason'; 10 | import { PARENT, ON_REMOVE, TRIGGER_REMOVE, ON_MAINTENANCE, MAINTENANCE } from '../symbols'; 11 | 12 | import { Expirable } from './Expirable'; 13 | import { MaxAgeDecider } from './MaxAgeDecider'; 14 | import { TimerWheel, TimerNode } from './TimerWheel'; 15 | 16 | 17 | const DATA = Symbol('expirationData'); 18 | 19 | /** 20 | * Options available for a loading cache. 21 | */ 22 | export interface ExpirationCacheOptions extends CommonCacheOptions { 23 | maxWriteAge?: MaxAgeDecider; 24 | maxNoReadAge?: MaxAgeDecider; 25 | 26 | parent: Cache>; 27 | } 28 | 29 | interface ExpirationCacheData { 30 | timerWheel: TimerWheel; 31 | 32 | removalListener: RemovalListener | null; 33 | 34 | maxWriteAge?: MaxAgeDecider; 35 | maxNoReadAge?: MaxAgeDecider; 36 | } 37 | 38 | /** 39 | * Wrapper for another cache that provides evictions of times based on timers. 40 | * 41 | * Currently supports expiration based on maximum age. 42 | */ 43 | export class ExpirationCache extends AbstractCache implements CacheSPI { 44 | private [DATA]: ExpirationCacheData; 45 | private [PARENT]: Cache> & CacheSPI>; 46 | 47 | public [ON_REMOVE]?: RemovalListener; 48 | public [ON_MAINTENANCE]?: () => void; 49 | 50 | public constructor(options: ExpirationCacheOptions) { 51 | super(); 52 | 53 | this[PARENT] = options.parent; 54 | 55 | this[DATA] = { 56 | maxWriteAge: options.maxWriteAge, 57 | maxNoReadAge: options.maxNoReadAge, 58 | 59 | removalListener: options.removalListener || null, 60 | 61 | timerWheel: new TimerWheel(keys => { 62 | for(const key of keys) { 63 | this.delete(key); 64 | } 65 | }) 66 | }; 67 | 68 | // Custom onRemove handler for the parent cache 69 | this[PARENT][ON_REMOVE] = (key: K, node: Expirable, reason: RemovalReason) => { 70 | const actualReason = node.isExpired() ? RemovalReason.EXPIRED : reason; 71 | this[DATA].timerWheel.deschedule(node as TimerNode); 72 | this[TRIGGER_REMOVE](key, node.value as V, actualReason); 73 | }; 74 | 75 | // Custom maintenance behaviour to advance the wheel 76 | this[PARENT][ON_MAINTENANCE] = this[MAINTENANCE].bind(this); 77 | } 78 | 79 | /** 80 | * The maximum size the cache can be. Will be -1 if the cache is unbounded. 81 | * 82 | * @returns 83 | * maximum size 84 | */ 85 | public get maxSize(): number { 86 | return this[PARENT].maxSize; 87 | } 88 | 89 | /** 90 | * The current size of the cache. 91 | * 92 | * @returns 93 | * current size 94 | */ 95 | public get size(): number { 96 | return this[PARENT].size; 97 | } 98 | 99 | /** 100 | * The size of the cache weighted via the activate estimator. 101 | * 102 | * @returns 103 | * weighted size 104 | */ 105 | public get weightedSize(): number { 106 | return this[PARENT].weightedSize; 107 | } 108 | 109 | /** 110 | * Store a value tied to the specified key. Returns the previous value or 111 | * `null` if no value currently exists for the given key. 112 | * 113 | * @param key - 114 | * key to store value under 115 | * @param value - 116 | * value to store 117 | * @returns 118 | * current value or `null` 119 | */ 120 | public set(key: K, value: V) { 121 | const data = this[DATA]; 122 | const timerWheel = data.timerWheel; 123 | const node = timerWheel.node(key, value); 124 | 125 | let age = null; 126 | if(data.maxWriteAge) { 127 | age = data.maxWriteAge(key, value) || 0; 128 | } else if(data.maxNoReadAge) { 129 | age = data.maxNoReadAge(key, value) || 0; 130 | } 131 | 132 | if(age !== null && ! data.timerWheel.schedule(node, age)) { 133 | // Age was not accepted by wheel, delete any previous value 134 | return this.delete(key); 135 | } 136 | 137 | try { 138 | const replaced = this[PARENT].set(key, node); 139 | return replaced ? replaced.value : null; 140 | } catch(ex) { 141 | timerWheel.deschedule(node); 142 | throw ex; 143 | } 144 | } 145 | 146 | /** 147 | * Get the cached value for the specified key if it exists. Will return 148 | * the value or `null` if no cached value exist. Updates the usage of the 149 | * key. 150 | * 151 | * @param key - 152 | * key to get 153 | * @returns 154 | * current value or `null` 155 | */ 156 | public getIfPresent(key: K): V | null { 157 | const node = this[PARENT].getIfPresent(key); 158 | if(node) { 159 | if(node.isExpired()) { 160 | // Check if the node is expired and return null if so 161 | return null; 162 | } 163 | 164 | // Reschedule if we have a maximum age between reads 165 | const data = this[DATA]; 166 | if(data.maxNoReadAge) { 167 | const age = data.maxNoReadAge(key, node.value as V); 168 | if(! data.timerWheel.schedule(node as TimerNode, age)) { 169 | // Age was not accepted by wheel, expire it directly 170 | this.delete(key); 171 | } 172 | } 173 | 174 | return node.value; 175 | } 176 | 177 | return null; 178 | } 179 | 180 | /** 181 | * Peek to see if a key is present without updating the usage of the 182 | * key. Returns the value associated with the key or `null` if the key 183 | * is not present. 184 | * 185 | * In many cases `has(key)` is a better option to see if a key is present. 186 | * 187 | * @param key - 188 | * the key to check 189 | * @returns 190 | * value associated with key or `null` 191 | */ 192 | public peek(key: K): V | null { 193 | const node = this[PARENT].peek(key); 194 | return node && ! node.isExpired() ? node.value : null; 195 | } 196 | 197 | /** 198 | * Check if the given key exists in the cache. 199 | * 200 | * @param key - 201 | * key to check 202 | * @returns 203 | * `true` if value currently exists, `false` otherwise 204 | */ 205 | public has(key: K): boolean { 206 | const node = this[PARENT].peek(key); 207 | return (node && ! node.isExpired()) || false; 208 | } 209 | 210 | /** 211 | * Delete a value in the cache. Returns the deleted value or `null` if 212 | * there was no value associated with the key in the cache. 213 | * 214 | * @param key - 215 | * the key to delete 216 | * @returns 217 | * deleted value or `null` 218 | */ 219 | public delete(key: K): V | null { 220 | const node = this[PARENT].delete(key); 221 | return node ? node.value : null; 222 | } 223 | 224 | /** 225 | * Clear the cache removing all of the entries cached. 226 | */ 227 | public clear(): void { 228 | this[PARENT].clear(); 229 | } 230 | 231 | /** 232 | * Get all of the keys in the cache as an array. Can be used to iterate 233 | * over all of the values in the cache, but be sure to protect against 234 | * values being removed during iteration due to time-based expiration if 235 | * used. 236 | * 237 | * @returns 238 | * snapshot of keys 239 | */ 240 | public keys(): K[] { 241 | return this[PARENT].keys(); 242 | } 243 | 244 | /** 245 | * Request clean up of the cache by removing expired entries and 246 | * old data. Clean up is done automatically a short time after sets and 247 | * deletes, but if your cache uses time-based expiration and has very 248 | * sporadic updates it might be a good idea to call `cleanUp()` at times. 249 | * 250 | * A good starting point would be to call `cleanUp()` in a `setInterval` 251 | * with a delay of at least a few minutes. 252 | */ 253 | public cleanUp(): void { 254 | this[PARENT].cleanUp(); 255 | } 256 | 257 | /** 258 | * Get metrics for this cache. Returns an object with the keys `hits`, 259 | * `misses` and `hitRate`. For caches that do not have metrics enabled 260 | * trying to access metrics will throw an error. 261 | * 262 | * @returns 263 | * metrics if available via the parent cache 264 | */ 265 | public get metrics(): Metrics { 266 | return this[PARENT].metrics; 267 | } 268 | 269 | private [MAINTENANCE]() { 270 | this[DATA].timerWheel.advance(); 271 | 272 | const onMaintenance = this[ON_MAINTENANCE]; 273 | if(onMaintenance) { 274 | onMaintenance(); 275 | } 276 | } 277 | 278 | private [TRIGGER_REMOVE](key: K, value: V, reason: RemovalReason) { 279 | // Trigger any extended remove listeners 280 | const onRemove = this[ON_REMOVE]; 281 | if(onRemove) { 282 | onRemove(key, value, reason); 283 | } 284 | 285 | const data = this[DATA]; 286 | // Trigger the removal listener 287 | if(data.removalListener) { 288 | data.removalListener(key, value, reason); 289 | } 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /src/cache/expiration/MaxAgeDecider.ts: -------------------------------------------------------------------------------- 1 | import { KeyType } from '../KeyType'; 2 | 3 | /** 4 | * Decider for how long something stays cached. Takes in the key and the value 5 | * and should return a maximum age in milliseconds. 6 | */ 7 | export type MaxAgeDecider = (key: K, value: V) => number; 8 | -------------------------------------------------------------------------------- /src/cache/expiration/TimerWheel.ts: -------------------------------------------------------------------------------- 1 | import { CacheNode } from '../CacheNode'; 2 | import { KeyType } from '../KeyType'; 3 | 4 | import { Expirable } from './Expirable'; 5 | 6 | /** 7 | * Helper function to calculate the closest power of two to N. 8 | * 9 | * @param n - 10 | * input 11 | * @returns 12 | * closest power of two to `n` 13 | */ 14 | function toPowerOfN(n: number) { 15 | return Math.pow(2, Math.ceil(Math.log(n) / Math.LN2)); 16 | } 17 | 18 | const LAYERS = [ 64, 64, 32, 4, 1 ]; 19 | const SPANS = [ toPowerOfN(1000), toPowerOfN(60000), toPowerOfN(3600000), toPowerOfN(86400000), LAYERS[3] * toPowerOfN(86400000), LAYERS[3] * toPowerOfN(86400000) ]; 20 | 21 | const SHIFTS = SPANS.slice(0, SPANS.length - 1).map(span => 1 + Math.floor(Math.log(span - 1) * Math.LOG2E)); 22 | 23 | export type EvictionListener = (keys: K[]) => void; 24 | 25 | /** 26 | * A timer wheel for variable expiration of items in a cache. Stores items in 27 | * layers that are circular buffers that represent a time span. 28 | * 29 | * This implementation takes some extra care to work with Number as they are 30 | * actually doubles and shifting turns them into 32-bit ints. To represent 31 | * time we need more than 32-bits so to fully support things this implementation 32 | * uses a base which is removed from all of the numbers to make them fit into 33 | * 32-bits. 34 | * 35 | * Based on an idea by Ben Manes implemented in Caffeine. 36 | */ 37 | export class TimerWheel { 38 | private evict: EvictionListener; 39 | 40 | private time: number; 41 | private base: number; 42 | private layers: TimerNode[][]; 43 | 44 | public constructor(evict: EvictionListener) { 45 | this.evict = evict; 46 | 47 | this.base = Date.now(); 48 | this.layers = LAYERS.map(b => { 49 | const result = new Array(b); 50 | for(let i = 0; i < b; i++) { 51 | result[i] = new TimerNode(this, null, null); 52 | } 53 | return result; 54 | }); 55 | 56 | this.time = 0; 57 | } 58 | 59 | public get localTime() { 60 | return Date.now() - this.base; 61 | } 62 | 63 | private findBucket(node: TimerNode): TimerNode | null { 64 | const d = node.time - this.time; 65 | if(d <= 0) return null; 66 | 67 | const layers = this.layers; 68 | for(let i = 0, n = layers.length - 1; i < n; i++) { 69 | if(d >= SPANS[i + 1]) continue; 70 | 71 | const ticks = node.time >>> SHIFTS[i]; 72 | const index = ticks & (layers[i].length - 1); 73 | return layers[i][index]; 74 | } 75 | return layers[layers.length - 1][0]; 76 | } 77 | 78 | public advance(localTime?: number) { 79 | const previous = this.time; 80 | const time = localTime || this.localTime; 81 | this.time = time; 82 | 83 | const layers = this.layers; 84 | 85 | // Holder for expired keys 86 | let expired: K[] | null = null; 87 | 88 | /* 89 | * Go through all of the layers on the wheel, evict things and move 90 | * other stuff around. 91 | */ 92 | for(let i = 0, n = SHIFTS.length; i < n; i++) { 93 | const previousTicks = previous >>> SHIFTS[i]; 94 | const timeTicks = time >>> SHIFTS[i]; 95 | 96 | // At the same tick, no need to keep working down the layers 97 | if(timeTicks <= previousTicks) break; 98 | 99 | const wheel = layers[i]; 100 | 101 | // Figure out the actual buckets to use 102 | let start; 103 | let end; 104 | if(time - previous >= SPANS[i + 1]) { 105 | start = 0; 106 | end = wheel.length - 1; 107 | } else { 108 | start = previousTicks & (SPANS[i] - 1); 109 | end = timeTicks & (SPANS[i] - 1); 110 | } 111 | 112 | // Go through all of the buckets and move stuff around 113 | for(let j = start; j <= end; j++) { 114 | const head = wheel[j & (wheel.length - 1)]; 115 | 116 | let node = head.next; 117 | 118 | head.previous = head; 119 | head.next = head; 120 | 121 | while(node !== head) { 122 | const next = node.next; 123 | node.remove(); 124 | 125 | if(node.time <= time) { 126 | // This node has expired, add it to the queue 127 | if(! expired) expired = []; 128 | expired.push(node.key as K); 129 | } else { 130 | // Find a new bucket to put this node in 131 | const b = this.findBucket(node); 132 | if(b) { 133 | node.appendToTail(b); 134 | } 135 | } 136 | node = next; 137 | } 138 | } 139 | } 140 | 141 | if(expired) { 142 | this.evict(expired); 143 | } 144 | } 145 | 146 | /** 147 | * Create a node that that helps with tracking when a key and value 148 | * should be evicted. 149 | * 150 | * @param key - 151 | * key to set 152 | * @param value - 153 | * value to set 154 | * @returns 155 | * node 156 | */ 157 | public node(key: K, value: V): TimerNode { 158 | return new TimerNode(this, key, value); 159 | } 160 | 161 | /** 162 | * Schedule eviction of the given node at the given timestamp. 163 | * 164 | * @param node - 165 | * node to reschedule 166 | * @param time - 167 | * new expiration time 168 | * @returns 169 | * if the node was rescheduled 170 | */ 171 | public schedule(node: TimerNode, time: number) { 172 | node.remove(); 173 | 174 | if(time <= 0) return false; 175 | 176 | node.time = this.localTime + time; 177 | 178 | const parent = this.findBucket(node); 179 | if(! parent) return false; 180 | 181 | node.appendToTail(parent); 182 | return true; 183 | } 184 | 185 | /* 186 | * Remove the given node from the wheel. 187 | */ 188 | public deschedule(node: TimerNode) { 189 | node.remove(); 190 | } 191 | } 192 | 193 | /* Node in a doubly linked list. More or less the same as used in BoundedCache */ 194 | export class TimerNode extends CacheNode implements Expirable { 195 | private wheel: TimerWheel; 196 | public time: number; 197 | 198 | public constructor(wheel: TimerWheel, key: K | null, value: V | null) { 199 | super(key, value); 200 | 201 | this.time = Number.MAX_SAFE_INTEGER; 202 | this.wheel = wheel; 203 | } 204 | 205 | public isExpired() { 206 | return this.wheel.localTime > this.time; 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/cache/expiration/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ExpirationCache'; 2 | export * from './Expirable'; 3 | export * from './MaxAgeDecider'; 4 | -------------------------------------------------------------------------------- /src/cache/index.ts: -------------------------------------------------------------------------------- 1 | export { KeyType } from './KeyType'; 2 | 3 | export { AbstractCache } from './AbstractCache'; 4 | export { Cache } from './Cache'; 5 | 6 | export { Weigher } from './Weigher'; 7 | 8 | export { RemovalReason } from './RemovalReason'; 9 | export { RemovalListener } from './RemovalListener'; 10 | 11 | export * from './metrics'; 12 | export * from './loading'; 13 | export * from './bounded'; 14 | export * from './boundless'; 15 | export * from './expiration'; 16 | -------------------------------------------------------------------------------- /src/cache/loading/DefaultLoadingCache.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Cache } from '../Cache'; 3 | import { CommonCacheOptions } from '../CommonCacheOptions'; 4 | import { KeyType } from '../KeyType'; 5 | import { WrappedCache } from '../WrappedCache'; 6 | 7 | import { Loader } from './Loader'; 8 | import { LoadingCache } from './LoadingCache'; 9 | 10 | const DATA = Symbol('loadingData'); 11 | 12 | /** 13 | * Options available for a loading cache. 14 | */ 15 | export interface LoadingCacheOptions extends CommonCacheOptions { 16 | loader?: Loader | undefined | null; 17 | 18 | parent: Cache; 19 | } 20 | 21 | interface LoadingCacheData { 22 | promises: Map>; 23 | 24 | loader: Loader | null; 25 | } 26 | 27 | /** 28 | * Extension to another cache that will load items if they are not cached. 29 | */ 30 | export class DefaultLoadingCache extends WrappedCache implements LoadingCache { 31 | private [DATA]: LoadingCacheData; 32 | 33 | public constructor(options: LoadingCacheOptions) { 34 | super(options.parent, options.removalListener || null); 35 | 36 | this[DATA] = { 37 | promises: new Map(), 38 | loader: options.loader || null 39 | }; 40 | } 41 | 42 | /** 43 | * Get cached value or load it if not currently cached. Updates the usage 44 | * of the key. 45 | * 46 | * @param key - 47 | * key to get 48 | * @param loader - 49 | * optional loader to use for loading the object 50 | * @returns 51 | * promise that resolves to the loaded value 52 | */ 53 | public get(key: K, loader?: Loader): Promise { 54 | const currentValue = this.getIfPresent(key); 55 | if(currentValue !== null) { 56 | return Promise.resolve(currentValue); 57 | } 58 | 59 | const data = this[DATA]; 60 | 61 | // First check if we are already loading this value 62 | let promise = data.promises.get(key); 63 | if(promise) return promise; 64 | 65 | // Create the initial promise if we are not already loading 66 | if(typeof loader !== 'undefined') { 67 | if(typeof loader !== 'function') { 68 | throw new Error('If loader is used it must be a function that returns a value or a Promise'); 69 | } 70 | promise = Promise.resolve(loader(key)); 71 | } else if(data.loader) { 72 | promise = Promise.resolve(data.loader(key)); 73 | } 74 | 75 | if(! promise) { 76 | throw new Error('No way to load data for key: ' + key); 77 | } 78 | 79 | // Enhance with handler that will remove promise and set value if success 80 | const resolve = () => data.promises.delete(key); 81 | promise = promise.then(result => { 82 | this.set(key, result); 83 | resolve(); 84 | return result; 85 | }).catch(err => { 86 | resolve(); 87 | throw err; 88 | }); 89 | 90 | data.promises.set(key, promise); 91 | 92 | return promise; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/cache/loading/Loader.ts: -------------------------------------------------------------------------------- 1 | import { KeyType } from '../KeyType'; 2 | 3 | /** 4 | * Function used to load a value in the cache. Can return a promise or a 5 | * value directly. 6 | */ 7 | export type Loader = (key: K) => Promise | V; 8 | -------------------------------------------------------------------------------- /src/cache/loading/LoadingCache.ts: -------------------------------------------------------------------------------- 1 | import { Cache } from '../Cache'; 2 | import { KeyType } from '../KeyType'; 3 | 4 | import { Loader } from './Loader'; 5 | 6 | /** 7 | * Cache that also supports loading of data if it's not in the cache. 8 | */ 9 | export interface LoadingCache extends Cache { 10 | /** 11 | * Get cached value or load it if not currently cached. Updates the usage 12 | * of the key. 13 | * 14 | * @param key - 15 | * key to get 16 | * @param loader - 17 | * optional loader to use for loading the object 18 | * @returns 19 | * promise that resolves to the loaded value 20 | */ 21 | get(key: K, loader?: Loader): Promise; 22 | } 23 | -------------------------------------------------------------------------------- /src/cache/loading/index.ts: -------------------------------------------------------------------------------- 1 | export * from './DefaultLoadingCache'; 2 | export * from './LoadingCache'; 3 | export * from './Loader'; 4 | -------------------------------------------------------------------------------- /src/cache/metrics/Metrics.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Metrics for a cache. Includes information about the number of hits, 3 | * misses and the hit ratio. 4 | */ 5 | export interface Metrics { 6 | hits: number; 7 | misses: number; 8 | readonly hitRate: number; 9 | } 10 | -------------------------------------------------------------------------------- /src/cache/metrics/MetricsCache.ts: -------------------------------------------------------------------------------- 1 | import { Cache } from '../Cache'; 2 | import { CommonCacheOptions } from '../CommonCacheOptions'; 3 | import { KeyType } from '../KeyType'; 4 | import { WrappedCache } from '../WrappedCache'; 5 | 6 | import { Metrics } from './Metrics'; 7 | 8 | 9 | const METRICS = Symbol('metrics'); 10 | 11 | /** 12 | * Options available for a metrics cache. 13 | */ 14 | export interface MetricsCacheOptions extends CommonCacheOptions { 15 | parent: Cache; 16 | } 17 | 18 | /** 19 | * Extension to a cache that tracks metrics about the size and hit rate of 20 | * a cache. 21 | */ 22 | export class MetricsCache extends WrappedCache { 23 | private [METRICS]: Metrics; 24 | 25 | public constructor(options: MetricsCacheOptions) { 26 | super(options.parent, options.removalListener || null); 27 | 28 | this[METRICS] = { 29 | hits: 0, 30 | misses: 0, 31 | 32 | get hitRate() { 33 | const total = this.hits + this.misses; 34 | if(total === 0) return 1.0; 35 | 36 | return this.hits / total; 37 | } 38 | }; 39 | } 40 | 41 | /** 42 | * Get metrics for this cache. Returns an object with the keys `hits`, 43 | * `misses` and `hitRate`. For caches that do not have metrics enabled 44 | * trying to access metrics will throw an error. 45 | * 46 | * @returns 47 | * metrics of cache 48 | */ 49 | public get metrics(): Metrics { 50 | return this[METRICS]; 51 | } 52 | 53 | /** 54 | * Get the cached value for the specified key if it exists. Will return 55 | * the value or `null` if no cached value exist. Updates the usage of the 56 | * key. 57 | * 58 | * @param key - 59 | * key to get 60 | * @returns 61 | * current value or `null` 62 | */ 63 | public getIfPresent(key: K): V | null { 64 | const result = super.getIfPresent(key); 65 | 66 | if(result === null) { 67 | this[METRICS].misses++; 68 | } else { 69 | this[METRICS].hits++; 70 | } 71 | return result; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/cache/metrics/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Metrics'; 2 | export * from './MetricsCache'; 3 | -------------------------------------------------------------------------------- /src/cache/symbols.ts: -------------------------------------------------------------------------------- 1 | export const PARENT = Symbol('parent'); 2 | 3 | /** 4 | * SPI extension for listening to removal of a wrapped cache. 5 | */ 6 | export const ON_REMOVE = Symbol('onRemove'); 7 | 8 | /** 9 | * Shared symbol used for common code that triggers remove listeners. 10 | */ 11 | export const TRIGGER_REMOVE = Symbol('triggerRemove'); 12 | 13 | /** 14 | * SPI extension for listening to eviction events. 15 | */ 16 | export const ON_MAINTENANCE = Symbol('onMaintenance'); 17 | 18 | /** 19 | * Shared symbol used for common code related to maintenace. 20 | */ 21 | export const MAINTENANCE = Symbol('maintenance'); 22 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cache'; 2 | export * from './builder'; 3 | 4 | export { memoryEstimator } from './utils/memoryEstimator'; 5 | -------------------------------------------------------------------------------- /src/utils/memoryEstimator.ts: -------------------------------------------------------------------------------- 1 | import { CacheNode } from '../cache/CacheNode'; 2 | 3 | const OBJ_OVERHEAD = 4; 4 | 5 | /** 6 | * Estimate the memory usage of the given object. 7 | * 8 | * @param value - 9 | * value to estimates 10 | * @returns 11 | * estimated memory usage in bytes 12 | */ 13 | export function memoryEstimator(value: any): number { 14 | switch(typeof value) { 15 | case 'string': 16 | return OBJ_OVERHEAD + (value.length * 2); 17 | case 'boolean': 18 | return 4; 19 | case 'number': 20 | return 8; 21 | case 'object': 22 | { 23 | if(typeof Buffer !== 'undefined' && Buffer.isBuffer(value)) { 24 | return OBJ_OVERHEAD + value.length; 25 | } else if(Array.isArray(value)) { 26 | let arraySize = OBJ_OVERHEAD; 27 | for(const v of value) { 28 | arraySize += memoryEstimator(v); 29 | } 30 | return arraySize; 31 | } else if(value instanceof CacheNode) { 32 | // Treat cache nodes as having a key and value field 33 | return OBJ_OVERHEAD 34 | + OBJ_OVERHEAD + memoryEstimator(value.key) 35 | + OBJ_OVERHEAD + memoryEstimator(value.value); 36 | } 37 | 38 | let size = OBJ_OVERHEAD; 39 | Object.keys(value).forEach(key => { 40 | size += OBJ_OVERHEAD; 41 | 42 | size += memoryEstimator(key); 43 | size += memoryEstimator(value[key]); 44 | }); 45 | return size; 46 | } 47 | default: 48 | return 0; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /test/bounded.test.ts: -------------------------------------------------------------------------------- 1 | 2 | import { BoundedCache } from '../src/cache/bounded'; 3 | import { RemovalReason } from '../src/cache/RemovalReason'; 4 | 5 | import { RemovalHelper } from './removal-helper'; 6 | 7 | describe('BoundedCache', function() { 8 | it('Can create', function() { 9 | new BoundedCache({ 10 | maxSize: 50 11 | }); 12 | }); 13 | 14 | it('Set value in cache', function() { 15 | const cache = new BoundedCache({ maxSize: 50 }); 16 | cache.set('key', 'value'); 17 | 18 | expect(cache.has('key')).toEqual(true); 19 | expect(cache.getIfPresent('key')).toEqual('value'); 20 | expect(cache.peek('key')).toEqual('value'); 21 | 22 | cache.cleanUp(); 23 | }); 24 | 25 | it('Get non-existent value in cache', function() { 26 | const cache = new BoundedCache({ maxSize: 50 }); 27 | 28 | expect(cache.getIfPresent('key')).toEqual(null); 29 | }); 30 | 31 | it('Delete works', function() { 32 | const cache = new BoundedCache({ maxSize: 50 }); 33 | cache.set('key', 'value'); 34 | 35 | cache.delete('key'); 36 | expect(cache.getIfPresent('key')).toEqual(null); 37 | 38 | cache.cleanUp(); 39 | }); 40 | 41 | it('Weighted size is correct', function() { 42 | const cache = new BoundedCache({ maxSize: 50 }); 43 | 44 | expect(cache.weightedSize).toEqual(0); 45 | expect(cache.maxSize).toEqual(50); 46 | 47 | cache.set('key', 'value'); 48 | expect(cache.weightedSize).toEqual(1); 49 | 50 | cache.set('key2', 'value'); 51 | expect(cache.weightedSize).toEqual(2); 52 | 53 | cache.set('key', 'value'); 54 | expect(cache.weightedSize).toEqual(2); 55 | 56 | cache.delete('key'); 57 | expect(cache.weightedSize).toEqual(1); 58 | 59 | cache.cleanUp(); 60 | }); 61 | 62 | it('Clear for empty', function() { 63 | const cache = new BoundedCache({ maxSize: 50 }); 64 | cache.clear(); 65 | expect(cache.size).toEqual(0); 66 | }); 67 | 68 | it('Clear for single', function() { 69 | const cache = new BoundedCache({ maxSize: 50 }); 70 | cache.set('key', 'value'); 71 | 72 | cache.clear(); 73 | expect(cache.size).toEqual(0); 74 | }); 75 | 76 | it('Getting keys work', function() { 77 | const cache = new BoundedCache({ maxSize: 50 }); 78 | cache.set('key', 'value'); 79 | 80 | expect(cache.keys()).toEqual([ 'key' ]); 81 | cache.cleanUp(); 82 | }); 83 | 84 | describe('Eviction', function() { 85 | it('Does not exceed maxSize', function() { 86 | const maxSize = 10; 87 | const cache = new BoundedCache({ maxSize }); 88 | 89 | for(let i = 0; i < maxSize * 2; i++) { 90 | cache.set(i, i); 91 | cache.cleanUp(); 92 | } 93 | 94 | expect(cache.size).toEqual(maxSize); 95 | }); 96 | 97 | it('Eviction order for small cache', function() { 98 | const maxSize = 3; 99 | const cache = new BoundedCache({ maxSize }); 100 | 101 | for(let i = 0; i < maxSize; i++) { 102 | cache.set(i, i); 103 | } 104 | 105 | cache.getIfPresent(0); 106 | cache.getIfPresent(2); 107 | 108 | cache.set(maxSize, maxSize); 109 | cache.cleanUp(); 110 | 111 | expect(cache.getIfPresent(1)).toEqual(null); 112 | expect(cache.getIfPresent(2)).toEqual(2); 113 | expect(cache.getIfPresent(3)).toEqual(3); 114 | }); 115 | 116 | it('Keys evicted before array returned', function() { 117 | const maxSize = 10; 118 | const cache = new BoundedCache({ maxSize }); 119 | 120 | for(let i = 0; i < maxSize * 2; i++) { 121 | cache.set(i, i); 122 | } 123 | 124 | expect(cache.keys().length).toEqual(maxSize); 125 | cache.cleanUp(); 126 | }); 127 | }); 128 | 129 | describe('Removal listeners', function() { 130 | it('Triggers on delete', function() { 131 | const removal = new RemovalHelper(); 132 | const cache = new BoundedCache({ 133 | maxSize: 10, 134 | removalListener: removal.listener 135 | }); 136 | 137 | cache.set('one', 1234); 138 | cache.cleanUp(); 139 | expect(removal.didRemove).toEqual(false); 140 | 141 | cache.delete('one'); 142 | cache.cleanUp(); 143 | expect(removal.didRemove).toEqual(true); 144 | expect(removal.removedKey).toEqual('one'); 145 | expect(removal.removedValue).toEqual(1234); 146 | expect(removal.removalReason).toEqual(RemovalReason.EXPLICIT); 147 | }); 148 | 149 | it('Triggers on set', function() { 150 | const removal = new RemovalHelper(); 151 | const cache = new BoundedCache({ 152 | maxSize: 10, 153 | removalListener: removal.listener 154 | }); 155 | 156 | cache.set('one', 1234); 157 | cache.cleanUp(); 158 | expect(removal.didRemove).toEqual(false); 159 | 160 | cache.set('one', 4321); 161 | cache.cleanUp(); 162 | expect(removal.didRemove).toEqual(true); 163 | expect(removal.removedKey).toEqual('one'); 164 | expect(removal.removedValue).toEqual(1234); 165 | expect(removal.removalReason).toEqual(RemovalReason.REPLACED); 166 | }); 167 | 168 | it('Triggers on evict', function() { 169 | const removal = new RemovalHelper(); 170 | const cache = new BoundedCache({ 171 | maxSize: 5, 172 | removalListener: removal.listener 173 | }); 174 | 175 | for(let i = 0; i < 5; i++) { 176 | cache.set(i, 1234); 177 | } 178 | cache.cleanUp(); 179 | expect(removal.didRemove).toEqual(false); 180 | 181 | cache.getIfPresent(0); 182 | cache.getIfPresent(1); 183 | cache.getIfPresent(2); 184 | cache.getIfPresent(3); 185 | 186 | cache.set(5, 1234); 187 | cache.cleanUp(); 188 | expect(removal.didRemove).toEqual(true); 189 | expect(removal.removedKey).toEqual(4); 190 | expect(removal.removedValue).toEqual(1234); 191 | expect(removal.removalReason).toEqual(RemovalReason.SIZE); 192 | }); 193 | 194 | it('Triggers on clear', function() { 195 | const removal = new RemovalHelper(); 196 | const cache = new BoundedCache({ 197 | maxSize: 10, 198 | removalListener: removal.listener 199 | }); 200 | 201 | cache.set('one', 1234); 202 | cache.cleanUp(); 203 | expect(removal.didRemove).toEqual(false); 204 | 205 | cache.clear(); 206 | expect(removal.didRemove).toEqual(true); 207 | expect(removal.removedKey).toEqual('one'); 208 | expect(removal.removedValue).toEqual(1234); 209 | expect(removal.removalReason).toEqual(RemovalReason.EXPLICIT); 210 | }); 211 | }); 212 | 213 | describe('Weighted', function() { 214 | it('Can set', function() { 215 | const cache = new BoundedCache({ 216 | maxSize: 50, 217 | weigher: (key, value) => 2 218 | }); 219 | cache.set('key', 'value'); 220 | 221 | expect(cache.has('key')).toEqual(true); 222 | expect(cache.getIfPresent('key')).toEqual('value'); 223 | 224 | cache.cleanUp(); 225 | }); 226 | 227 | it('Does not exceed maxSize', function() { 228 | const cache = new BoundedCache({ 229 | maxSize: 50, 230 | weigher: (key, value) => 10 231 | }); 232 | 233 | for(let i = 0; i < 6; i++) { 234 | cache.set(i, i); 235 | } 236 | 237 | cache.cleanUp(); 238 | 239 | expect(cache.size).toEqual(5); 240 | }); 241 | 242 | it('Variable sizes do not exceed maxSize', function() { 243 | const cache = new BoundedCache({ 244 | maxSize: 500, 245 | weigher: (key, value) => value 246 | }); 247 | 248 | for(let i = 0; i < 500; i++) { 249 | cache.set(i, i); 250 | } 251 | 252 | cache.cleanUp(); 253 | 254 | expect(cache.weightedSize).toBeLessThanOrEqual(500); 255 | }); 256 | 257 | it('Variable sizes with random access do not exceed maxSize', function() { 258 | const cache = new BoundedCache({ 259 | maxSize: 500, 260 | weigher: (key, value) => value 261 | }); 262 | 263 | randomTrace(cache, 400, 5000); 264 | 265 | cache.cleanUp(); 266 | 267 | expect(cache.weightedSize).toBeLessThanOrEqual(500); 268 | }); 269 | }); 270 | }); 271 | 272 | function randomTrace(cache: BoundedCache, max: number, n: number) { 273 | for(let i = 0; i < n; i++) { 274 | const id = Math.floor(Math.random() * max); 275 | const c = cache.getIfPresent(id); 276 | if(c === null) { 277 | cache.set(id, id); 278 | } 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /test/boundless.test.ts: -------------------------------------------------------------------------------- 1 | import { BoundlessCache } from '../src/cache/boundless'; 2 | import { RemovalReason } from '../src/cache/RemovalReason'; 3 | 4 | import { RemovalHelper } from './removal-helper'; 5 | 6 | describe('BoundlessCache', function() { 7 | it('Can create', function() { 8 | new BoundlessCache({}); 9 | }); 10 | 11 | it('Set value in cache', function() { 12 | const cache = new BoundlessCache({}); 13 | cache.set('key', 'value'); 14 | 15 | expect(cache.has('key')).toEqual(true); 16 | expect(cache.getIfPresent('key')).toEqual('value'); 17 | }); 18 | 19 | it('Get non-existent value in cache', function() { 20 | const cache = new BoundlessCache({}); 21 | 22 | expect(cache.getIfPresent('key')).toEqual(null); 23 | }); 24 | 25 | it('Delete works', function() { 26 | const cache = new BoundlessCache({}); 27 | cache.set('key', 'value'); 28 | 29 | cache.delete('key'); 30 | expect(cache.getIfPresent('key')).toEqual(null); 31 | }); 32 | 33 | it('Clear for empty', function() { 34 | const cache = new BoundlessCache({}); 35 | cache.clear(); 36 | expect(cache.size).toEqual(0); 37 | }); 38 | 39 | it('Clear for single', function() { 40 | const cache = new BoundlessCache({}); 41 | cache.set('key', 'value'); 42 | 43 | cache.clear(); 44 | expect(cache.size).toEqual(0); 45 | }); 46 | 47 | it('Getting keys work', function() { 48 | const cache = new BoundlessCache({}); 49 | cache.set('key', 'value'); 50 | 51 | expect(cache.keys()).toEqual([ 'key' ]); 52 | }); 53 | 54 | describe('Removal listeners', function() { 55 | it('Triggers on delete', function() { 56 | const removal = new RemovalHelper(); 57 | const cache = new BoundlessCache({ 58 | removalListener: removal.listener 59 | }); 60 | 61 | cache.set('one', 1234); 62 | expect(removal.didRemove).toEqual(false); 63 | 64 | cache.delete('one'); 65 | expect(removal.didRemove).toEqual(true); 66 | expect(removal.removedKey).toEqual('one'); 67 | expect(removal.removedValue).toEqual(1234); 68 | expect(removal.removalReason).toEqual(RemovalReason.EXPLICIT); 69 | }); 70 | 71 | it('Triggers on set', function() { 72 | const removal = new RemovalHelper(); 73 | const cache = new BoundlessCache({ 74 | removalListener: removal.listener 75 | }); 76 | 77 | cache.set('one', 1234); 78 | expect(removal.didRemove).toEqual(false); 79 | 80 | cache.set('one', 4321); 81 | expect(removal.didRemove).toEqual(true); 82 | expect(removal.removedKey).toEqual('one'); 83 | expect(removal.removedValue).toEqual(1234); 84 | expect(removal.removalReason).toEqual(RemovalReason.REPLACED); 85 | }); 86 | 87 | it('Triggers on clear', function() { 88 | const removal = new RemovalHelper(); 89 | const cache = new BoundlessCache({ 90 | removalListener: removal.listener 91 | }); 92 | 93 | cache.set('one', 1234); 94 | expect(removal.didRemove).toEqual(false); 95 | 96 | cache.clear(); 97 | expect(removal.didRemove).toEqual(true); 98 | expect(removal.removedKey).toEqual('one'); 99 | expect(removal.removedValue).toEqual(1234); 100 | expect(removal.removalReason).toEqual(RemovalReason.EXPLICIT); 101 | }); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /test/builder.test.ts: -------------------------------------------------------------------------------- 1 | import { CacheBuilderImpl } from '../src/builder/CacheBuilder'; 2 | import { AbstractCache } from '../src/cache/AbstractCache'; 3 | 4 | describe('Builder', function() { 5 | it('Can create boundless cache', function() { 6 | const cache = new CacheBuilderImpl() 7 | .build(); 8 | 9 | expect(cache).toBeTruthy(); 10 | expect(cache.maxSize).toEqual(-1); 11 | }); 12 | 13 | it('Can create bounded cache', function() { 14 | const cache = new CacheBuilderImpl() 15 | .maxSize(200) 16 | .build(); 17 | 18 | expect(cache).toBeTruthy(); 19 | expect(cache.maxSize).toEqual(200); 20 | }); 21 | 22 | it('Boundless cache is cache', function() { 23 | const cache = new CacheBuilderImpl() 24 | .build(); 25 | 26 | expect(cache instanceof AbstractCache).toEqual(true); 27 | }); 28 | 29 | it('Bounded cache is cache', function() { 30 | const cache = new CacheBuilderImpl() 31 | .maxSize(200) 32 | .build(); 33 | 34 | expect(cache instanceof AbstractCache).toEqual(true); 35 | }); 36 | 37 | it('Can create bounded cache', function() { 38 | const cache = new CacheBuilderImpl() 39 | .maxSize(20000) 40 | .build(); 41 | 42 | expect(cache).not.toBeNull(); 43 | expect(cache.maxSize).toEqual(20000); 44 | }); 45 | 46 | it('Can create boundless cache with loader', function() { 47 | const cache = new CacheBuilderImpl() 48 | .withLoader(() => Math.random()) 49 | .build(); 50 | 51 | expect(cache.maxSize).toEqual(-1); 52 | 53 | const p = cache.get('id'); 54 | expect(p).toBeInstanceOf(Promise); 55 | return p; 56 | }); 57 | 58 | it('Can create bounded cache with loader', function() { 59 | const cache = new CacheBuilderImpl() 60 | .maxSize(200) 61 | .withLoader(() => Math.random()) 62 | .build(); 63 | 64 | expect(cache.maxSize).toEqual(200); 65 | 66 | const p = cache.get('id'); 67 | expect(p).toBeInstanceOf(Promise); 68 | return p; 69 | }); 70 | 71 | it('Can create boundedless cache with local loader', function() { 72 | const cache = new CacheBuilderImpl() 73 | .loading() 74 | .build(); 75 | 76 | expect(cache.maxSize).toEqual(-1); 77 | }); 78 | 79 | it('Can create bounded cache with local loader', function() { 80 | const cache = new CacheBuilderImpl() 81 | .maxSize(200) 82 | .loading() 83 | .build(); 84 | 85 | expect(cache.maxSize).toEqual(200); 86 | }); 87 | 88 | it('Boundless cache with loader is cache', function() { 89 | const cache = new CacheBuilderImpl() 90 | .loading() 91 | .build(); 92 | 93 | expect(cache instanceof AbstractCache).toEqual(true); 94 | }); 95 | 96 | it('Bounded cache with loader is cache', function() { 97 | const cache = new CacheBuilderImpl() 98 | .maxSize(200) 99 | .loading() 100 | .build(); 101 | 102 | expect(cache instanceof AbstractCache).toEqual(true); 103 | }); 104 | 105 | it('Can create boundless cache with expire after write', function() { 106 | const cache = new CacheBuilderImpl() 107 | .expireAfterWrite(5000) 108 | .build(); 109 | 110 | expect(cache.maxSize).toEqual(-1); 111 | }); 112 | 113 | it('Can create bounded cache with expire after write', function() { 114 | const cache = new CacheBuilderImpl() 115 | .maxSize(200) 116 | .expireAfterWrite(5000) 117 | .build(); 118 | 119 | expect(cache.maxSize).toEqual(200); 120 | }); 121 | 122 | it('Can create boundless cache with expire after read', function() { 123 | const cache = new CacheBuilderImpl() 124 | .expireAfterRead(5000) 125 | .build(); 126 | 127 | expect(cache.maxSize).toEqual(-1); 128 | }); 129 | 130 | it('Can create bounded cache with expire after read', function() { 131 | const cache = new CacheBuilderImpl() 132 | .maxSize(200) 133 | .expireAfterRead(5000) 134 | .build(); 135 | 136 | expect(cache.maxSize).toEqual(200); 137 | }); 138 | 139 | it('Can create boundless cache with metrics', function() { 140 | const cache = new CacheBuilderImpl() 141 | .metrics() 142 | .build(); 143 | 144 | expect(cache.maxSize).toEqual(-1); 145 | }); 146 | 147 | it('Can create bounded cache with metrics', function() { 148 | const cache = new CacheBuilderImpl() 149 | .maxSize(200) 150 | .metrics() 151 | .build(); 152 | 153 | expect(cache.maxSize).toEqual(200); 154 | }); 155 | }); 156 | -------------------------------------------------------------------------------- /test/expiration.test.ts: -------------------------------------------------------------------------------- 1 | import { BoundlessCache } from '../src/cache/boundless'; 2 | import { ExpirationCache, Expirable } from '../src/cache/expiration'; 3 | import { KeyType } from '../src/cache/KeyType'; 4 | import { RemovalListener } from '../src/cache/RemovalListener'; 5 | import { RemovalReason } from '../src/cache/RemovalReason'; 6 | 7 | import { RemovalHelper } from './removal-helper'; 8 | 9 | /** 10 | * 11 | * @param listener 12 | */ 13 | function newCache(listener?: RemovalListener) { 14 | return new ExpirationCache({ 15 | parent: new BoundlessCache>({}), 16 | 17 | maxWriteAge: () => 100, 18 | removalListener: listener 19 | }); 20 | } 21 | 22 | /** 23 | * 24 | * @param listener 25 | */ 26 | function newReadCache(listener?: RemovalListener) { 27 | return new ExpirationCache({ 28 | parent: new BoundlessCache>({}), 29 | 30 | maxNoReadAge: () => 10, 31 | removalListener: listener 32 | }); 33 | } 34 | 35 | describe('ExpirationCache', function() { 36 | it('Can create', function() { 37 | newCache(); 38 | }); 39 | 40 | describe('With maxWriteAge', function() { 41 | it('Set value in cache', function() { 42 | const cache = newCache(); 43 | cache.set('key', 'value'); 44 | 45 | expect(cache.has('key')).toEqual(true); 46 | expect(cache.getIfPresent('key')).toEqual('value'); 47 | }); 48 | 49 | it('Get non-existent value in cache', function() { 50 | const cache = newCache(); 51 | 52 | expect(cache.getIfPresent('key')).toEqual(null); 53 | }); 54 | 55 | it('Delete works', function() { 56 | const cache = newCache(); 57 | cache.set('key', 'value'); 58 | 59 | cache.delete('key'); 60 | expect(cache.getIfPresent('key')).toEqual(null); 61 | }); 62 | 63 | it('Set value in cache with timeout', function(cb) { 64 | const cache = newCache(); 65 | cache.set('key', 'value'); 66 | 67 | setTimeout(() => { 68 | expect(cache.getIfPresent('key')).toBeNull(); 69 | cb(); 70 | }, 200); 71 | }); 72 | 73 | it('Set evicts old keys', function(cb) { 74 | const cache = newCache(); 75 | cache.set('key', 'value'); 76 | 77 | setTimeout(() => { 78 | cache.set('key2', 'value'); 79 | cache.cleanUp(); 80 | expect(cache.size).toEqual(1); 81 | cb(); 82 | }, 1080); 83 | }); 84 | 85 | it('Keys evicted before array returned', function(cb) { 86 | const cache = newCache(); 87 | cache.set('key', 'value'); 88 | 89 | setTimeout(() => { 90 | cache.cleanUp(); 91 | expect(cache.keys().length).toEqual(0); 92 | cb(); 93 | }, 1080); 94 | }); 95 | }); 96 | 97 | describe('With maxNoReadAge', function() { 98 | it('Set value in cache', function() { 99 | const cache = newReadCache(); 100 | cache.set('key', 'value'); 101 | 102 | expect(cache.has('key')).toEqual(true); 103 | expect(cache.getIfPresent('key')).toEqual('value'); 104 | }); 105 | 106 | it('Set value in cache with timeout', function(cb) { 107 | const cache = newReadCache(); 108 | cache.set('key', 'value'); 109 | 110 | setTimeout(() => { 111 | expect(cache.getIfPresent('key')).toBeNull(); 112 | cb(); 113 | }, 15); 114 | }); 115 | 116 | it('Set and get value in cache with timeout', function(cb) { 117 | const cache = newReadCache(); 118 | cache.set('key', 'value'); 119 | 120 | // Trigger the max age read 121 | cache.getIfPresent('key'); 122 | 123 | setTimeout(() => { 124 | expect(cache.getIfPresent('key')).toBeNull(); 125 | cb(); 126 | }, 15); 127 | }); 128 | }); 129 | 130 | describe('Removal listeners', function() { 131 | it('Triggers on delete', function() { 132 | const removal = new RemovalHelper(); 133 | const cache = newCache(removal.listener); 134 | cache.set('key', 'value'); 135 | 136 | cache.delete('key'); 137 | expect(cache.getIfPresent('key')).toEqual(null); 138 | 139 | expect(removal.didRemove).toEqual(true); 140 | expect(removal.removedKey).toEqual('key'); 141 | expect(removal.removedValue).toEqual('value'); 142 | expect(removal.removalReason).toEqual(RemovalReason.EXPLICIT); 143 | }); 144 | 145 | it('Triggers on set', function() { 146 | const removal = new RemovalHelper(); 147 | const cache = newCache(removal.listener); 148 | 149 | cache.set('one', 1234); 150 | expect(removal.didRemove).toEqual(false); 151 | 152 | cache.set('one', 4321); 153 | expect(removal.didRemove).toEqual(true); 154 | expect(removal.removedKey).toEqual('one'); 155 | expect(removal.removedValue).toEqual(1234); 156 | expect(removal.removalReason).toEqual(RemovalReason.REPLACED); 157 | }); 158 | 159 | it('Triggers on expiration', function(cb) { 160 | const removal = new RemovalHelper(); 161 | const cache = newCache(removal.listener); 162 | 163 | cache.set('one', 1234); 164 | expect(removal.didRemove).toEqual(false); 165 | 166 | setTimeout(() => { 167 | cache.set('one', 4321); 168 | expect(removal.didRemove).toEqual(true); 169 | expect(removal.removedKey).toEqual('one'); 170 | expect(removal.removedValue).toEqual(1234); 171 | expect(removal.removalReason).toEqual(RemovalReason.EXPIRED); 172 | cb(); 173 | }, 200); 174 | }); 175 | }); 176 | }); 177 | 178 | -------------------------------------------------------------------------------- /test/hashcode.test.ts: -------------------------------------------------------------------------------- 1 | import { hashcode } from '../src/cache/bounded/hashcode'; 2 | 3 | describe('Hash codes', function() { 4 | it('Calculates for string', function() { 5 | expect(hashcode('')).toEqual(0); 6 | expect(hashcode('', 1)).toEqual(0x514E28B7); 7 | expect(hashcode('abc')).toEqual(0xB3DD93FA); 8 | expect(hashcode('aaaa', 0x9747b28c)).toEqual(0x5A97808A); 9 | expect(hashcode('Hello, world!', 0x9747b28c)).toEqual(0x24884CBA); 10 | }); 11 | 12 | it('Calculates for number', function() { 13 | expect(hashcode(9292)).toBeTruthy(); 14 | }); 15 | 16 | it('Calculates for boolean', function() { 17 | expect(hashcode(false)).toBeTruthy(); 18 | }); 19 | 20 | it('Fails for object', function() { 21 | expect(() => hashcode({} as any)).toThrow(); 22 | }); 23 | 24 | it('Fails for function', function() { 25 | expect(() => hashcode(function() {} as any)).toThrow(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /test/loading.test.ts: -------------------------------------------------------------------------------- 1 | 2 | import { BoundlessCache } from '../src/cache/boundless'; 3 | import { KeyType } from '../src/cache/KeyType'; 4 | import { DefaultLoadingCache, Loader } from '../src/cache/loading'; 5 | import { RemovalReason } from '../src/cache/RemovalReason'; 6 | 7 | import { RemovalHelper } from './removal-helper'; 8 | 9 | /** 10 | * 11 | * @param loader 12 | */ 13 | function newCache(loader?: Loader) { 14 | return new DefaultLoadingCache({ 15 | loader: loader, 16 | parent: new BoundlessCache({}) 17 | }); 18 | } 19 | 20 | describe('LoadingCache', function() { 21 | it('Can create', function() { 22 | newCache(); 23 | }); 24 | 25 | it('Set value in cache', function() { 26 | const cache = newCache(); 27 | cache.set('key', 'value'); 28 | 29 | expect(cache.has('key')).toEqual(true); 30 | expect(cache.getIfPresent('key')).toEqual('value'); 31 | expect(cache.get('key')).toBeInstanceOf(Promise); 32 | 33 | return cache.get('key') 34 | .then((v: string) => expect(v).toEqual('value')); 35 | }); 36 | 37 | it('Get non-existent value in cache', function() { 38 | const cache = newCache(); 39 | 40 | expect(cache.getIfPresent('key')).toEqual(null); 41 | 42 | try { 43 | // eslint-disable-next-line @typescript-eslint/no-floating-promises 44 | cache.get('key'); 45 | fail(); 46 | // eslint-disable-next-line no-empty 47 | } catch(ex) { 48 | } 49 | }); 50 | 51 | it('Delete works', function() { 52 | const cache = newCache(); 53 | cache.set('key', 'value'); 54 | 55 | cache.delete('key'); 56 | expect(cache.getIfPresent('key')).toEqual(null); 57 | }); 58 | 59 | it('Loads non-existent via global loader', function() { 60 | const cache = newCache(id => -id); 61 | 62 | return cache.get(100) 63 | .then((v: number) => expect(v).toEqual(-100)); 64 | }); 65 | 66 | it('Loads non-existent via local loader', function() { 67 | const cache = newCache(); 68 | 69 | return cache.get(100, key => key * 2) 70 | .then((v: number) => expect(v).toEqual(200)); 71 | }); 72 | 73 | it('Loads non-existent via global loader with Promise', function() { 74 | const cache = newCache(key => value(key / 2)); 75 | 76 | return cache.get(100) 77 | .then((v: number) => expect(v).toEqual(50)); 78 | }); 79 | 80 | it('Non-existent failure via global loader with Promise', function() { 81 | const cache = newCache(() => error()); 82 | 83 | return cache.get(100) 84 | .then(() => { 85 | throw Error('This should have failed'); 86 | }) 87 | .catch(() => null); 88 | }); 89 | 90 | describe('Removal listeners', function() { 91 | it('Triggers on delete', function() { 92 | const removal = new RemovalHelper(); 93 | const cache = new BoundlessCache({ 94 | removalListener: removal.listener 95 | }); 96 | 97 | cache.set('one', 1234); 98 | expect(removal.didRemove).toEqual(false); 99 | 100 | cache.delete('one'); 101 | expect(removal.didRemove).toEqual(true); 102 | expect(removal.removedKey).toEqual('one'); 103 | expect(removal.removedValue).toEqual(1234); 104 | expect(removal.removalReason).toEqual(RemovalReason.EXPLICIT); 105 | }); 106 | 107 | it('Triggers on set', function() { 108 | const removal = new RemovalHelper(); 109 | const cache = new BoundlessCache({ 110 | removalListener: removal.listener 111 | }); 112 | 113 | cache.set('one', 1234); 114 | expect(removal.didRemove).toEqual(false); 115 | 116 | cache.set('one', 4321); 117 | expect(removal.didRemove).toEqual(true); 118 | expect(removal.removedKey).toEqual('one'); 119 | expect(removal.removedValue).toEqual(1234); 120 | expect(removal.removalReason).toEqual(RemovalReason.REPLACED); 121 | }); 122 | 123 | it('Triggers on clear', function() { 124 | const removal = new RemovalHelper(); 125 | const cache = new BoundlessCache({ 126 | removalListener: removal.listener 127 | }); 128 | 129 | cache.set('one', 1234); 130 | expect(removal.didRemove).toEqual(false); 131 | 132 | cache.clear(); 133 | expect(removal.didRemove).toEqual(true); 134 | expect(removal.removedKey).toEqual('one'); 135 | expect(removal.removedValue).toEqual(1234); 136 | expect(removal.removalReason).toEqual(RemovalReason.EXPLICIT); 137 | }); 138 | }); 139 | }); 140 | 141 | /** 142 | * 143 | * @param v 144 | */ 145 | function value(v: number): Promise { 146 | return new Promise(resolve => { 147 | setTimeout(() => resolve(v), 0); 148 | }); 149 | } 150 | 151 | /** 152 | * 153 | */ 154 | function error(): Promise { 155 | return new Promise((resolve, reject) => { 156 | setTimeout(() => reject(), 0); 157 | }); 158 | } 159 | -------------------------------------------------------------------------------- /test/memoryEstimator.test.ts: -------------------------------------------------------------------------------- 1 | 2 | import { CacheNode } from '../src/cache/CacheNode'; 3 | import { memoryEstimator } from '../src/utils/memoryEstimator'; 4 | 5 | describe('memoryEstimator', function() { 6 | it('string', function() { 7 | expect(memoryEstimator('kaka')).toEqual(12); 8 | }); 9 | 10 | it('number', function() { 11 | expect(memoryEstimator(2)).toEqual(8); 12 | }); 13 | 14 | it('boolean', function() { 15 | expect(memoryEstimator(false)).toEqual(4); 16 | }); 17 | 18 | it('object', function() { 19 | expect(memoryEstimator({ kaka: 2 })).toEqual(28); 20 | }); 21 | 22 | it('array', function() { 23 | expect(memoryEstimator([ 2, 'kaka' ])).toEqual(24); 24 | }); 25 | 26 | it('CacheNode', function() { 27 | expect(memoryEstimator(new CacheNode('key', { kaka: 2 }))).toEqual(50); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /test/metrics.test.ts: -------------------------------------------------------------------------------- 1 | import { BoundlessCache } from '../src/cache/boundless'; 2 | import { KeyType } from '../src/cache/KeyType'; 3 | import { MetricsCache } from '../src/cache/metrics/index'; 4 | import { RemovalReason } from '../src/cache/RemovalReason'; 5 | 6 | import { RemovalHelper } from './removal-helper'; 7 | 8 | /** 9 | * 10 | */ 11 | function newCache() { 12 | return new MetricsCache({ 13 | parent: new BoundlessCache({}) 14 | }); 15 | } 16 | 17 | describe('MetricsCache', function() { 18 | it('Can create', function() { 19 | newCache(); 20 | }); 21 | 22 | it('Set and get value in cache', function() { 23 | const cache = newCache(); 24 | cache.set('key', 'value'); 25 | 26 | expect(cache.has('key')).toEqual(true); 27 | expect(cache.getIfPresent('key')).toEqual('value'); 28 | 29 | expect(cache.metrics.hits).toEqual(1); 30 | expect(cache.metrics.misses).toEqual(0); 31 | expect(cache.metrics.hitRate).toEqual(1.0); 32 | }); 33 | 34 | it('Get non-existent value in cache', function() { 35 | const cache = newCache(); 36 | 37 | expect(cache.getIfPresent('key')).toEqual(null); 38 | 39 | expect(cache.metrics.hits).toEqual(0); 40 | expect(cache.metrics.misses).toEqual(1); 41 | expect(cache.metrics.hitRate).toEqual(0); 42 | }); 43 | 44 | it('Get without recording stats', function() { 45 | const cache = newCache(); 46 | 47 | expect(cache.peek('key')).toEqual(null); 48 | 49 | expect(cache.metrics.hits).toEqual(0); 50 | expect(cache.metrics.misses).toEqual(0); 51 | expect(cache.metrics.hitRate).toEqual(1); 52 | }); 53 | 54 | it('Peek without recording stats', function() { 55 | const cache = newCache(); 56 | 57 | expect(cache.peek('key')).toEqual(null); 58 | 59 | expect(cache.metrics.hits).toEqual(0); 60 | expect(cache.metrics.misses).toEqual(0); 61 | expect(cache.metrics.hitRate).toEqual(1); 62 | }); 63 | 64 | describe('Removal listeners', function() { 65 | it('Triggers on delete', function() { 66 | const removal = new RemovalHelper(); 67 | const cache = new BoundlessCache({ 68 | removalListener: removal.listener 69 | }); 70 | 71 | cache.set('one', 1234); 72 | expect(removal.didRemove).toEqual(false); 73 | 74 | cache.delete('one'); 75 | expect(removal.didRemove).toEqual(true); 76 | expect(removal.removedKey).toEqual('one'); 77 | expect(removal.removedValue).toEqual(1234); 78 | expect(removal.removalReason).toEqual(RemovalReason.EXPLICIT); 79 | }); 80 | 81 | it('Triggers on set', function() { 82 | const removal = new RemovalHelper(); 83 | const cache = new BoundlessCache({ 84 | removalListener: removal.listener 85 | }); 86 | 87 | cache.set('one', 1234); 88 | expect(removal.didRemove).toEqual(false); 89 | 90 | cache.set('one', 4321); 91 | expect(removal.didRemove).toEqual(true); 92 | expect(removal.removedKey).toEqual('one'); 93 | expect(removal.removedValue).toEqual(1234); 94 | expect(removal.removalReason).toEqual(RemovalReason.REPLACED); 95 | }); 96 | 97 | it('Triggers on clear', function() { 98 | const removal = new RemovalHelper(); 99 | const cache = new BoundlessCache({ 100 | removalListener: removal.listener 101 | }); 102 | 103 | cache.set('one', 1234); 104 | expect(removal.didRemove).toEqual(false); 105 | 106 | cache.clear(); 107 | expect(removal.didRemove).toEqual(true); 108 | expect(removal.removedKey).toEqual('one'); 109 | expect(removal.removedValue).toEqual(1234); 110 | expect(removal.removalReason).toEqual(RemovalReason.EXPLICIT); 111 | }); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /test/removal-helper.ts: -------------------------------------------------------------------------------- 1 | import { KeyType } from '../src/cache/KeyType'; 2 | import { RemovalListener } from '../src/cache/RemovalListener'; 3 | import { RemovalReason } from '../src/cache/RemovalReason'; 4 | 5 | export class RemovalHelper { 6 | public listener: RemovalListener; 7 | 8 | public didRemove: boolean; 9 | 10 | public removedKey: K | null; 11 | public removedValue: V | null; 12 | public removalReason: RemovalReason | null; 13 | 14 | public constructor() { 15 | this.listener = (key, value, reason) => { 16 | this.didRemove = true; 17 | 18 | this.removedKey = key; 19 | this.removedValue = value; 20 | this.removalReason = reason; 21 | }; 22 | 23 | this.didRemove = false; 24 | this.removedKey = null; 25 | this.removedValue = null; 26 | this.removalReason = null; 27 | } 28 | 29 | public reset() { 30 | this.didRemove = false; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/sketch.test.ts: -------------------------------------------------------------------------------- 1 | 2 | import { CountMinSketch } from '../src/cache/bounded/CountMinSketch'; 3 | 4 | /** 5 | * 6 | * @param key 7 | */ 8 | function hash(key: number | string) { 9 | return CountMinSketch.hash(key); 10 | } 11 | 12 | describe('CountMinSketch', function() { 13 | describe('uint8', function() { 14 | it('Update + estimate', function() { 15 | const sketch = CountMinSketch.uint8(5, 4); 16 | sketch.update(hash('one')); 17 | sketch.update(hash('two')); 18 | sketch.update(hash('two')); 19 | sketch.update(hash('two')); 20 | sketch.update(hash('two')); 21 | sketch.update(hash('two')); 22 | 23 | expect(sketch.estimate(hash('two'))).toEqual(5); 24 | }); 25 | 26 | it('Stability', function() { 27 | const updates = 2000; 28 | const max = 200; 29 | const sketch = CountMinSketch.uint8(max, 4, false); 30 | const data = new Map(); 31 | for(let i = 0; i < updates; i++) { 32 | const key = Math.floor(Math.random() * max); 33 | const c = data.get(key) || 0; 34 | data.set(key, c + 1); 35 | sketch.update(hash(key)); 36 | } 37 | 38 | let diff = 0; 39 | data.forEach((value, key) => { 40 | const estimated = sketch.estimate(hash(key)); 41 | const isSame = estimated === value || (value > 255 && estimated === 255); 42 | if(! isSame) diff++; 43 | }); 44 | 45 | expect(diff / data.size).toBeLessThan(0.10); 46 | }); 47 | }); 48 | 49 | describe('Generic', function() { 50 | it('Decay after N updates', function() { 51 | const sketch = CountMinSketch.uint8(5, 4); 52 | const n = 5 * 10;// same as sketch.resetAfter 53 | 54 | // Perform up to n-1 updates 55 | for(let i = 0; i < n - 1; i++) { 56 | sketch.update(hash(i % 10)); 57 | } 58 | 59 | // Perform one last update 60 | const current = sketch.estimate(hash(2)); 61 | sketch.update(hash(2)); 62 | 63 | // Check that the value has been cut in half 64 | const updated = sketch.estimate(hash(2)); 65 | 66 | expect(updated).toBeLessThanOrEqual(Math.ceil(current / 2)); 67 | expect(updated).toBeGreaterThanOrEqual(Math.floor(current / 2)); 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /test/timer-wheel.test.ts: -------------------------------------------------------------------------------- 1 | import { TimerWheel } from '../src/cache/expiration/TimerWheel'; 2 | import { KeyType } from '../src/cache/KeyType'; 3 | 4 | 5 | /** 6 | * 7 | * @param array 8 | */ 9 | function newWheel(array?: K[]): TimerWheel { 10 | const r = array || []; 11 | return new TimerWheel((expired: K[]) => expired.forEach(k => r.push(k))); 12 | } 13 | 14 | describe('TimerWheel', function() { 15 | it('Schedule', function() { 16 | const wheel = newWheel(); 17 | 18 | const node = wheel.node('test', 1); 19 | wheel.schedule(node, 10); 20 | }); 21 | 22 | describe('Expiration', function() { 23 | it('Expire in 200 ms @ 500 ms', function() { 24 | const expired: string[] = []; 25 | const wheel = newWheel(expired); 26 | 27 | const node = wheel.node('test', 1); 28 | wheel.schedule(node, 200); 29 | 30 | wheel.advance(500); 31 | expect(expired.length).toEqual(0); 32 | }); 33 | 34 | it('Expire in 200 ms @ 1.07 seconds', function() { 35 | const expired: string[] = []; 36 | const wheel = newWheel(expired); 37 | 38 | const node = wheel.node('test', 1); 39 | wheel.schedule(node, 200); 40 | 41 | wheel.advance(1070); 42 | expect(expired.length).toEqual(1); 43 | expect(expired[0]).toEqual('test'); 44 | }); 45 | 46 | it('Expire in 2 seconds @ 1.07 seconds', function() { 47 | const expired: string[] = []; 48 | const wheel = newWheel(expired); 49 | 50 | const node = wheel.node('test', 1); 51 | wheel.schedule(node, 2000); 52 | 53 | wheel.advance(1024); 54 | expect(expired.length).toEqual(0); 55 | }); 56 | 57 | it('Expire in 2 seconds @ 4 seconds', function() { 58 | const expired: string[] = []; 59 | const wheel = newWheel(expired); 60 | 61 | const node = wheel.node('test', 1); 62 | wheel.schedule(node, 2000); 63 | 64 | wheel.advance(4000); 65 | expect(expired.length).toEqual(1); 66 | expect(expired[0]).toEqual('test'); 67 | }); 68 | 69 | it('Expire in 2 minutes @ 1 minute', function() { 70 | const expired: string[] = []; 71 | const wheel = newWheel(expired); 72 | 73 | const node = wheel.node('test', 1); 74 | wheel.schedule(node, 2 * 60 * 1000); 75 | 76 | wheel.advance(60 * 1000); 77 | expect(expired.length).toEqual(0); 78 | }); 79 | 80 | it('Expire in 2 minutes @ 3 minutes', function() { 81 | const expired: string[] = []; 82 | const wheel = newWheel(expired); 83 | 84 | const node = wheel.node('test', 1); 85 | wheel.schedule(node, 2 * 60 * 1000); 86 | 87 | wheel.advance(3 * 60 * 1000); 88 | expect(expired.length).toEqual(1); 89 | expect(expired[0]).toEqual('test'); 90 | }); 91 | 92 | it('Expire in 2 minutes @ 1 minute and, 3 minutes', function() { 93 | const expired: string[] = []; 94 | const wheel = newWheel(expired); 95 | 96 | const node = wheel.node('test', 1); 97 | wheel.schedule(node, 2 * 60 * 1000); 98 | 99 | wheel.advance(1 * 60 * 1000); 100 | expect(expired.length).toEqual(0); 101 | 102 | wheel.advance(3 * 60 * 1000); 103 | expect(expired.length).toEqual(1); 104 | expect(expired[0]).toEqual('test'); 105 | }); 106 | 107 | it('Expire in 2 minutes @ 1 minute, 1 minute-10seconds and 2 minutes', function() { 108 | const expired: string[] = []; 109 | const wheel = newWheel(expired); 110 | 111 | const node = wheel.node('test', 1); 112 | wheel.schedule(node, 2 * 60 * 1000); 113 | 114 | wheel.advance(1 * 60 * 1000); 115 | expect(expired.length).toEqual(0); 116 | wheel.advance(2 * 60 * 1000 - 10000); 117 | expect(expired.length).toEqual(0); 118 | 119 | wheel.advance(2 * 60 * 1001); 120 | 121 | expect(expired.length).toEqual(1); 122 | expect(expired[0]).toEqual('test'); 123 | }); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | 6 | "noEmit": true, 7 | 8 | "baseUrl": ".", 9 | }, 10 | "include": [ "test/*", "src/*" ], 11 | "exclude": [ "**/node_modules/**", "**/dist/**" ] 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "es5", 5 | "module":"es2015", 6 | "lib": [ "es2015", "es2016", "es2017" ], 7 | "strict": true, 8 | "sourceMap": true, 9 | "declaration": true, 10 | "allowSyntheticDefaultImports": true, 11 | "downlevelIteration": true, 12 | "experimentalDecorators": true, 13 | "emitDecoratorMetadata": true, 14 | "declarationDir": "dist/types", 15 | "outDir": "dist/lib", 16 | "typeRoots": [ 17 | "node_modules/@types" 18 | ] 19 | }, 20 | "include": [ 21 | "src" 22 | ] 23 | } 24 | --------------------------------------------------------------------------------