├── .editorconfig ├── .eslintrc.json ├── .github └── workflows │ ├── build.yml │ └── codeql-analysis.yml ├── .gitignore ├── .prettierrc.yaml ├── .releaserc ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets └── logo.svg ├── jest.config.js ├── package-lock.json ├── package.json ├── renovate.json ├── src ├── Cache.spec.ts ├── Cache.ts ├── ConnectionStatus.ts ├── Executor.ts ├── LockedKeyRetrieveStrategy.ts ├── Logger.ts ├── Manager.ts ├── StorageAdapter.ts ├── __snapshots__ │ └── deserialize.spec.ts.snap ├── adapters │ ├── MemcachedStorageAdapter.spec.ts │ ├── MemcachedStorageAdapter.ts │ ├── RedisStorageAdapter.spec.ts │ ├── RedisStorageAdapter.ts │ └── TestStorageAdapter.ts ├── deserialize.spec.ts ├── deserialize.ts ├── errors │ ├── constants.ts │ ├── custom-error.spec.ts │ ├── custom-error.ts │ └── errors.ts ├── index.ts ├── locked-key-retrieve-strategies │ ├── RunExecutorLockedKeyRetrieveStrategy.spec.ts │ ├── RunExecutorLockedKeyRetrieveStrategy.ts │ ├── WaitForResultLockedKeyRetrieveStrategy.spec.ts │ └── WaitForResultLockedKeyRetrieveStrategy.ts ├── managers │ ├── BaseManager.ts │ ├── ReadThroughManager.spec.ts │ ├── ReadThroughManager.ts │ ├── RefreshAheadManager.spec.ts │ ├── RefreshAheadManager.ts │ ├── WriteThroughManager.spec.ts │ ├── WriteThroughManager.ts │ └── __mocks__ │ │ └── RefreshAheadManager.ts ├── storage │ ├── BaseStorage.spec.ts │ ├── BaseStorage.ts │ ├── Record.ts │ ├── Storage.ts │ └── __mocks__ │ │ ├── BaseStorage.ts │ │ └── TestStorage.ts ├── timeout.spec.ts ├── timeout.ts ├── with-timeout.spec.ts └── with-timeout.ts ├── tests ├── integration │ ├── adapter-agnostic.ts │ ├── base-agnostic.ts │ ├── base-memcached.spec.ts │ ├── base-redis.spec.ts │ ├── cache.spec.ts │ ├── memcached.spec.ts │ └── redis.spec.ts └── jest.config.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | end_of_line = lf 10 | # editorconfig-tools is unable to ignore longs strings or urls 11 | max_line_length = 140 12 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint"], 4 | "extends": [ 5 | "prettier", 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/eslint-recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | "overrides": [ 11 | { 12 | "files": ["src/**/__mocks__/*.ts", "*.spec.ts"], 13 | "rules": { 14 | "@typescript-eslint/no-explicit-any": "off" 15 | } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the action will run. Triggers the workflow on push or pull request 6 | # events but only for the master branch 7 | on: 8 | push: 9 | branches: [ master ] 10 | pull_request: 11 | branches: [ master ] 12 | 13 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 14 | jobs: 15 | # This workflow contains a single job called "build" 16 | build: 17 | # The type of runner that the job will run on 18 | runs-on: ubuntu-latest 19 | 20 | services: 21 | redis: 22 | image: redis:5 23 | ports: 24 | - 6379:6379 25 | options: >- 26 | --health-cmd "redis-cli ping" 27 | --health-interval 10s 28 | --health-timeout 5s 29 | --health-retries 5 30 | --entrypoint redis-server 31 | 32 | memcached: 33 | image: memcached 34 | ports: 35 | - 11211:11211 36 | 37 | strategy: 38 | matrix: 39 | node: [ 10, 12, 14, 16 ] 40 | 41 | # Steps represent a sequence of tasks that will be executed as part of the job 42 | steps: 43 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 44 | - uses: actions/checkout@v3 45 | 46 | - name: Use Node.js ${{ matrix.node-version }} 47 | uses: actions/setup-node@v3 48 | with: 49 | node-version: ${{ matrix.node }} 50 | cache: 'npm' 51 | - name: Install dependencies 52 | run: npm ci 53 | - run: npm run lint 54 | - run: npm run build 55 | - run: npm run test:ci 56 | - run: npm run test:integration 57 | 58 | - name: Coveralls GitHub Action 59 | uses: coverallsapp/github-action@master 60 | env: 61 | COVERALLS_FLAG_NAME: run-${{ matrix.node-version }} 62 | with: 63 | github-token: ${{ secrets.GITHUB_TOKEN }} 64 | parallel: true 65 | 66 | finish: 67 | needs: build 68 | runs-on: ubuntu-latest 69 | steps: 70 | - name: Coveralls Finished 71 | uses: coverallsapp/github-action@master 72 | with: 73 | github-token: ${{ secrets.GITHUB_TOKEN }} 74 | parallel-finished: true 75 | 76 | release: 77 | # Only release on push to master 78 | if: github.event_name == 'push' && github.ref == 'refs/heads/master' 79 | runs-on: ubuntu-latest 80 | # Waits for test jobs for each Node.js version to complete 81 | needs: [finish] 82 | steps: 83 | - name: Checkout 84 | uses: actions/checkout@v3 85 | with: 86 | persist-credentials: false 87 | 88 | - name: Setup Node.js 89 | uses: actions/setup-node@v3 90 | with: 91 | node-version: 14.x 92 | 93 | - name: Install dependencies 94 | run: npm ci 95 | 96 | - name: Release 97 | run: npx semantic-release 98 | env: 99 | GITHUB_TOKEN: ${{ secrets.GAEVSKIY_GITHUB_TOKEN }} 100 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 101 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '15 12 * * 2' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | .vscode 4 | node_modules 5 | debug 6 | dist 7 | .nyc_output 8 | coverage 9 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | trailingComma: "es5" 2 | tabWidth: 2 3 | printWidth: 110 4 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "analyzeCommits": ["@semantic-release/commit-analyzer"], 3 | "verifyConditions": ["@semantic-release/changelog"], 4 | "prepare": ["@semantic-release/changelog", "@semantic-release/npm", [ 5 | "@semantic-release/git", { 6 | "message": "chore(release): ${nextRelease.version} ${nextRelease.notes}" 7 | }] 8 | ], 9 | "generateNotes": ["@semantic-release/release-notes-generator"] 10 | } 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [3.3.1](https://github.com/Tinkoff/cachalot/compare/v3.3.0...v3.3.1) (2022-04-04) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * lower log level in case when lock acquiring fails ([a03e77a](https://github.com/Tinkoff/cachalot/commit/a03e77a68f52a2f10325fc6ef96d74b127b0a64b)) 7 | * using correct binding when using 'waitForResult' strategy ([1aef8cc](https://github.com/Tinkoff/cachalot/commit/1aef8ccec89751dae6c6cc4be869d79e5151755a)) 8 | 9 | # [3.3.0](https://github.com/Tinkoff/cachalot/compare/v3.2.0...v3.3.0) (2021-12-29) 10 | 11 | 12 | ### Bug Fixes 13 | 14 | * prettier ([acd94be](https://github.com/Tinkoff/cachalot/commit/acd94bee08fb7795ef01b83bef1c3104f3a5ec52)) 15 | * remove obsolete check for error ([141c242](https://github.com/Tinkoff/cachalot/commit/141c242c8dc1292122b9705ec16c4d8247bb8fcb)) 16 | 17 | 18 | ### Features 19 | 20 | * **deps:** updated dependencies, type lib versions, solved security alerts ([5512749](https://github.com/Tinkoff/cachalot/commit/5512749eb64a43ae9eb81015d5719e0e187bdb8e)) 21 | 22 | # [3.2.0](https://github.com/Tinkoff/cachalot/compare/v3.1.1...v3.2.0) (2021-06-10) 23 | 24 | 25 | ### Bug Fixes 26 | 27 | * undefined into resolved ([e68a642](https://github.com/Tinkoff/cachalot/commit/e68a642af56a757ceaa713ef36e6e584d8028cac)) 28 | 29 | 30 | ### Features 31 | 32 | * update typescript version, added logo, updated core dev dependencies ([81480a5](https://github.com/Tinkoff/cachalot/commit/81480a5b2c6a4c97b3f1ce91500e32e01f2fcdd6)) 33 | 34 | ## [3.1.1](https://github.com/Tinkoff/cachalot/compare/v3.1.0...v3.1.1) (2020-11-17) 35 | 36 | 37 | ### Bug Fixes 38 | 39 | * redis: allow null return from set ([12a93dd](https://github.com/Tinkoff/cachalot/commit/12a93dd)) 40 | 41 | # [3.1.0](https://github.com/Tinkoff/cachalot/compare/v3.0.3...v3.1.0) (2020-08-01) 42 | 43 | 44 | ### Features 45 | 46 | * make cachalot free from dependencies ([fc54e6c](https://github.com/Tinkoff/cachalot/commit/fc54e6c)) 47 | 48 | ## [3.0.3](https://github.com/Tinkoff/cachalot/compare/v3.0.2...v3.0.3) (2020-06-24) 49 | 50 | 51 | ### Bug Fixes 52 | 53 | * less lodash in project ([#31](https://github.com/Tinkoff/cachalot/issues/31)) ([5b4d99d](https://github.com/Tinkoff/cachalot/commit/5b4d99d)) 54 | 55 | ## [3.0.2](https://github.com/Tinkoff/cachalot/compare/v3.0.1...v3.0.2) (2020-06-24) 56 | 57 | 58 | ### Bug Fixes 59 | 60 | * Executor cannot return `undefined`. The only valid result for emptiness is `null`. ([ed8a701](https://github.com/Tinkoff/cachalot/commit/ed8a701)) 61 | 62 | ## [3.0.1](https://github.com/Tinkoff/cachalot/compare/v3.0.0...v3.0.1) (2020-05-08) 63 | 64 | 65 | ### Bug Fixes 66 | 67 | * Do not depend on redis or memcached typings ([95e1f2c](https://github.com/Tinkoff/cachalot/commit/95e1f2c)) 68 | 69 | # [3.0.0](https://github.com/Tinkoff/cachalot/compare/v2.0.0...v3.0.0) (2020-05-08) 70 | 71 | 72 | ### Performance Improvements 73 | 74 | * Base storage only touches and get tags if tag list is not empty ([22b8d3a](https://github.com/Tinkoff/cachalot/commit/22b8d3a)) 75 | 76 | 77 | ### BREAKING CHANGES 78 | 79 | * fixed typings for get/set and managers. Throw errors if executor returns undefined. Executor should always return value or null - for emptiness 80 | 81 | fix: Remove undefined as get return type. 82 | 83 | Also removed `E extends Executor` type parameter. 84 | 85 | `Record.value` is always defined. 86 | 87 | WriteOptions now has type parameter used in getTags signature. 88 | 89 | Throw an error if executor returns undefined. 90 | 91 | # [2.0.0](https://github.com/Tinkoff/cachalot/compare/v1.6.0...v2.0.0) (2020-03-17) 92 | 93 | 94 | ### Features 95 | 96 | * **adapters:** Memcached adapter based on "memcached" module ([9b4aa04](https://github.com/Tinkoff/cachalot/commit/9b4aa04)) 97 | 98 | 99 | ### BREAKING CHANGES 100 | 101 | * **adapters:** * Removed tag reading optimizations. It is not intended to use caches in this way. 102 | * The "del" interface has been changed to be more convenient. 103 | 104 | # [1.6.0](https://github.com/Tinkoff/cachalot/compare/v1.5.1...v1.6.0) (2020-02-14) 105 | 106 | 107 | ### Features 108 | 109 | * Queue "cached" commands if its execution timed out ([b024999](https://github.com/Tinkoff/cachalot/commit/b024999)) 110 | 111 | ## [1.5.1](https://github.com/Tinkoff/cachalot/compare/v1.5.0...v1.5.1) (2020-02-12) 112 | 113 | 114 | ### Bug Fixes 115 | 116 | * Queue delete not touched tags command if adapter is not connected ([ce3c8f5](https://github.com/Tinkoff/cachalot/commit/ce3c8f5)) 117 | 118 | # [1.5.0](https://github.com/Tinkoff/cachalot/compare/v1.4.0...v1.5.0) (2020-02-04) 119 | 120 | 121 | ### Features 122 | 123 | * Not touched tags optimization ([076e895](https://github.com/Tinkoff/cachalot/commit/076e895)) 124 | 125 | # [1.4.0](https://github.com/Tinkoff/cachalot/compare/v1.3.1...v1.4.0) (2020-01-29) 126 | 127 | 128 | ### Features 129 | 130 | * Tags can be stored separately in case the main redis instance uses eviction policy. ([c25ae76](https://github.com/Tinkoff/cachalot/commit/c25ae76)) 131 | 132 | ## [1.3.1](https://github.com/Tinkoff/cachalot/compare/v1.3.0...v1.3.1) (2020-01-28) 133 | 134 | 135 | ### Bug Fixes 136 | 137 | * mset does not add cache key prefix. ([8d58233](https://github.com/Tinkoff/cachalot/commit/8d58233)) 138 | 139 | # [1.3.0](https://github.com/Tinkoff/cachalot/compare/v1.2.1...v1.3.0) (2020-01-27) 140 | 141 | 142 | ### Features 143 | 144 | * Multiple get and set are now using for read/write tags. ([71d60a3](https://github.com/Tinkoff/cachalot/commit/71d60a3)) 145 | 146 | ## [1.2.1](https://github.com/Tinkoff/cachalot/compare/v1.2.0...v1.2.1) (2020-01-23) 147 | 148 | 149 | ### Bug Fixes 150 | 151 | * protect merged static and dynamic tags from being duplicated ([dabd053](https://github.com/Tinkoff/cachalot/commit/dabd053)) 152 | 153 | # [1.2.0](https://github.com/Tinkoff/cachalot/compare/v1.1.2...v1.2.0) (2020-01-23) 154 | 155 | 156 | ### Features 157 | 158 | * Static tags can be lazy calculated ([6965862](https://github.com/Tinkoff/cachalot/commit/6965862)) 159 | 160 | #### [1.1.2] 161 | - Fixed potential vulnerability by updating lodash 162 | 163 | #### [1.1.1] 164 | - run executor after storage throws an error 165 | - change default operation timeout to 150 166 | 167 | #### [1.1.0] 168 | - support for dynamic tags in Manager's default storage via "getTags" option. 169 | - updated jest 170 | - security updates 171 | 172 | #### [1.0.1] 173 | - update package.json information 174 | - version bump 175 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual identity 11 | and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the 27 | overall community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or 32 | advances of any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email 36 | address, without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | [INSERT CONTACT METHOD]. 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series 87 | of actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or 94 | permanent ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within 114 | the community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.0, available at 120 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available 127 | at [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | > Thank you for considering contributing to our project. Your help is very much appreciated! 4 | 5 | When contributing, it's better to first discuss the change you wish to make via issue or discussion, or any other method with the owners of this repository before making a change. 6 | 7 | All members of our community are expected to follow our [Code of Conduct](CODE_OF_CONDUCT.md). 8 | Please make sure you are welcoming and friendly in all of our spaces. 9 | 10 | ## Getting started 11 | 12 | In order to make your contribution please make a fork of the repository. After you've pulled 13 | the code, follow these steps to kick start the development: 14 | 15 | 1. Run `npm ci` to install dependencies 16 | 2. Run `npm watch` to launch ts compiler in watch mode 17 | 3. Please make sure that `npm run test: unit` runs successfully before pushing any code. 18 | 19 | ## Pull Request Process 20 | 21 | 1. We follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0-beta.4/) 22 | in our commit messages, i.e. `feat(core): improve typing` 23 | 2. Make sure you cover all code changes with unit tests 24 | 3. When you are ready, create Pull Request of your fork into original repository 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | Copyright 2019 Tinkoff Bank 180 | 181 | Licensed under the Apache License, Version 2.0 (the "License"); 182 | you may not use this file except in compliance with the License. 183 | You may obtain a copy of the License at 184 | 185 | http://www.apache.org/licenses/LICENSE-2.0 186 | 187 | Unless required by applicable law or agreed to in writing, software 188 | distributed under the License is distributed on an "AS IS" BASIS, 189 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 190 | See the License for the specific language governing permissions and 191 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cachalot logo 2 | 3 | [![Build status](https://img.shields.io/github/workflow/status/Tinkoff/cachalot/CI?style=flat-square)](https://github.com/Tinkoff/cachalot/actions?query=branch%3Amaster+workflow%3ACI) 4 | [![Coveralls github](https://img.shields.io/coveralls/github/Tinkoff/cachalot.svg?style=flat-square)](https://coveralls.io/github/Tinkoff/cachalot) 5 | [![Written in typescript](https://img.shields.io/badge/written_in-typescript-blue.svg?style=flat-square)](https://www.typescriptlang.org/) 6 | [![npm](https://img.shields.io/npm/v/cachalot.svg?style=flat-square)](https://www.npmjs.com/package/cachalot) 7 | 8 | Zero-dependency library designed to cache query results. Features: 9 | * Implements popular caching strategies (Read-Through, Write-Through, Refresh-Ahead), and also allows them to be combined 10 | * Defines an adapter interface that allows you to use it with any key-value storage for which the corresponding adapter is written 11 | * Comes with adapter for redis, memcached 12 | * Allows to use prefixes for keys, automatic hashing 13 | * Allows to pass in a logger that will be used to display informational messages and errors 14 | * Supports various behaviors of cache write waiting (heavy queries), more details below. 15 | 16 | ### Getting started 17 | 18 | To initialize Cache instance, you need: 19 | * StorageAdapter (in the example below, an adapter for connecting to redis). RedisStorageAdapter takes as an argument the instance of ioredis client. 20 | * Settings object. The options are the following options: 21 |    22 | - prefix - prefix used by CacheManager for storing keys. In essence, this is the namespace for a specific CacheManager. 23 |    24 | - logger - instance of logger. Must implement following interface: 25 | 26 | ```typescript 27 | interface Logger { 28 | info(...args: any[]): void; 29 | trace(...args: any[]): void; 30 | warn(...args: any[]): void; 31 | error(...args: any[]): void; 32 | } 33 | ``` 34 | - expiresIn - the time after which the keys lose relevance (ms) 35 | 36 | ##### Example of use: 37 | 38 | Initialization: 39 | 40 | ```typescript 41 | // cache.ts 42 | 43 | import Redis from 'ioredis'; 44 | import Cache, { RedisStorageAdapter } from 'cachalot'; 45 | import logger from './logger'; 46 | 47 | const redis = new Redis(); 48 | 49 | export const cache = new Cache({ 50 | adapter: new RedisStorageAdapter(redis), 51 | logger, 52 | }); 53 | ``` 54 | If your Redis instance uses eviction policy you need to use separate Redis instance for tags. **Tags should not be evicted!** 55 | 56 | ```typescript 57 | // cache-with-tags.ts 58 | 59 | import Redis from 'ioredis'; 60 | import Cache, { RedisStorageAdapter } from 'cachalot'; 61 | import logger from './logger'; 62 | 63 | const redis = new Redis(); // with eviction policy enabled 64 | const redisForTags = new Redis(6380); 65 | 66 | export const cache = new Cache({ 67 | adapter: new RedisStorageAdapter(redis), 68 | tagsAdapter: new RedisStorageAdapter(redisForTags), 69 | logger, 70 | }); 71 | ``` 72 | 73 | There are three main methods of working with Cache; their behavior depends on the chosen caching strategy: 74 | 75 | `get` gets cache data 76 | 77 | ```typescript 78 | // get-something.ts 79 | import { cache } from './cache' 80 | 81 | const cacheKey = 'something:id100'; // key that records and accesses the value 82 | 83 | function getSomething() { 84 | return cache.get( 85 | cacheKey, 86 | () => executor('hello', 'world'), // executor is a function that returns promise. Run if failed to get valid cache entry 87 | { tags: [cacheKey, 'something'] }, // you can associate tags with any cache record. You can later invalidate record with any of them. 88 | ); 89 | } 90 | ``` 91 | 92 | `get` will check the tags and compare their versions with the current date, runs an executor if necessary and returns result. 93 | Options for `get`: 94 | - expiresIn?: number; - The number of milliseconds after which key values are considered expired 95 | - tags?: string[] | (() => string[]) - Tags - keys for which checks the validity of a particular record. If the tag value in the cache + invalidation time is string[] function which extracts tags from executor result. These tags will be merged with tags given in option below. 97 | 98 | The next method, "touch", serves to invalidate tags. Calling this method with one of the tags will make all records in the cache with this tag invalid. 99 | It can be used both to invalidate a single record (for example, by creating a unique id) or a group of records. 100 | 101 | Example: 102 | ```typescript 103 | import { cache } from './cache' 104 | 105 | async function createSomething() { 106 | await cache.touch(['something']) // invalidates all entries with the tag "something" 107 | } 108 | ``` 109 | The latter method is `set`, used in write-through strategies to update entries. 110 | 111 | Note that `touch` does not make sense when using Write-Through in its pure form, just as there is no point in using set in the Refresh-Ahead and Read-Through strategies 112 | 113 | ## Locked key retrieve strategies 114 | 115 | Cachalot allows you to set a strategy for `get` behavior if the cache entry is locked (for updating). Available strategies: 116 | 117 | `waitForResult` -` get` will wait for the result to appear in the cache and the lock will be removed. Good to use with heavy demands and average load 118 | . Under high loads, spikes can occur due to queuing requests. 119 | 120 | `runExecutor` -` get` will immediately call the executor and return its result. Good in cases where requests are light. The disadvantage of 121 | this strategy is a temporary increase in the load on the database at the time of updating the record. This strategy is used by default. 122 | 123 | For each entry, the strategy can be set individually. To do this, its name must be passed in the readThrough options. 124 | ```typescript 125 | cache.get('something:id100', () => executor('hello', 'world'), { 126 | tags: [cacheKey, 'something'], 127 | lockedKeyRetrieveStrategy: 'runExecutor' 128 | }, 129 | ); 130 | ``` 131 | ### Cache Managers 132 | 133 | For all the examples above, the Refresh-Ahead strategy is used. This strategy is used by default, but it is possible to connect other strategies from cachalot. 134 | Different caching strategies implement different classes of "managers". Each manager has a string identifier. 135 | When registering a strategy, it is obtained by calling the getName static method of the manager class. Further, the same identifier can be used 136 | in get and set calls to tell the Cache instance to which manager to delegate the call. 137 | 138 | #### Refresh-Ahead 139 | 140 | The Refresh-Ahead Cache strategy allows the developer to configure the cache to automatically and asynchronously reload (refresh) any recently available cache entry from the cache loader before it expires. As a result, after a frequently used entry entered the cache, the application will not sense the effect of reading on the potentially slow cache storage when the entry is reloaded due to expiration. An asynchronous update is launched only when accessing an object close enough to its expiration time — if the object is accessed after it has expired, Cache will perform a synchronous read from the cache storage to update its value. 141 | 142 | The refresh ahead factor is expressed as a percentage of the record expiration time. For example, suppose that the expiration time for entries in the cache is set to 60 seconds, and refresh ahead factor is set to 0.5. If the cached object is accessed after 60 seconds, Cache will perform a synchronous read from the cache storage to update its value. However, if the request is made for a record that is older than 30, but less than 60 seconds, the current value in the cache is returned, and Cache plans an asynchronous reboot from the cache storage. 143 | 144 | An advanced update is especially useful if objects are accessed by a large number of users. The values ​​in the cache remain fresh, and the delay that may result from an excessive reload from the cache storage is eliminated. 145 | 146 | By default, RefreshAhead is already defined in Cache with default settings. However, you can override it. To do this, simply register `RefreshAheadManager` again 147 | 148 | ```typescript 149 | cache.registerManager(RefreshAheadManager, null, { 150 | refreshAheadFactor: 0.5, 151 | }); 152 | ``` 153 | #### Read-Through 154 | 155 | When an application requests an entry in the cache, for example, the X key, and X is not yet in the cache, Cache will automatically call executor, which loads X from the underlying data source. If X exists in the data source, executor loads it, returns it to Cache, then Cache puts it in the cache for future use and finally returns X to the application code that requested it. This is called read-through caching. Advanced caching functionality (Refresh-Ahead) can further improve read performance (by reducing the estimated latency). 156 | 157 | ```typescript 158 | import Redis from 'ioredis'; 159 | import logger from './logger'; 160 | import Cache, { RedisStorageAdapter, ReadThroughManager } from 'cachalot'; // constructor adapter for redis 161 | 162 | const redis = new Redis(); 163 | 164 | export const cache = new Cache({ 165 | adapter: new RedisStorageAdapter(redis), 166 | logger, 167 | }); 168 | 169 | cache.registerManager(ReadThroughManager); 170 | 171 | // ... 172 | const x = await cache.get('something:id100', () => executor('hello', 'world'), { 173 | tags: [cacheKey, 'something'], 174 | manager: 'read-through', 175 | }, 176 | ); 177 | ``` 178 | #### Write-Through 179 | 180 | With Write-Through, get causes no validation logic for the cache, tags, and so on. A record is considered invalid only if it is not in the cache as such. In this strategy, when an application updates a portion of the data in the cache (that is, calls set (...) to change the cache entry), the operation will not complete (that is, set will not return) until the Cache has passed through the underlying database and successfully saved data to the underlying data source. This does not improve write performance at all, since you are still dealing with a delay in writing to the data source. 181 | 182 | #### Read-Through + Write-Through 183 | 184 | It is also possible to combine different strategies, the most common option is Read-Through + Write-Through. 185 | 186 | ```typescript 187 | // ... 188 | export const cache = new Cache({ 189 | adapter: new RedisStorageAdapter(redisClient), 190 | logger, 191 | }); 192 | 193 | cache.registerManager(ReadThroughManager); 194 | cache.registerManager(WriteThroughManager); 195 | 196 | // creates permanent record 197 | cache.set('something:id100', 'hello', { 198 | tags: ['something:id100', 'something'], 199 | manager: WriteThroughManager.getName() 200 | }); 201 | 202 | // gets the record 203 | const x = await cache.get('something:id100', () => executor('hello', 'world'), { 204 | tags: ['something:id100', 'something'], 205 | manager: ReadThroughManager.getName(), 206 | }, 207 | ); 208 | ``` 209 | ## License 210 | 211 | ``` 212 | Copyright 2019 Tinkoff Bank 213 | 214 | Licensed under the Apache License, Version 2.0 (the "License"); 215 | you may not use this file except in compliance with the License. 216 | You may obtain a copy of the License at 217 | 218 | http://www.apache.org/licenses/LICENSE-2.0 219 | 220 | Unless required by applicable law or agreed to in writing, software 221 | distributed under the License is distributed on an "AS IS" BASIS, 222 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 223 | See the License for the specific language governing permissions and 224 | limitations under the License. 225 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 94 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: ["ts", "js"], 3 | testMatch: [ 4 | "/src/**/__tests__/**/*.ts", 5 | "/src/*.spec.ts", 6 | "/src/**/*.spec.ts" 7 | ], 8 | testEnvironment: "node", 9 | collectCoverageFrom: [ 10 | "/src/*.ts", 11 | "/src/**/*.ts", 12 | "!/src/RedisStorageAdapter.ts", 13 | "!/src/constants.ts", 14 | "!/src/errors.ts", 15 | "!/src/index.ts", 16 | "!/src/ConnectionStatus.ts", 17 | "!/src/LockedKeyRetrieveStrategy.ts", 18 | "!/src/adapters/TestStorageAdapter.ts", 19 | ], 20 | transform: { 21 | "^.+\\.ts$": "ts-jest", 22 | }, 23 | coverageReporters: ["text", "text-summary"], 24 | coverageThreshold: { 25 | global: { 26 | branches: 90, 27 | functions: 90, 28 | lines: 90, 29 | statements: 90 30 | } 31 | }, 32 | }; 33 | 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cachalot", 3 | "version": "3.3.1", 4 | "description": "Cache manager for nodejs with support different cache strategies", 5 | "keywords": [ 6 | "cache", 7 | "redis", 8 | "read-through", 9 | "refresh-ahead" 10 | ], 11 | "license": "Apache-2.0", 12 | "author": "Gajewski Dmitriy Yurevish ", 13 | "files": [ 14 | "dist" 15 | ], 16 | "main": "./dist/index.js", 17 | "types": "./dist/index.d.ts", 18 | "repository": { 19 | "type": "git", 20 | "repository": "https://github.com/Tinkoff/cachalot.git" 21 | }, 22 | "engines": { 23 | "node": ">=10.0.0" 24 | }, 25 | "scripts": { 26 | "cz": "git-cz", 27 | "clean": "rm -rf dist", 28 | "build": "tsc", 29 | "watch": "tsc -w", 30 | "format": "prettier --write tests/**/*.ts src/*.ts src/**/*.ts", 31 | "lint": "prettier -c tests/**/*.ts src/*.ts src/**/*.ts && eslint src tests --ext .ts --max-warnings 0", 32 | "test": "npm run test:unit", 33 | "test:unit": "jest --coverage --verbose --passWithNoTests", 34 | "test:ci": "jest --coverage --verbose --passWithNoTests --coverageReporters=lcovonly", 35 | "test:integration": "jest --config tests/jest.config.js --forceExit --detectOpenHandles --verbose", 36 | "test:unit:watch": "jest --watch", 37 | "prepublishOnly": "npm run build", 38 | "semantic-release": "semantic-release" 39 | }, 40 | "devDependencies": { 41 | "@semantic-release/changelog": "^6.0.1", 42 | "@semantic-release/git": "^10.0.1", 43 | "@types/ioredis": "^4.27.5", 44 | "@types/jest": "^27.0.2", 45 | "@types/memcached": "^2.2.7", 46 | "@types/node": "^10.17.60", 47 | "@types/uuid": "^8.3.1", 48 | "@typescript-eslint/eslint-plugin": "^4.32.0", 49 | "@typescript-eslint/parser": "^4.32.0", 50 | "eslint": "^7.32.0", 51 | "eslint-config-prettier": "^8.3.0", 52 | "ioredis": "^4.27.9", 53 | "jest": "^27.2.4", 54 | "memcached": "^2.2.2", 55 | "prettier": "^2.4.1", 56 | "semantic-release": "^19.0.2", 57 | "ts-jest": "^27.0.5", 58 | "typescript": "^4.4.3", 59 | "uuid": "^8.3.2" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/Cache.spec.ts: -------------------------------------------------------------------------------- 1 | import Cache, { EXPIRES_IN } from "./Cache"; 2 | import TestStorage from "./storage/__mocks__/TestStorage"; 3 | import TestStorageAdapter from "./adapters/TestStorageAdapter"; 4 | import { BaseStorage } from "./storage/BaseStorage"; 5 | import RefreshAheadManager from "./managers/RefreshAheadManager"; 6 | import { ConnectionStatus } from "./ConnectionStatus"; 7 | 8 | const logger = { 9 | info: jest.fn(), 10 | trace: jest.fn(), 11 | warn: jest.fn(), 12 | error: jest.fn(), 13 | }; 14 | 15 | const MockManager = class { 16 | static getName: any = () => "mock"; 17 | get: any = jest.fn().mockResolvedValue("mockResult"); 18 | set: any = jest.fn(); 19 | }; 20 | 21 | let instance: any; 22 | 23 | jest.mock("./storage/BaseStorage"); 24 | jest.mock("./managers/RefreshAheadManager"); 25 | 26 | describe("Cache", () => { 27 | beforeEach(() => { 28 | instance = new Cache({ 29 | adapter: new TestStorageAdapter({}, true), 30 | logger, 31 | }); 32 | }); 33 | 34 | it("uses provided storage instead of BaseStorage", () => { 35 | const customStorage = new TestStorage({ 36 | adapter: new TestStorageAdapter({}, true), 37 | }); 38 | instance = new Cache({ 39 | storage: customStorage, 40 | logger, 41 | }); 42 | 43 | expect(instance.storage).toEqual(customStorage); 44 | }); 45 | 46 | it("creates BaseStorage if custom storage not provided", () => { 47 | expect(instance.storage).toBeInstanceOf(BaseStorage); 48 | }); 49 | 50 | it("throws if nor BaseStorage nor custom storage was created", () => { 51 | expect(() => new Cache({ logger } as any)).toThrow(); 52 | }); 53 | 54 | it("throws if Logger was not passed in as dependency", () => { 55 | expect( 56 | () => 57 | new Cache({ 58 | adapter: new TestStorageAdapter({}, true), 59 | } as any) 60 | ).toThrow(); 61 | }); 62 | 63 | it("default key expiration is one day", () => { 64 | expect(instance.expiresIn).toEqual(EXPIRES_IN.day); 65 | }); 66 | 67 | it("gets key expiration from options", () => { 68 | instance = new Cache({ 69 | adapter: new TestStorageAdapter({}, true), 70 | logger, 71 | expiresIn: EXPIRES_IN.hour, 72 | }); 73 | expect(instance.expiresIn).toEqual(EXPIRES_IN.hour); 74 | }); 75 | 76 | it("registers RefreshAheadManager", () => { 77 | expect(instance.managers.get("refresh-ahead")).toBeInstanceOf(RefreshAheadManager); 78 | }); 79 | 80 | it("registerManager registers new manager", () => { 81 | instance.registerManager(MockManager); 82 | expect(instance.managers.get("mock")).toBeInstanceOf(MockManager); 83 | }); 84 | 85 | it("getManager throws if cannot get manager", () => { 86 | expect(() => instance.getManager("unknown")).toThrow(); 87 | }); 88 | 89 | it("getManager returns registered manager by its name", () => { 90 | instance.registerManager(MockManager, "custom-name"); 91 | expect(instance.getManager("custom-name")).toBeInstanceOf(MockManager); 92 | }); 93 | 94 | it('get calls executor directly if storage connection status is not "connected"', async () => { 95 | const customStorage = new TestStorage({ 96 | adapter: new TestStorageAdapter({}, false), 97 | }); 98 | 99 | customStorage.getConnectionStatus.mockReturnValue(ConnectionStatus.DISCONNECTED); 100 | 101 | const executor = jest.fn().mockResolvedValue(1); 102 | 103 | instance = new Cache({ 104 | storage: customStorage, 105 | logger, 106 | }); 107 | 108 | await expect(instance.get("test", executor)).resolves.toEqual(1); 109 | }); 110 | 111 | it("get gets manager from options", async () => { 112 | const executor = jest.fn().mockResolvedValue(1); 113 | 114 | instance.registerManager(MockManager); 115 | 116 | await expect(instance.get("test", executor, { manager: "mock" })).resolves.toEqual("mockResult"); 117 | }); 118 | 119 | it("get defaults manager to RefreshAhead", async () => { 120 | const executor = jest.fn(); 121 | const manager = instance.getManager("refresh-ahead"); 122 | 123 | manager.get.mockResolvedValueOnce(1); 124 | 125 | await expect(instance.get("test", executor)).resolves.toEqual(1); 126 | }); 127 | 128 | it("get delegates get to manager's get", async () => { 129 | instance.registerManager(MockManager); 130 | 131 | const executor = jest.fn().mockResolvedValue(1); 132 | const manager = instance.getManager("mock"); 133 | 134 | manager.get.mockResolvedValueOnce("delegated call"); 135 | 136 | await expect(instance.get("test", executor, { manager: "mock" })).resolves.toEqual("delegated call"); 137 | }); 138 | 139 | it("set gets manager from options", async () => { 140 | instance.registerManager(MockManager); 141 | 142 | const manager = instance.getManager("mock"); 143 | 144 | await instance.set("test", "value", { manager: "mock" }); 145 | await expect(manager.set).toBeCalled(); 146 | }); 147 | 148 | it("set defaults manager to RefreshAhead", async () => { 149 | const manager = instance.getManager("refresh-ahead"); 150 | 151 | await instance.set("test", "value"); 152 | await expect(manager.set).toBeCalled(); 153 | }); 154 | 155 | it("touch delegates touch of tags to storage directly", async () => { 156 | await instance.touch(["test", "value"]); 157 | await expect(instance.storage.touch).toBeCalled(); 158 | }); 159 | }); 160 | -------------------------------------------------------------------------------- /src/Cache.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionStatus } from "./ConnectionStatus"; 2 | import { Executor, runExecutor } from "./Executor"; 3 | import { Logger } from "./Logger"; 4 | import { Manager } from "./Manager"; 5 | import { ManagerOptions } from "./managers/BaseManager"; 6 | import RefreshAheadManager from "./managers/RefreshAheadManager"; 7 | import { BaseStorage } from "./storage/BaseStorage"; 8 | import { Record } from "./storage/Record"; 9 | import { ReadWriteOptions, Storage, WriteOptions } from "./storage/Storage"; 10 | import { StorageAdapter } from "./StorageAdapter"; 11 | 12 | export interface CacheWithCustomStorageOptions { 13 | storage: Storage; 14 | } 15 | export interface CacheWithBaseStorageOptions { 16 | adapter: StorageAdapter; 17 | tagsAdapter?: StorageAdapter; 18 | } 19 | 20 | export interface ManagerConstructor { 21 | new (options: ManagerOptions): T; 22 | getName(): string; 23 | } 24 | 25 | export type CacheOptions = (CacheWithCustomStorageOptions | CacheWithBaseStorageOptions) & { 26 | logger: Logger; 27 | expiresIn?: number; 28 | prefix?: string; 29 | hashKeys?: boolean; 30 | }; 31 | 32 | export const isCustomStorageOptions = (options: unknown): options is CacheWithCustomStorageOptions => 33 | Object.prototype.hasOwnProperty.call(options, "storage"); 34 | 35 | export const isBaseStorageOptions = (options: unknown): options is CacheWithBaseStorageOptions => 36 | Object.prototype.hasOwnProperty.call(options, "adapter"); 37 | 38 | export interface ManagerSelectorOptions { 39 | manager?: string; 40 | } 41 | 42 | export const EXPIRES_IN = { 43 | minute: 60000, 44 | hour: 3600000, 45 | day: 86400000, 46 | }; 47 | 48 | /** 49 | * Cache is the basic class of CacheManager. 50 | * @example 51 | * 52 | * ```typescript 53 | * // Cache.ts 54 | * import logger from './logger'; 55 | * import Cache, { RedisStorageAdapter } from 'cachalot'; 56 | * 57 | * const redis = new Redis(); 58 | * 59 | * export const cache = new Cache({ 60 | * adapter: new RedisStorageAdapter(redisClient), 61 | * logger, 62 | * }); 63 | * ``` 64 | */ 65 | class Cache { 66 | constructor(options: CacheOptions) { 67 | if (isCustomStorageOptions(options)) { 68 | this.storage = options.storage; 69 | } else if (isBaseStorageOptions(options)) { 70 | this.storage = new BaseStorage({ 71 | adapter: options.adapter, 72 | tagsAdapter: options.tagsAdapter, 73 | prefix: options.prefix, 74 | hashKeys: options.hashKeys, 75 | }); 76 | } else { 77 | throw new Error("Either custom storage or storage adapter must be passed in options."); 78 | } 79 | 80 | if (!options.logger) { 81 | throw new Error("Logger is required."); 82 | } 83 | 84 | this.logger = options.logger; 85 | this.expiresIn = options.expiresIn || EXPIRES_IN.day; 86 | 87 | this.managers = new Map(); 88 | this.registerManager(RefreshAheadManager); 89 | } 90 | 91 | private readonly storage: Storage; 92 | private readonly logger: Logger; 93 | private readonly expiresIn: number; 94 | private managers: Map; 95 | 96 | /** 97 | * Get delegates call to default or provided manager. The only thing it does by itself is checking 98 | * the connection status of storage. If storage is disconnected calls executor directly and returns result. 99 | */ 100 | public async get( 101 | key: string, 102 | executor: Executor, 103 | options: ReadWriteOptions & ManagerSelectorOptions = {} 104 | ): Promise { 105 | const connectionStatus = this.storage.getConnectionStatus(); 106 | 107 | if (connectionStatus !== ConnectionStatus.CONNECTED) { 108 | this.logger.error( 109 | `Storage connection status is "${connectionStatus}", cache is unavailable!. Running executor.` 110 | ); 111 | 112 | return runExecutor(executor); 113 | } 114 | 115 | const { manager: managerName = RefreshAheadManager.getName(), expiresIn = this.expiresIn } = options; 116 | const computedOptions = { ...options, expiresIn }; 117 | const manager = this.getManager(managerName); 118 | 119 | return manager.get(key, executor, computedOptions); 120 | } 121 | 122 | /** 123 | * Just like "get" this method delegates call to default or provided manager 124 | */ 125 | public async set( 126 | key: string, 127 | value: R, 128 | options: WriteOptions & ManagerSelectorOptions = {} 129 | ): Promise> { 130 | const { manager: managerName = RefreshAheadManager.getName(), expiresIn = this.expiresIn } = options; 131 | const computedOptions = { ...options, expiresIn }; 132 | const manager = this.getManager(managerName); 133 | 134 | return manager.set(key, value, computedOptions); 135 | } 136 | 137 | /** 138 | * The touch method is intended for all cases when you need to inform the cache manager that the data for 139 | * any tags are updated without making a cache entry; 140 | * 141 | * @example 142 | * 143 | * ```typescript 144 | * await saveNews(news); 145 | * cache.touch(['news']); 146 | * ``` 147 | */ 148 | public async touch(tags: string[]): Promise { 149 | return this.storage.touch(tags); 150 | } 151 | 152 | /** 153 | * Register a new cache manager 154 | */ 155 | public registerManager( 156 | managerClass: ManagerConstructor, 157 | name?: string | null, 158 | options: Partial = {} 159 | ): void { 160 | this.managers.set( 161 | name || managerClass.getName(), 162 | new managerClass({ 163 | logger: this.logger, 164 | storage: this.storage, 165 | ...options, 166 | }) 167 | ); 168 | } 169 | 170 | /** 171 | * Returns cache manager by its name 172 | */ 173 | private getManager(name: string): Manager { 174 | const manager = this.managers.get(name); 175 | 176 | if (!manager) { 177 | throw new Error(`Unknown manager "${name}"`); 178 | } 179 | 180 | return manager; 181 | } 182 | } 183 | 184 | export default Cache; 185 | -------------------------------------------------------------------------------- /src/ConnectionStatus.ts: -------------------------------------------------------------------------------- 1 | export enum ConnectionStatus { 2 | CONNECTING = "connecting", 3 | CONNECTED = "connected", 4 | DISCONNECTED = "disconnected", 5 | } 6 | -------------------------------------------------------------------------------- /src/Executor.ts: -------------------------------------------------------------------------------- 1 | import { executorReturnsUndefinedError } from "./errors/errors"; 2 | import { ReadWriteOptions } from "./storage/Storage"; 3 | import { Record } from "./storage/Record"; 4 | 5 | export interface ExecutorContext { 6 | key: string; 7 | executor: Executor; 8 | options: ReadWriteOptions; 9 | record?: Record; 10 | } 11 | 12 | type NonUndefined = T extends undefined ? never : T; 13 | 14 | export async function runExecutor(executor: Executor): Promise { 15 | const result = await executor(); 16 | 17 | if (result === undefined) { 18 | throw executorReturnsUndefinedError(); 19 | } 20 | 21 | return result; 22 | } 23 | 24 | export type Executor = (...args: unknown[]) => Promise> | NonUndefined; 25 | -------------------------------------------------------------------------------- /src/LockedKeyRetrieveStrategy.ts: -------------------------------------------------------------------------------- 1 | import { ExecutorContext } from "./Executor"; 2 | 3 | /** 4 | * It is possible (for descendants of BaseManager) to change the behavior of getting 5 | * updated results. For example if you deal with heavy queries to DB you probably want 6 | * to run one query at once forcing other consumers to wait for results in cache. 7 | */ 8 | export interface LockedKeyRetrieveStrategy { 9 | getName(): string; 10 | get(context: ExecutorContext): Promise; 11 | } 12 | 13 | export enum LockedKeyRetrieveStrategyTypes { 14 | waitForResult = "waitForResult", 15 | runExecutor = "runExecutor", 16 | } 17 | 18 | export type LockedKeyRetrieveStrategyType = keyof typeof LockedKeyRetrieveStrategyTypes; 19 | -------------------------------------------------------------------------------- /src/Logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Logger is the simple interface which used by Manager and Cache to log 3 | * errors and trace/debug information 4 | */ 5 | export interface Logger { 6 | info(...args: unknown[]): void; 7 | trace(...args: unknown[]): void; 8 | warn(...args: unknown[]): void; 9 | error(...args: unknown[]): void; 10 | } 11 | -------------------------------------------------------------------------------- /src/Manager.ts: -------------------------------------------------------------------------------- 1 | import { Executor } from "./Executor"; 2 | import { WriteOptions, ReadWriteOptions } from "./storage/Storage"; 3 | import { Record } from "./storage/Record"; 4 | 5 | /** 6 | * Manager is the basic interface for all caching classes. Manager must implement 7 | * two simple methods - get, and set. Cache class will delegate it's get and set calls to manager 8 | * which must decide what record should be threaten as invalid, when and how to update record 9 | */ 10 | export interface Manager { 11 | get(key: string, executor: Executor, options: ReadWriteOptions): Promise; 12 | set(key: string, value: R, options?: WriteOptions): Promise>; 13 | } 14 | -------------------------------------------------------------------------------- /src/StorageAdapter.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionStatus } from "./ConnectionStatus"; 2 | 3 | /** 4 | * The interface of the settings object required to transfer parameters from the Manager to the StorageAdapter 5 | * for later use in its methods set, get, del, etc. 6 | */ 7 | export interface StorageAdapterOptions { 8 | expiresIn?: number; 9 | } 10 | 11 | /** 12 | * Interface adapter for class Manager. Adapter is "interlayer" 13 | * between storage methods and the manager itself. Any adapter 14 | * must implement all methods and properties of this interface 15 | */ 16 | export interface StorageAdapter { 17 | /** 18 | * The method sends the settings from above (from BaseManager) to the adapter. Called in the BaseManager constructor if set 19 | */ 20 | setOptions?(options: StorageAdapterOptions): void; 21 | 22 | /** 23 | * The method should call the callback passed to it as soon as the storage is ready to execute commands. 24 | */ 25 | onConnect(callback: (...args: unknown[]) => void): void; 26 | 27 | /** 28 | * Returns the current status of the storage connection. 29 | */ 30 | getConnectionStatus(): ConnectionStatus; 31 | 32 | /** 33 | * set - sets the value for the key key in storage. 34 | * Returns true on success; false otherwise. 35 | */ 36 | set(key: string, value: string, expiresIn?: number): Promise; 37 | 38 | /** 39 | * mset - stores values to the storage 40 | */ 41 | mset(values: Map): Promise; 42 | 43 | /** 44 | * get - returns value by key 45 | * Returns null if record does not exist 46 | */ 47 | get(key: string): Promise; 48 | 49 | /** 50 | * mget - returns values by keys 51 | * Returns null for records that do not exist 52 | */ 53 | mget(keys: string[]): Promise<(string | null)[]>; 54 | 55 | /** 56 | * Removes the entry with the key key from storage 57 | */ 58 | del(key: string): Promise; 59 | 60 | /** 61 | * Locks the entry with the key key to be changed, returns true if the operation is successful, otherwise false 62 | */ 63 | acquireLock(key: string, lockExpireTimeout?: number): Promise; 64 | 65 | /** 66 | * Unlocks the record with the key key, returns true if the operation is successful, otherwise false 67 | */ 68 | releaseLock(key: string): Promise; 69 | 70 | /** 71 | * Checks if the entry with the key key is locked for changes 72 | */ 73 | isLockExists(key: string): Promise; 74 | } 75 | -------------------------------------------------------------------------------- /src/__snapshots__/deserialize.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`deserizalize throws parse error on invalid json string 1`] = `"Unexpected token e in JSON at position 1"`; 4 | 5 | exports[`deserizalize throws parse error on invalid json string 2`] = `"Unexpected end of JSON input"`; 6 | -------------------------------------------------------------------------------- /src/adapters/MemcachedStorageAdapter.spec.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import { MemcachedStorageAdapter } from "./MemcachedStorageAdapter"; 3 | import { ConnectionStatus } from "../ConnectionStatus"; 4 | 5 | class MemcachedMock extends EventEmitter {} 6 | 7 | let mock: any = new MemcachedMock(); 8 | let adapter: MemcachedStorageAdapter = new MemcachedStorageAdapter(mock); 9 | 10 | describe("Memcached adapter", () => { 11 | beforeEach(() => { 12 | mock = new MemcachedMock(); 13 | adapter = new MemcachedStorageAdapter(mock); 14 | }); 15 | 16 | it('Sets connection status to "connected" if Memcached emits reconnect', () => { 17 | mock.emit("reconnect"); 18 | expect((adapter as any).connectionStatus).toEqual(ConnectionStatus.CONNECTED); 19 | }); 20 | 21 | it('Sets connection status to "connecting" if Memcached emits reconnecting', () => { 22 | mock.emit("reconnecting"); 23 | expect((adapter as any).connectionStatus).toEqual(ConnectionStatus.CONNECTING); 24 | }); 25 | 26 | it('Sets connection status to "diconnected" if Memcached emits failure', () => { 27 | mock.emit("failure"); 28 | expect((adapter as any).connectionStatus).toEqual(ConnectionStatus.DISCONNECTED); 29 | }); 30 | 31 | it("connection status is connected by default", () => { 32 | expect((adapter as any).getConnectionStatus()).toEqual(ConnectionStatus.CONNECTED); 33 | }); 34 | 35 | it("getConnectionStatus returns current connection status", () => { 36 | (adapter as any).connectionStatus = ConnectionStatus.CONNECTING; 37 | 38 | expect(adapter.getConnectionStatus()).toEqual(ConnectionStatus.CONNECTING); 39 | }); 40 | 41 | it("onConnect calls callback function if Memcached instance emits reconnect", () => { 42 | const cb = jest.fn(); 43 | 44 | adapter.onConnect(cb); 45 | mock.emit("reconnect"); 46 | 47 | expect(cb).toHaveBeenCalledTimes(1); 48 | }); 49 | 50 | it("set returns true if operation is successful", async () => { 51 | mock.set = jest.fn().mockImplementation((key, value, lifetime, cb) => cb(null, true)); 52 | 53 | expect(await adapter.set("some", "value")).toEqual(true); 54 | }); 55 | 56 | it("set returns false if operation is not successful", async () => { 57 | mock.set = jest.fn().mockImplementation((key, value, lifetime, cb) => cb(null, false)); 58 | 59 | expect(await adapter.set("some", "value")).toEqual(false); 60 | }); 61 | 62 | it("set throws if operation throws", async () => { 63 | mock.set = jest.fn().mockImplementation((key, value, lifetime, cb) => cb(new Error("err"), false)); 64 | 65 | await expect(adapter.set("some", "value")).rejects.toThrowErrorMatchingInlineSnapshot( 66 | `"Operation \\"set\\" error. err"` 67 | ); 68 | }); 69 | 70 | it("set calls set with cache prefix", async () => { 71 | mock.set = jest.fn().mockImplementation((key, value, lifetime, cb) => cb(null, true)); 72 | 73 | expect(await adapter.set("some", "value")).toEqual(true); 74 | expect(mock.set).toHaveBeenCalledTimes(1); 75 | expect(mock.set).toHaveBeenCalledWith("some", "value", 0, expect.any(Function)); 76 | }); 77 | 78 | it("set converts milliseconds to seconds", async () => { 79 | mock.set = jest.fn().mockImplementation((key, value, lifetime, cb) => cb(null, true)); 80 | 81 | expect(await adapter.set("some", "value", 25000)).toEqual(true); 82 | expect(mock.set).toHaveBeenCalledTimes(1); 83 | expect(mock.set).toHaveBeenCalledWith("some", "value", 25, expect.any(Function)); 84 | }); 85 | 86 | it("get calls returns value", async () => { 87 | mock.get = jest.fn().mockImplementation((key, cb) => cb(null, "some")); 88 | 89 | const value = await adapter.get("some"); 90 | 91 | expect(value).toEqual("some"); 92 | }); 93 | 94 | it("get returns null if no value in storage", async () => { 95 | mock.get = jest.fn().mockImplementation((key, cb) => cb(null, undefined)); 96 | 97 | const value = await adapter.get("some"); 98 | 99 | expect(value).toEqual(null); 100 | }); 101 | 102 | it("get throws on operation error", async () => { 103 | mock.get = jest.fn().mockImplementation((key, cb) => cb(new Error("some"), null)); 104 | 105 | await expect(adapter.get("some")).rejects.toThrowErrorMatchingInlineSnapshot( 106 | `"Operation \\"get\\" error. some"` 107 | ); 108 | }); 109 | 110 | it("get throws on empty key", async () => { 111 | await expect(adapter.get("")).rejects.toThrowErrorMatchingInlineSnapshot( 112 | `"ERR wrong arguments for 'get' command"` 113 | ); 114 | }); 115 | 116 | it("del calls del with cache prefix", async () => { 117 | mock.del = jest.fn().mockImplementation((key, cb) => cb(null, true)); 118 | 119 | await adapter.del("some"); 120 | 121 | expect(mock.del).toHaveBeenCalledTimes(1); 122 | expect(mock.del).toHaveBeenCalledWith("some", expect.any(Function)); 123 | }); 124 | 125 | it("del throws if operation throws", async () => { 126 | mock.del = jest.fn().mockImplementation((key, cb) => cb(new Error("some"), true)); 127 | 128 | await expect(adapter.del("some")).rejects.toThrowErrorMatchingInlineSnapshot( 129 | `"Operation \\"del\\" error. some"` 130 | ); 131 | }); 132 | 133 | it("acquireLock returns true if lock is successful", async () => { 134 | mock.add = jest.fn().mockImplementation((key, value, expire, cb) => cb(null, true)); 135 | 136 | const lockResult = await adapter.acquireLock("some"); 137 | 138 | expect(lockResult).toEqual(true); 139 | }); 140 | 141 | it("acquireLock returns false if lock is unsuccessful", async () => { 142 | mock.add = jest.fn().mockImplementation((key, value, expire, cb) => cb(null, false)); 143 | 144 | const lockResult = await adapter.acquireLock("some"); 145 | 146 | expect(lockResult).toEqual(false); 147 | }); 148 | 149 | it("acquireLock throws if lock operation throws", async () => { 150 | mock.add = jest.fn().mockImplementation((key, value, expire, cb) => cb(new Error("some"), false)); 151 | 152 | await expect(adapter.acquireLock("some")).rejects.toThrowErrorMatchingInlineSnapshot( 153 | `"Operation \\"acquireLock\\" error. some"` 154 | ); 155 | }); 156 | 157 | it("acquireLock calls add with generated key name", async () => { 158 | mock.add = jest.fn().mockImplementation((key, value, expire, cb) => cb(null, true)); 159 | 160 | const lockResult = await adapter.acquireLock("some"); 161 | 162 | expect(lockResult).toEqual(true); 163 | expect(mock.add).toBeCalledTimes(1); 164 | expect(mock.add).toBeCalledWith("some_lock", "", 20, expect.any(Function)); 165 | }); 166 | 167 | it("acquireLock uses provided expire timeout instead of global one", async () => { 168 | mock.add = jest.fn().mockImplementation((key, value, expire, cb) => cb(null, true)); 169 | 170 | const lockResult = await adapter.acquireLock("some", 10000); 171 | 172 | expect(lockResult).toEqual(true); 173 | expect(mock.add).toBeCalledTimes(1); 174 | expect(mock.add).toBeCalledWith("some_lock", "", 10, expect.any(Function)); 175 | }); 176 | 177 | it("releaseLock delete lock record with appropriate key, and returns true on success", async () => { 178 | mock.del = jest.fn().mockImplementation((key, cb) => cb(null, true)); 179 | 180 | const releaseLockResult = await adapter.releaseLock("some"); 181 | 182 | expect(releaseLockResult).toEqual(true); 183 | expect(mock.del).toBeCalledTimes(1); 184 | expect(mock.del).toBeCalledWith("some_lock", expect.any(Function)); 185 | }); 186 | 187 | it("releaseLock delete lock record with appropriate key, and returns false on fail", async () => { 188 | mock.del = jest.fn().mockImplementation((key, cb) => cb(null, false)); 189 | 190 | const releaseLockResult = await adapter.releaseLock("some"); 191 | 192 | expect(releaseLockResult).toEqual(false); 193 | expect(mock.del).toBeCalledTimes(1); 194 | expect(mock.del).toBeCalledWith("some_lock", expect.any(Function)); 195 | }); 196 | 197 | it("releaseLock delete lock record with appropriate key, throws on error", async () => { 198 | mock.del = jest.fn().mockImplementation((key, cb) => cb(new Error("some"), false)); 199 | 200 | await expect(adapter.releaseLock("some")).rejects.toThrowErrorMatchingInlineSnapshot( 201 | `"Operation \\"releaseLock\\" error. some"` 202 | ); 203 | }); 204 | 205 | it("isLockExists returns true if lock exists", async () => { 206 | mock.get = jest.fn().mockImplementation((key, cb) => cb(null, "")); 207 | 208 | await expect(adapter.isLockExists("some")).resolves.toEqual(true); 209 | }); 210 | 211 | it("isLockExists returns false if lock not exists", async () => { 212 | mock.get = jest.fn().mockImplementation((key, cb) => cb(null, null)); 213 | 214 | await expect(adapter.isLockExists("some")).resolves.toEqual(false); 215 | 216 | mock.get = jest.fn().mockImplementation((key, cb) => cb(null, undefined)); 217 | 218 | await expect(adapter.isLockExists("some")).resolves.toEqual(false); 219 | }); 220 | 221 | it("isLockExists throws if operation get throws", async () => { 222 | mock.get = jest.fn().mockImplementation((key, cb) => cb(new Error("some"), false)); 223 | 224 | await expect(adapter.isLockExists("some")).rejects.toThrowErrorMatchingInlineSnapshot( 225 | `"Operation \\"isLockExists\\" error. some"` 226 | ); 227 | }); 228 | 229 | it("setConnectionStatus sets connection status to given string", () => { 230 | (adapter as any).setConnectionStatus(ConnectionStatus.CONNECTED); 231 | expect((adapter as any).connectionStatus).toEqual(ConnectionStatus.CONNECTED); 232 | }); 233 | 234 | it("mset sets values", async () => { 235 | mock.set = jest.fn().mockImplementation((key, value, lifetime, cb) => cb(null, true)); 236 | 237 | const values = new Map([ 238 | ["some1", "value1"], 239 | ["some2", "value2"], 240 | ]); 241 | await adapter.mset(values); 242 | 243 | expect(mock.set).toHaveBeenCalledTimes(2); 244 | expect(mock.set.mock.calls).toMatchInlineSnapshot(` 245 | Array [ 246 | Array [ 247 | "some1", 248 | "value1", 249 | 0, 250 | [Function], 251 | ], 252 | Array [ 253 | "some2", 254 | "value2", 255 | 0, 256 | [Function], 257 | ], 258 | ] 259 | `); 260 | }); 261 | 262 | it("mset throws on empty Map", async () => { 263 | const values = new Map(); 264 | await expect(adapter.mset(values)).rejects.toThrowErrorMatchingInlineSnapshot( 265 | `"ERR wrong number of arguments for 'mset' command"` 266 | ); 267 | }); 268 | 269 | it("mget gets values", async () => { 270 | const values = { 271 | some1: "value1", 272 | some2: "value2", 273 | some3: undefined, 274 | }; 275 | 276 | mock.getMulti = jest.fn().mockImplementation((keys, cb) => cb(null, values)); 277 | await expect(adapter.mget(Object.keys(values))).resolves.toMatchInlineSnapshot(` 278 | Array [ 279 | "value1", 280 | "value2", 281 | null, 282 | ] 283 | `); 284 | 285 | expect(mock.getMulti).toHaveBeenCalledTimes(1); 286 | expect(mock.getMulti).toHaveBeenCalledWith(["some1", "some2", "some3"], expect.any(Function)); 287 | }); 288 | 289 | it("mget throws on error", async () => { 290 | mock.getMulti = jest.fn().mockImplementation((keys, cb) => cb(new Error("some"), null)); 291 | await expect(adapter.mget([])).rejects.toThrowErrorMatchingInlineSnapshot( 292 | `"Operation \\"mget\\" error. some"` 293 | ); 294 | }); 295 | }); 296 | -------------------------------------------------------------------------------- /src/adapters/MemcachedStorageAdapter.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import { ConnectionStatus } from "../ConnectionStatus"; 3 | import customError from "../errors/custom-error"; 4 | import { StorageAdapter } from "../StorageAdapter"; 5 | 6 | export const DEFAULT_LOCK_EXPIRES = 20000; 7 | 8 | interface Memcached extends EventEmitter { 9 | add( 10 | key: string, 11 | value: string, 12 | lifetime: number, 13 | cb: (err: Error | undefined, result: boolean) => void 14 | ): void; 15 | 16 | get(key: string, cb: (err: Error | undefined, data: string) => void): void; 17 | 18 | getMulti(keys: string[], cb: (err: Error | undefined, data: { [key: string]: string }) => void): void; 19 | 20 | set( 21 | key: string, 22 | value: string, 23 | lifetime: number, 24 | cb: (err: Error | undefined, result: boolean) => void 25 | ): void; 26 | 27 | del(key: string, cb: (err: Error | undefined, result: boolean) => void): void; 28 | } 29 | 30 | function operationError(op: string, err: Error): Error { 31 | return customError("operationError", `Operation "${op}" error. ${err.message}`, err); 32 | } 33 | 34 | export type MemcachedStorageAdapterOptions = { 35 | lockExpireTimeout: number; 36 | }; 37 | 38 | /** 39 | * Memcached adapter for Manager. Implements the StorageAdapter interface 40 | */ 41 | export class MemcachedStorageAdapter implements StorageAdapter { 42 | /** 43 | * The adapter's constructor takes as its input the only parameter - the memcached instance (new Memcached) 44 | */ 45 | constructor(memcachedInstance: Memcached, options?: MemcachedStorageAdapterOptions) { 46 | this.memcachedInstance = memcachedInstance; 47 | this.options = { 48 | lockExpireTimeout: DEFAULT_LOCK_EXPIRES, 49 | ...options, 50 | }; 51 | this.memcachedInstance.on("reconnect", () => this.setConnectionStatus(ConnectionStatus.CONNECTED)); 52 | this.memcachedInstance.on("reconnecting", () => this.setConnectionStatus(ConnectionStatus.CONNECTING)); 53 | this.memcachedInstance.on("failure", () => this.setConnectionStatus(ConnectionStatus.DISCONNECTED)); 54 | } 55 | 56 | private connectionStatus: ConnectionStatus = ConnectionStatus.CONNECTED; 57 | private memcachedInstance: Memcached; 58 | private options: MemcachedStorageAdapterOptions; 59 | 60 | /** 61 | * The method should call the callback passed to it as soon as the storage is ready to execute commands. 62 | */ 63 | onConnect(callback: (err: unknown) => void): void { 64 | this.memcachedInstance.on("reconnect", callback); 65 | } 66 | 67 | /** 68 | * Returns the current status of the storage connection. 69 | */ 70 | getConnectionStatus(): ConnectionStatus { 71 | return this.connectionStatus; 72 | } 73 | 74 | /** 75 | * set - sets the value for the key key in storage. 76 | * Returns true on success; false otherwise. 77 | */ 78 | async set(key: string, value: string, expiresIn?: number): Promise { 79 | const lifetime = this.getLifetimeFromMs(expiresIn); 80 | 81 | return new Promise((resolve, reject) => { 82 | this.memcachedInstance.set(key, value, lifetime, (err, result) => { 83 | if (err) { 84 | return reject(operationError("set", err)); 85 | } 86 | 87 | resolve(result); 88 | }); 89 | }); 90 | } 91 | 92 | /** 93 | * mset - stores values to the storage 94 | */ 95 | async mset(values: Map): Promise { 96 | if (values.size === 0) { 97 | throw new Error("ERR wrong number of arguments for 'mset' command"); 98 | } 99 | 100 | await Promise.all([...values.entries()].map(([key, value]) => this.set(key, value))); 101 | } 102 | 103 | /** 104 | * get - returns value by key 105 | * Returns null if record does not exist 106 | */ 107 | async get(key: string): Promise { 108 | if (key === "") { 109 | throw new Error("ERR wrong arguments for 'get' command"); 110 | } 111 | 112 | return new Promise((resolve, reject) => { 113 | this.memcachedInstance.get(key, (err, result) => { 114 | if (err) { 115 | return reject(operationError("get", err)); 116 | } 117 | 118 | return resolve(result || null); 119 | }); 120 | }); 121 | } 122 | 123 | /** 124 | * mget - returns values by keys 125 | * Returns null for records that do not exist 126 | */ 127 | async mget(keys: string[]): Promise<(string | null)[]> { 128 | return new Promise((resolve, reject) => { 129 | this.memcachedInstance.getMulti(keys, (err, result) => { 130 | if (err) { 131 | return reject(operationError("mget", err)); 132 | } 133 | 134 | resolve( 135 | keys.map((key) => { 136 | if (result[key] === undefined) { 137 | return null; 138 | } 139 | 140 | return result[key]; 141 | }) 142 | ); 143 | }); 144 | }); 145 | } 146 | 147 | /** 148 | * Removes the entry with the key key from storage 149 | */ 150 | async del(key: string): Promise { 151 | return new Promise((resolve, reject) => { 152 | this.memcachedInstance.del(key, (err, result) => { 153 | if (err) { 154 | return reject(operationError("del", err)); 155 | } 156 | 157 | return resolve(result); 158 | }); 159 | }); 160 | } 161 | 162 | /** 163 | * Locks the entry with the key key to be changed, returns true if the operation is successful, otherwise false 164 | */ 165 | async acquireLock(key: string, lockExpireTimeout?: number): Promise { 166 | const expiresIn = lockExpireTimeout !== undefined ? lockExpireTimeout : this.options.lockExpireTimeout; 167 | 168 | return new Promise((resolve, reject) => { 169 | this.memcachedInstance.add(`${key}_lock`, "", this.getLifetimeFromMs(expiresIn), (err, result) => { 170 | if (err) { 171 | return reject(operationError("acquireLock", err)); 172 | } 173 | 174 | resolve(result); 175 | }); 176 | }); 177 | } 178 | 179 | /** 180 | * Unlocks the record with the key key, returns true if the operation is successful, otherwise false 181 | */ 182 | async releaseLock(key: string): Promise { 183 | return new Promise((resolve, reject) => { 184 | this.memcachedInstance.del(`${key}_lock`, (err, result) => { 185 | if (err) { 186 | return reject(operationError("releaseLock", err)); 187 | } 188 | 189 | resolve(result); 190 | }); 191 | }); 192 | } 193 | 194 | /** 195 | * Checks if the entry with the key key is locked for changes 196 | */ 197 | async isLockExists(key: string): Promise { 198 | return new Promise((resolve, reject) => { 199 | this.memcachedInstance.get(`${key}_lock`, (err, result) => { 200 | if (err) { 201 | return reject(operationError("isLockExists", err)); 202 | } 203 | 204 | resolve(result !== null && result !== undefined); 205 | }); 206 | }); 207 | } 208 | 209 | /** 210 | * Changes adapter connection status 211 | */ 212 | private setConnectionStatus(status: ConnectionStatus): void { 213 | this.connectionStatus = status; 214 | } 215 | 216 | /** 217 | * Since memcached throws "bad command line format" if float value is 218 | * passed as lifetime, we need to ceil it together with converting to seconds. 219 | */ 220 | private getLifetimeFromMs(expiresMs: number | undefined): number { 221 | const SECOND = 1000; 222 | 223 | if (expiresMs && isFinite(Number(expiresMs)) && expiresMs > 0) { 224 | return Math.ceil(expiresMs / SECOND); 225 | } 226 | 227 | return 0; 228 | } 229 | } 230 | 231 | export default MemcachedStorageAdapter; 232 | -------------------------------------------------------------------------------- /src/adapters/RedisStorageAdapter.spec.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import { DEFAULT_LOCK_EXPIRES, RedisStorageAdapter } from "./RedisStorageAdapter"; 3 | import { ConnectionStatus } from "../ConnectionStatus"; 4 | 5 | class RedisMock extends EventEmitter {} 6 | 7 | let mock: any = new RedisMock(); 8 | let adapter: RedisStorageAdapter = new RedisStorageAdapter(mock as any); 9 | 10 | describe("Redis adapter", () => { 11 | beforeEach(() => { 12 | mock = new RedisMock(); 13 | adapter = new RedisStorageAdapter(mock as any); 14 | }); 15 | 16 | it('Sets connection status to "connected" if redis emits ready', () => { 17 | mock.emit("ready"); 18 | expect((adapter as any).connectionStatus).toEqual(ConnectionStatus.CONNECTED); 19 | }); 20 | 21 | it('Sets connection status to "connecting" if redis emits reconnecting', () => { 22 | mock.emit("reconnecting"); 23 | expect((adapter as any).connectionStatus).toEqual(ConnectionStatus.CONNECTING); 24 | }); 25 | 26 | it('Sets connection status to "diconnected" if redis emits end', () => { 27 | mock.emit("end"); 28 | expect((adapter as any).connectionStatus).toEqual(ConnectionStatus.DISCONNECTED); 29 | }); 30 | 31 | it("connection status is disconnected by default", () => { 32 | expect((adapter as any).getConnectionStatus()).toEqual(ConnectionStatus.DISCONNECTED); 33 | }); 34 | 35 | it("getConnectionStatus returns current connection status", () => { 36 | (adapter as any).connectionStatus = ConnectionStatus.CONNECTING; 37 | 38 | expect(adapter.getConnectionStatus()).toEqual(ConnectionStatus.CONNECTING); 39 | }); 40 | 41 | it("onConnect calls callback function if redis instance emits ready", () => { 42 | const cb = jest.fn(); 43 | 44 | adapter.onConnect(cb); 45 | mock.emit("ready"); 46 | 47 | expect(cb).toHaveBeenCalledTimes(1); 48 | }); 49 | 50 | it("set returns true if operation is successful", async () => { 51 | mock.set = jest.fn().mockImplementation(() => 1); 52 | 53 | expect(await adapter.set("some", "value")).toEqual(true); 54 | }); 55 | 56 | it("set returns false if operation is not successful", async () => { 57 | mock.set = jest.fn().mockImplementation(() => 0); 58 | 59 | expect(await adapter.set("some", "value")).toEqual(false); 60 | }); 61 | 62 | it("set calls set with cache prefix", async () => { 63 | mock.set = jest.fn().mockImplementation(() => 1); 64 | 65 | expect(await adapter.set("some", "value")).toEqual(true); 66 | expect(mock.set).toHaveBeenCalledTimes(1); 67 | expect(mock.set).toHaveBeenCalledWith("some", "value"); 68 | }); 69 | 70 | it("set calls set with cache prefix and PX mode when expires set", async () => { 71 | mock.set = jest.fn().mockImplementation(() => 1); 72 | 73 | expect(await adapter.set("some", "value", 1)).toEqual(true); 74 | expect(mock.set).toHaveBeenCalledTimes(1); 75 | expect(mock.set).toHaveBeenCalledWith("some", "value", "PX", 1); 76 | }); 77 | 78 | it("get calls get with cache prefix", async () => { 79 | mock.get = jest.fn().mockImplementation(() => "hello"); 80 | 81 | const value = await adapter.get("some"); 82 | 83 | expect(value).toEqual("hello"); 84 | expect(mock.get).toHaveBeenCalledTimes(1); 85 | expect(mock.get).toHaveBeenCalledWith("some"); 86 | }); 87 | 88 | it("del calls del with cache prefix", async () => { 89 | mock.del = jest.fn(); 90 | 91 | await adapter.del("some"); 92 | 93 | expect(mock.del).toHaveBeenCalledTimes(1); 94 | expect(mock.del).toHaveBeenCalledWith("some"); 95 | }); 96 | 97 | it("acquireLock returns true if lock is successful", async () => { 98 | mock.set = jest.fn().mockImplementation(() => "OK"); 99 | 100 | const lockResult = await adapter.acquireLock("some"); 101 | 102 | expect(lockResult).toEqual(true); 103 | }); 104 | 105 | it("acquireLock returns false if lock is unsuccessful", async () => { 106 | mock.set = jest.fn().mockImplementation(() => null); 107 | 108 | const lockResult = await adapter.acquireLock("some"); 109 | 110 | expect(lockResult).toEqual(false); 111 | }); 112 | 113 | it("acquireLock calls set with generated key name and in NX mode", async () => { 114 | mock.set = jest.fn().mockImplementation(() => "OK"); 115 | 116 | const lockResult = await adapter.acquireLock("some"); 117 | 118 | expect(lockResult).toEqual(true); 119 | expect(mock.set).toBeCalledTimes(1); 120 | expect(mock.set).toBeCalledWith("some_lock", "", "PX", DEFAULT_LOCK_EXPIRES, "NX"); 121 | }); 122 | 123 | it("acquireLock use passed lockExpireTimeout", async () => { 124 | mock.set = jest.fn().mockImplementation(() => "OK"); 125 | 126 | const lockExpireTimeout = 12345; 127 | const lockResult = await adapter.acquireLock("some", lockExpireTimeout); 128 | 129 | expect(lockResult).toEqual(true); 130 | expect(mock.set).toBeCalledTimes(1); 131 | expect(mock.set).toBeCalledWith("some_lock", "", "PX", lockExpireTimeout, "NX"); 132 | }); 133 | 134 | it("releaseLock delete lock record with appropriate key, and returns true on success", async () => { 135 | mock.del = jest.fn().mockImplementation(() => 1); 136 | 137 | const releaseLockResult = await adapter.releaseLock("some"); 138 | expect(releaseLockResult).toEqual(true); 139 | expect(mock.del).toBeCalledTimes(1); 140 | expect(mock.del).toBeCalledWith("some_lock"); 141 | }); 142 | 143 | it("releaseLock delete lock record with appropriate key, and returns false on fail", async () => { 144 | mock.del = jest.fn().mockImplementation(() => 0); 145 | 146 | const releaseLockResult = await adapter.releaseLock("some"); 147 | expect(releaseLockResult).toEqual(false); 148 | expect(mock.del).toBeCalledTimes(1); 149 | expect(mock.del).toBeCalledWith("some_lock"); 150 | }); 151 | 152 | it("isLockExists calls redis exists and return true if lock exists", async () => { 153 | mock.exists = jest.fn().mockImplementation(() => 1); 154 | 155 | const lockExists = await adapter.isLockExists("some"); 156 | expect(lockExists).toEqual(true); 157 | expect(mock.exists).toBeCalledTimes(1); 158 | expect(mock.exists).toBeCalledWith("some_lock"); 159 | }); 160 | 161 | it("setConnectionStatus sets connection status to given string", () => { 162 | (adapter as any).setConnectionStatus(ConnectionStatus.CONNECTED); 163 | expect((adapter as any).connectionStatus).toEqual(ConnectionStatus.CONNECTED); 164 | }); 165 | 166 | it("createRedisAdapter creates RedisStorageAdapter instance", () => { 167 | const instance = new RedisStorageAdapter(mock as any); 168 | 169 | expect(instance).toBeInstanceOf(RedisStorageAdapter); 170 | }); 171 | 172 | it("mset sets values", async () => { 173 | mock.mset = jest.fn().mockImplementation(); 174 | const values = new Map([ 175 | ["some1", "value1"], 176 | ["some2", "value2"], 177 | ]); 178 | await adapter.mset(values); 179 | 180 | expect(mock.mset).toHaveBeenCalledTimes(1); 181 | expect(mock.mset).toHaveBeenCalledWith( 182 | new Map([ 183 | ["some1", "value1"], 184 | ["some2", "value2"], 185 | ]) 186 | ); 187 | }); 188 | 189 | it("mget gets values", async () => { 190 | const values = new Map([ 191 | ["some1", "value1"], 192 | ["some2", "value2"], 193 | ]); 194 | mock.mget = jest.fn().mockImplementation(() => Array.from(values.values())); 195 | await adapter.mget(Array.from(values.keys())); 196 | 197 | expect(mock.mget).toHaveBeenCalledTimes(1); 198 | expect(mock.mget).toHaveBeenCalledWith("some1", "some2"); 199 | }); 200 | }); 201 | -------------------------------------------------------------------------------- /src/adapters/RedisStorageAdapter.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import { ConnectionStatus } from "../ConnectionStatus"; 3 | import { StorageAdapter } from "../StorageAdapter"; 4 | import { withTimeout } from "../with-timeout"; 5 | 6 | /** 7 | * Get commands 8 | */ 9 | export const DEFAULT_OPERATION_TIMEOUT = 150; 10 | export const DEFAULT_LOCK_EXPIRES = 20000; 11 | 12 | interface Redis extends EventEmitter { 13 | del(...keys: string[]): Promise; 14 | 15 | get(key: string): Promise; 16 | 17 | exists(...keys: string[]): Promise; 18 | 19 | set( 20 | key: string, 21 | value: string, 22 | expiryMode?: string, 23 | time?: number | string, 24 | setMode?: number | string 25 | ): Promise<"OK" | null>; 26 | 27 | mget(...keys: string[]): Promise>; 28 | 29 | mset(data: Map): Promise<"OK">; 30 | } 31 | 32 | export type RedisStorageAdapterOptions = { 33 | operationTimeout?: number; 34 | lockExpireTimeout?: number; 35 | }; 36 | 37 | /** 38 | * Redis adapter for Manager. Implements the StorageAdapter interface 39 | */ 40 | export class RedisStorageAdapter implements StorageAdapter { 41 | /** 42 | * The adapter's constructor takes as its input the only parameter - the redis instance (new Redis) 43 | */ 44 | constructor(redisInstance: Redis, options?: RedisStorageAdapterOptions) { 45 | this.redisInstance = redisInstance; 46 | this.options = { 47 | operationTimeout: DEFAULT_OPERATION_TIMEOUT, 48 | lockExpireTimeout: DEFAULT_LOCK_EXPIRES, 49 | ...options, 50 | }; 51 | this.redisInstance.on("ready", () => this.setConnectionStatus(ConnectionStatus.CONNECTED)); 52 | this.redisInstance.on("reconnecting", () => this.setConnectionStatus(ConnectionStatus.CONNECTING)); 53 | this.redisInstance.on("end", () => this.setConnectionStatus(ConnectionStatus.DISCONNECTED)); 54 | 55 | this.withTimeout = (promise: Promise) => withTimeout(promise, this.options.operationTimeout); 56 | } 57 | 58 | /** 59 | * Storage adapter options 60 | */ 61 | private options: Required; 62 | 63 | /** 64 | * Instance of ioredis client (https://github.com/luin/ioredis) 65 | */ 66 | private redisInstance: Redis; 67 | 68 | /** 69 | * Redis connection status 70 | */ 71 | private connectionStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED; 72 | 73 | private readonly withTimeout: (promise: Promise) => Promise; 74 | 75 | /** 76 | * Returns the status of the connection with redis (see StorageAdapter) 77 | */ 78 | public getConnectionStatus(): ConnectionStatus { 79 | return this.connectionStatus; 80 | } 81 | 82 | /** 83 | * Implementing this method in the redis adapter will trigger a callback as soon as the redis send event to the 'ready' event. 84 | */ 85 | public onConnect(callback: (...args: unknown[]) => void): void { 86 | this.redisInstance.on("ready", callback); 87 | } 88 | 89 | /** 90 | * The set method provided by the adapter. 91 | * Use set command to set hash value in radish 92 | */ 93 | public async set(key: string, value: string, expiresIn?: number): Promise { 94 | const setPromise = expiresIn 95 | ? this.redisInstance.set(key, value, "PX", expiresIn) 96 | : this.redisInstance.set(key, value); 97 | 98 | return Boolean(await this.withTimeout(setPromise)); 99 | } 100 | 101 | /** 102 | * Set multiple values to redis storage 103 | */ 104 | public async mset(values: Map): Promise { 105 | const data = new Map(); 106 | 107 | for (const [key, value] of values.entries()) { 108 | data.set(key, value); 109 | } 110 | 111 | await this.withTimeout(this.redisInstance.mset(data)); 112 | } 113 | 114 | /** 115 | * The get command method provided by the adapter. Use get command to get key value from redis 116 | */ 117 | public async get(key: string): Promise { 118 | return this.withTimeout(this.redisInstance.get(key)); 119 | } 120 | 121 | /** 122 | * The mget command method provided by the adapter. 123 | * Use mget command to get multiple values from redis 124 | */ 125 | public async mget(keys: string[]): Promise<(string | null)[]> { 126 | return this.withTimeout(this.redisInstance.mget(...keys)); 127 | } 128 | 129 | /** 130 | * The del method provided by the adapter. Uses the del command to remove the key. 131 | */ 132 | public async del(key: string): Promise { 133 | return (await this.withTimeout(this.redisInstance.del(key))) > 0; 134 | } 135 | 136 | /** 137 | * Set the lock on the key 138 | */ 139 | public async acquireLock(key: string, lockExpireTimeout?: number): Promise { 140 | const expiresIn = lockExpireTimeout !== undefined ? lockExpireTimeout : this.options.lockExpireTimeout; 141 | const setResult = await this.withTimeout( 142 | this.redisInstance.set(`${key}_lock`, "", "PX", expiresIn, "NX") 143 | ); 144 | 145 | return setResult === "OK"; 146 | } 147 | 148 | /** 149 | * Unlocks the key 150 | */ 151 | public async releaseLock(key: string): Promise { 152 | const deletedKeys = await this.withTimeout(this.redisInstance.del(`${key}_lock`)); 153 | 154 | return deletedKeys > 0; 155 | } 156 | 157 | /** 158 | * Checks if key is locked 159 | */ 160 | public async isLockExists(key: string): Promise { 161 | const lockExists = await this.withTimeout(this.redisInstance.exists(`${key}_lock`)); 162 | 163 | return lockExists === 1; 164 | } 165 | 166 | /** 167 | * Changes adapter connection status 168 | */ 169 | private setConnectionStatus(status: ConnectionStatus): void { 170 | this.connectionStatus = status; 171 | } 172 | } 173 | 174 | export default RedisStorageAdapter; 175 | -------------------------------------------------------------------------------- /src/adapters/TestStorageAdapter.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionStatus } from "../ConnectionStatus"; 2 | import { StorageAdapter } from "../StorageAdapter"; 3 | 4 | class TestStorageAdapter implements StorageAdapter { 5 | internalStorage: Record; 6 | isConnected: boolean; 7 | 8 | constructor(storage: Record = {}, isConnected = true) { 9 | this.internalStorage = storage; 10 | this.isConnected = isConnected; 11 | } 12 | 13 | checkConnection(): void { 14 | if (!this.isConnected) { 15 | throw new Error("No connection"); 16 | } 17 | } 18 | 19 | getConnectionStatus(): ConnectionStatus { 20 | return this.isConnected ? ConnectionStatus.CONNECTED : ConnectionStatus.DISCONNECTED; 21 | } 22 | 23 | onConnect(): ConnectionStatus { 24 | return this.getConnectionStatus(); 25 | } 26 | 27 | async set(key: string, value: string): Promise { 28 | this.checkConnection(); 29 | this.internalStorage[key] = value; 30 | 31 | return true; 32 | } 33 | 34 | async get(key: string): Promise { 35 | this.checkConnection(); 36 | 37 | return this.internalStorage[key]; 38 | } 39 | 40 | async del(key: string): Promise { 41 | this.checkConnection(); 42 | 43 | if (this.internalStorage[key]) { 44 | delete this.internalStorage[key]; 45 | 46 | return true; 47 | } 48 | 49 | return false; 50 | } 51 | 52 | async acquireLock(key: string): Promise { 53 | this.checkConnection(); 54 | 55 | return this.set(`${key}_lock`, ""); 56 | } 57 | 58 | async releaseLock(key: string): Promise { 59 | this.checkConnection(); 60 | 61 | return this.del(`${key}_lock`); 62 | } 63 | 64 | async isLockExists(key: string): Promise { 65 | this.checkConnection(); 66 | 67 | return !!this.internalStorage[`${key}_lock`]; 68 | } 69 | 70 | async mset(values: Map): Promise { 71 | this.checkConnection(); 72 | values.forEach((value, key) => { 73 | this.internalStorage[key] = value; 74 | }); 75 | } 76 | 77 | async mget(keys: string[]): Promise<(string | null)[]> { 78 | this.checkConnection(); 79 | 80 | return keys.map((key) => this.internalStorage[key] || null); 81 | } 82 | 83 | setOptions(): void { 84 | return undefined; 85 | } 86 | } 87 | 88 | export default TestStorageAdapter; 89 | -------------------------------------------------------------------------------- /src/deserialize.spec.ts: -------------------------------------------------------------------------------- 1 | import deserialize from "./deserialize"; 2 | 3 | describe("deserizalize", () => { 4 | it("parses valid json string or null", () => { 5 | expect(deserialize("null")).toEqual(null); 6 | expect(deserialize('"test"')).toEqual("test"); 7 | expect(deserialize("1")).toEqual(1); 8 | expect(deserialize('{"a":1}')).toEqual({ a: 1 }); 9 | }); 10 | 11 | it("throws parse error on invalid json string", () => { 12 | expect(() => deserialize("test")).toThrowErrorMatchingSnapshot(); 13 | expect(() => deserialize('{"a":')).toThrowErrorMatchingSnapshot(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/deserialize.ts: -------------------------------------------------------------------------------- 1 | import * as errors from "./errors/errors"; 2 | 3 | export default function (value: string): R { 4 | try { 5 | return JSON.parse(value); 6 | } catch (error) { 7 | throw errors.parseError(error as Error); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/errors/constants.ts: -------------------------------------------------------------------------------- 1 | export const ERRORS = { 2 | ParseError: "ParseError", 3 | RequestMaximumTimeoutExceededError: "RequestMaximumTimeoutExceededError", 4 | WaitForResultError: "WaitForResultError", 5 | OperationTimeoutError: "OperationTimeoutError", 6 | ExecutorReturnsUndefinedError: "ExecutorReturnsUndefinedError", 7 | }; 8 | -------------------------------------------------------------------------------- /src/errors/custom-error.spec.ts: -------------------------------------------------------------------------------- 1 | import customError from "./custom-error"; 2 | 3 | describe("custom error", () => { 4 | it("creates custom error with given name", () => { 5 | const error = customError("Custom", "message", { test: 1 }); 6 | 7 | expect(error).toHaveProperty("name", "Custom"); 8 | expect(error).toHaveProperty("message", "message"); 9 | expect(error).toHaveProperty("payload", { test: 1 }); 10 | expect(error).toEqual(expect.any(Error)); 11 | }); 12 | 13 | it("error payload defaults to empty object", () => { 14 | const error = customError("Custom", "message"); 15 | 16 | expect(error).toHaveProperty("payload", {}); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/errors/custom-error.ts: -------------------------------------------------------------------------------- 1 | export default function

(name: string, message: string, payload: Partial

= {}): Error { 2 | class CacheManagerError extends Error { 3 | payload: Partial

; 4 | 5 | constructor() { 6 | super(); 7 | 8 | this.name = name; 9 | this.message = message; 10 | this.payload = payload; 11 | } 12 | } 13 | 14 | return new CacheManagerError(); 15 | } 16 | -------------------------------------------------------------------------------- /src/errors/errors.ts: -------------------------------------------------------------------------------- 1 | import customError from "./custom-error"; 2 | import { ERRORS } from "./constants"; 3 | 4 | export function parseError(error: Error): Error { 5 | return customError(ERRORS.ParseError, error.message); 6 | } 7 | 8 | export function requestMaximumTimeoutExceededError(maxTimeout: number, error?: Error): Error { 9 | const text = `Exceeded maximum timeout of ${maxTimeout}`; 10 | 11 | return customError(ERRORS.RequestMaximumTimeoutExceededError, error ? error.message : text); 12 | } 13 | 14 | export function waitForResultError(error?: Error): Error { 15 | const text = "Error while waiting for result in cache"; 16 | 17 | return customError(ERRORS.WaitForResultError, error ? error.message : text); 18 | } 19 | 20 | export function operationTimeoutError(timeout: number): Error { 21 | const text = `Operation timeout after ${timeout}`; 22 | 23 | return customError(ERRORS.OperationTimeoutError, text); 24 | } 25 | 26 | export function isOperationTimeoutError(error: unknown): boolean { 27 | return error instanceof Error && error.name === ERRORS.OperationTimeoutError; 28 | } 29 | 30 | export function executorReturnsUndefinedError(): Error { 31 | const text = "Executor should not return undefined. Correct value for emptiness is null."; 32 | 33 | return customError(ERRORS.ExecutorReturnsUndefinedError, text); 34 | } 35 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { StorageAdapter, StorageAdapterOptions } from "./StorageAdapter"; 2 | import { Tag, Tags } from "./storage/Storage"; 3 | import { Record } from "./storage/Record"; 4 | import Cache, { CacheOptions } from "./Cache"; 5 | import RedisStorageAdapter from "./adapters/RedisStorageAdapter"; 6 | import MemcachedStorageAdapter from "./adapters/MemcachedStorageAdapter"; 7 | import ReadThroughManager from "./managers/ReadThroughManager"; 8 | import WriteThroughManager from "./managers/WriteThroughManager"; 9 | import RefreshAheadManager from "./managers/RefreshAheadManager"; 10 | 11 | export { 12 | CacheOptions, 13 | StorageAdapter, 14 | StorageAdapterOptions, 15 | Record, 16 | Tag, 17 | Tags, 18 | RedisStorageAdapter, 19 | MemcachedStorageAdapter, 20 | ReadThroughManager, 21 | RefreshAheadManager, 22 | WriteThroughManager, 23 | }; 24 | export * from "./errors/constants"; 25 | export { LockedKeyRetrieveStrategy } from "./LockedKeyRetrieveStrategy"; 26 | export default Cache; 27 | -------------------------------------------------------------------------------- /src/locked-key-retrieve-strategies/RunExecutorLockedKeyRetrieveStrategy.spec.ts: -------------------------------------------------------------------------------- 1 | import { RunExecutorLockedKeyRetrieveStrategy } from "./RunExecutorLockedKeyRetrieveStrategy"; 2 | 3 | let instance: RunExecutorLockedKeyRetrieveStrategy; 4 | 5 | describe("RunExecutorLockedKeyRetrieveStrategy", () => { 6 | beforeEach(() => { 7 | instance = new RunExecutorLockedKeyRetrieveStrategy(); 8 | }); 9 | 10 | it("getName returns string", () => { 11 | expect(instance.getName()).toEqual(expect.any(String)); 12 | }); 13 | 14 | it("get calls context executor", async () => { 15 | const executorMock = jest.fn().mockResolvedValue(true); 16 | 17 | await expect(instance.get({ executor: executorMock } as any)).resolves.toEqual(true); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/locked-key-retrieve-strategies/RunExecutorLockedKeyRetrieveStrategy.ts: -------------------------------------------------------------------------------- 1 | import { LockedKeyRetrieveStrategy } from "../LockedKeyRetrieveStrategy"; 2 | import { ExecutorContext, runExecutor } from "../Executor"; 3 | 4 | /** 5 | * This locked key retrieve strategy is default and suitable for most cases. It just 6 | * runs executor and returns value from it. 7 | */ 8 | export class RunExecutorLockedKeyRetrieveStrategy implements LockedKeyRetrieveStrategy { 9 | public getName(): string { 10 | return "runExecutor"; 11 | } 12 | 13 | async get(context: ExecutorContext): Promise { 14 | return runExecutor(context.executor); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/locked-key-retrieve-strategies/WaitForResultLockedKeyRetrieveStrategy.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DEFAULT_MAXIMUM_TIMEOUT, 3 | DEFAULT_REQUEST_TIMEOUT, 4 | WaitForResultLockedKeyRetrieveStrategy, 5 | } from "./WaitForResultLockedKeyRetrieveStrategy"; 6 | 7 | let instance: any; 8 | 9 | const loggerMock: any = { 10 | error: jest.fn(), 11 | }; 12 | const getRecordMock = jest.fn(); 13 | const keyLockCheckFnMock = jest.fn(); 14 | 15 | describe("WaitForResultLockedKeyRetrieveStrategy", () => { 16 | beforeEach(() => { 17 | instance = new WaitForResultLockedKeyRetrieveStrategy({ 18 | logger: loggerMock, 19 | getRecord: getRecordMock, 20 | keyLockCheckFn: keyLockCheckFnMock, 21 | maximumTimeout: 100, 22 | requestTimeout: 10, 23 | }); 24 | }); 25 | 26 | it("sets default timeouts if other not provided", () => { 27 | instance = new WaitForResultLockedKeyRetrieveStrategy({ 28 | logger: loggerMock, 29 | getRecord: getRecordMock, 30 | keyLockCheckFn: keyLockCheckFnMock, 31 | }); 32 | 33 | expect(instance.maximumTimeout).toEqual(DEFAULT_MAXIMUM_TIMEOUT); 34 | expect(instance.requestTimeout).toEqual(DEFAULT_REQUEST_TIMEOUT); 35 | }); 36 | 37 | it("getName returns string", () => { 38 | expect(instance.getName()).toEqual(expect.any(String)); 39 | }); 40 | 41 | it("get deserializes value", async () => { 42 | keyLockCheckFnMock.mockReturnValue(false); 43 | getRecordMock.mockReturnValue({ value: `{"a":1}` }); 44 | 45 | expect(await instance.get({ key: "test" })).toEqual({ a: 1 }); 46 | getRecordMock.mockReset(); 47 | }); 48 | 49 | it("get throws if null in cache", async () => { 50 | getRecordMock.mockReturnValue(null); 51 | await expect(instance.get({ key: "test" })).rejects.toThrow("Error while waiting for result in cache"); 52 | getRecordMock.mockReset(); 53 | }); 54 | 55 | it("get throws if executor put no record in cache", async () => { 56 | await expect(instance.get({ key: "test" })).rejects.toThrow("Error while waiting for result in cache"); 57 | }); 58 | 59 | it("get throws if maximum timeout exceeded, while trying to access locked key", async () => { 60 | keyLockCheckFnMock.mockReturnValue(true); 61 | await expect(instance.get({ key: "test" })).rejects.toThrow("Exceeded maximum timeout of 100"); 62 | keyLockCheckFnMock.mockReset(); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/locked-key-retrieve-strategies/WaitForResultLockedKeyRetrieveStrategy.ts: -------------------------------------------------------------------------------- 1 | import { LockedKeyRetrieveStrategy } from "../LockedKeyRetrieveStrategy"; 2 | import * as errors from "../errors/errors"; 3 | import deserialize from "../deserialize"; 4 | import timeout from "../timeout"; 5 | import { ExecutorContext } from "../Executor"; 6 | import { Logger } from "../Logger"; 7 | import { Record } from "../storage/Record"; 8 | 9 | export const DEFAULT_MAXIMUM_TIMEOUT = 3000; 10 | export const DEFAULT_REQUEST_TIMEOUT = 250; 11 | 12 | export type KeyLockCheckFn = (key: string) => boolean | Promise; 13 | export type GetRecordFn = (key: string) => Promise | null>; 14 | export type WaitForResultLockedKeyRetrieveStrategyOptions = { 15 | maximumTimeout?: number; 16 | requestTimeout?: number; 17 | keyLockCheckFn: KeyLockCheckFn; 18 | getRecord: GetRecordFn; 19 | logger: Logger; 20 | }; 21 | 22 | /** 23 | * This locked key retrieve strategy used for prevent consumers getting non-valid or expired values 24 | * from storage. It holds consumers and not returning result until it appear in storage. 25 | * Due to complexity of this flow it is not recommended to use it, unless executor is not running 26 | * big and long query 27 | */ 28 | export class WaitForResultLockedKeyRetrieveStrategy implements LockedKeyRetrieveStrategy { 29 | constructor(options: WaitForResultLockedKeyRetrieveStrategyOptions) { 30 | this.maximumTimeout = Number(options.maximumTimeout) || DEFAULT_MAXIMUM_TIMEOUT; 31 | this.requestTimeout = Number(options.requestTimeout) || DEFAULT_REQUEST_TIMEOUT; 32 | this.keyIsLocked = options.keyLockCheckFn; 33 | this.getRecord = options.getRecord; 34 | this.logger = options.logger; 35 | } 36 | 37 | private readonly maximumTimeout: number; 38 | private readonly requestTimeout: number; 39 | private readonly keyIsLocked: KeyLockCheckFn; 40 | private readonly getRecord: GetRecordFn; 41 | private logger: Logger; 42 | 43 | public getName(): string { 44 | return "waitForResult"; 45 | } 46 | 47 | public async get(context: ExecutorContext): Promise { 48 | const startTime = Date.now(); 49 | const retryRequest = async (): Promise => { 50 | if (Date.now() < startTime + this.maximumTimeout) { 51 | const isLocked = await this.keyIsLocked(context.key); 52 | 53 | if (!isLocked) { 54 | const rec = await this.getRecord(context.key); 55 | 56 | switch (rec) { 57 | case null: 58 | case undefined: 59 | throw errors.waitForResultError(); 60 | default: 61 | return deserialize(rec.value); 62 | } 63 | } 64 | 65 | await timeout(this.requestTimeout); 66 | 67 | return retryRequest(); 68 | } 69 | 70 | this.logger.error(`Key "${context.key}" is locked more than allowed ${this.maximumTimeout}ms.`); 71 | 72 | throw errors.requestMaximumTimeoutExceededError(this.maximumTimeout); 73 | }; 74 | 75 | return retryRequest(); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/managers/BaseManager.ts: -------------------------------------------------------------------------------- 1 | import { ExpireOptions, WriteOptions, Storage, ReadWriteOptions } from "../storage/Storage"; 2 | import { LockedKeyRetrieveStrategy, LockedKeyRetrieveStrategyTypes } from "../LockedKeyRetrieveStrategy"; 3 | import { Logger } from "../Logger"; 4 | import { WaitForResultLockedKeyRetrieveStrategy } from "../locked-key-retrieve-strategies/WaitForResultLockedKeyRetrieveStrategy"; 5 | import { RunExecutorLockedKeyRetrieveStrategy } from "../locked-key-retrieve-strategies/RunExecutorLockedKeyRetrieveStrategy"; 6 | import { Executor, ExecutorContext, runExecutor } from "../Executor"; 7 | import { Record } from "../storage/Record"; 8 | 9 | export interface ManagerOptions extends ExpireOptions { 10 | prefix?: string; 11 | hashKeys?: boolean; 12 | logger: Logger; 13 | storage: Storage; 14 | refreshAheadFactor?: number; 15 | lockedKeyRetrieveStrategies?: [string, LockedKeyRetrieveStrategy][]; 16 | } 17 | 18 | export abstract class BaseManager { 19 | protected constructor(options: ManagerOptions) { 20 | this.logger = options.logger; 21 | this.storage = options.storage; 22 | this.lockedKeyRetrieveStrategies = new Map([ 23 | [ 24 | LockedKeyRetrieveStrategyTypes.waitForResult, 25 | new WaitForResultLockedKeyRetrieveStrategy({ 26 | keyLockCheckFn: this.storage.keyIsLocked.bind(this.storage), 27 | getRecord: this.storage.get.bind(this.storage), 28 | logger: this.logger, 29 | }), 30 | ], 31 | [LockedKeyRetrieveStrategyTypes.runExecutor, new RunExecutorLockedKeyRetrieveStrategy()], 32 | ]); 33 | 34 | if (Array.isArray(options.lockedKeyRetrieveStrategies)) { 35 | options.lockedKeyRetrieveStrategies.forEach(([name, strategy]) => { 36 | this.lockedKeyRetrieveStrategies.set(name, strategy); 37 | }); 38 | } 39 | } 40 | 41 | protected storage: Storage; 42 | 43 | protected lockedKeyRetrieveStrategies: Map; 44 | 45 | protected logger: Logger; 46 | 47 | public abstract get(key: string, executor: Executor, options: ReadWriteOptions): Promise; 48 | 49 | public abstract set(key: string, value: R, options?: WriteOptions): Promise>; 50 | 51 | public del(key: string): Promise { 52 | return this.storage.del(key); 53 | } 54 | 55 | protected async updateCacheAndGetResult( 56 | context: ExecutorContext, 57 | options: ReadWriteOptions 58 | ): Promise { 59 | const lockedKeyRetrieveStrategy = this.getLockedKeyRetrieveStrategy( 60 | options.lockedKeyRetrieveStrategyType 61 | ); 62 | let isKeySuccessfullyLocked = false; 63 | 64 | try { 65 | isKeySuccessfullyLocked = await this.storage.lockKey(context.key); 66 | } catch (keyLockError: unknown) { 67 | this.logger.info( 68 | `Error occurred while trying to lock key "${context.key}". Reason: ${ 69 | (keyLockError as Error).message 70 | }. Running executor` 71 | ); 72 | } 73 | if (!isKeySuccessfullyLocked) { 74 | return lockedKeyRetrieveStrategy.get(context); 75 | } 76 | 77 | try { 78 | this.logger.info(`Running executor for key "${context.key}"`); 79 | const executorResult = await runExecutor(context.executor); 80 | 81 | await this.set(context.key, executorResult, options); 82 | 83 | return executorResult; 84 | } finally { 85 | await this.storage.releaseKey(context.key); 86 | } 87 | } 88 | 89 | protected getLockedKeyRetrieveStrategy( 90 | strategyName: string = LockedKeyRetrieveStrategyTypes.runExecutor 91 | ): LockedKeyRetrieveStrategy { 92 | const strategy = this.lockedKeyRetrieveStrategies.get(strategyName); 93 | 94 | if (!strategy) { 95 | throw new Error(`Cannot find "${strategyName}" locked key retrieve strategy`); 96 | } 97 | 98 | return strategy; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/managers/ReadThroughManager.spec.ts: -------------------------------------------------------------------------------- 1 | import ReadThroughManager from "./ReadThroughManager"; 2 | import TestStorage from "../storage/__mocks__/TestStorage"; 3 | import { ConnectionStatus } from "../ConnectionStatus"; 4 | 5 | const logger = { 6 | info: jest.fn(), 7 | trace: jest.fn(), 8 | warn: jest.fn(), 9 | error: jest.fn(), 10 | }; 11 | let internalStorage = {}; 12 | let storage: TestStorage; 13 | let manager: ReadThroughManager; 14 | 15 | describe("ReadThroughManager", () => { 16 | beforeEach(() => { 17 | internalStorage = {}; 18 | storage = new TestStorage(internalStorage); 19 | manager = new ReadThroughManager({ 20 | storage, 21 | prefix: "cache", 22 | hashKeys: false, 23 | expiresIn: 10000, 24 | logger, 25 | }); 26 | }); 27 | 28 | it("getName returns string", () => { 29 | expect(ReadThroughManager.getName()).toEqual(expect.any(String)); 30 | }); 31 | 32 | it("registers new expiration strategies given in options", () => { 33 | const mockLockedKeyRetrieveStrategy = { 34 | getName: (): string => "test", 35 | get: jest.fn().mockResolvedValue(true), 36 | }; 37 | const instance: any = new ReadThroughManager({ 38 | storage: new TestStorage(internalStorage), 39 | lockedKeyRetrieveStrategies: [["test", mockLockedKeyRetrieveStrategy]], 40 | logger, 41 | }); 42 | 43 | expect(instance.lockedKeyRetrieveStrategies.get("test")).toEqual(mockLockedKeyRetrieveStrategy); 44 | }); 45 | 46 | it("getLockedKeyRetrieveStrategy throws if cannot get strategy", () => { 47 | expect(() => (manager as any).getLockedKeyRetrieveStrategy("unknown")).toThrow(); 48 | }); 49 | 50 | it("get returns result from executor if key lock throws error", async () => { 51 | await manager.set("test", undefined); 52 | 53 | storage.lockKey = (): boolean => { 54 | throw new Error("connection error"); 55 | }; 56 | expect(await manager.get("test", () => "234")).toEqual("234"); 57 | }); 58 | 59 | it("get returns result if it exists", async () => { 60 | await manager.set("test", "123", { expiresIn: 100 }); 61 | 62 | expect( 63 | await manager.get("test", () => { 64 | /* empty */ 65 | }) 66 | ).toEqual("123"); 67 | }); 68 | 69 | it("get returns undefined if record.value is not defined", async () => { 70 | await manager.set("test", undefined, { expiresIn: 100 }); 71 | 72 | await expect( 73 | manager.get("test", () => { 74 | /* empty */ 75 | }) 76 | ).rejects.toThrowError("Executor should not return undefined. Correct value for emptiness is null."); 77 | }); 78 | 79 | it("get runs executor and updates key if it not exists", async () => { 80 | storage.get.mockResolvedValueOnce(null); 81 | expect(await manager.get("test", () => "234")).toEqual("234"); 82 | expect(await storage.get("test")).toMatchObject({ value: '"234"' }); 83 | }); 84 | 85 | it("get runs executor and updates key if storage record expired", async () => { 86 | const DATE = 1550082589777; 87 | const DATE_ADVANCED = 1550082599777; 88 | const realNow = Date.now; 89 | 90 | Date.now = jest.fn().mockReturnValue(DATE); 91 | 92 | const returnMock = { 93 | key: "test", 94 | value: JSON.stringify("234"), 95 | permanent: false, 96 | expiresIn: 100, 97 | createdAt: Date.now(), 98 | }; 99 | 100 | (Date.now as any).mockReturnValueOnce(DATE_ADVANCED); 101 | 102 | storage.get.mockResolvedValueOnce(returnMock); 103 | expect(await manager.get("test", () => "234")).toEqual("234"); 104 | expect(await storage.get("test")).toMatchObject({ key: "test", value: '"234"' }); 105 | Date.now = realNow; 106 | }); 107 | 108 | it("get runs executor and updates key if record tags outdated", async () => { 109 | storage.getTags.mockResolvedValueOnce([{ name: "tag1", version: 2 }]); 110 | storage.isOutdated.mockResolvedValueOnce(true); 111 | storage.get.mockResolvedValueOnce({ 112 | key: "test", 113 | value: JSON.stringify("234"), 114 | permanent: true, 115 | tags: [{ name: "tag1", version: 1 }], 116 | }); 117 | 118 | expect(await manager.get("test", () => "234")).toEqual("234"); 119 | expect(storage.storage).toEqual({ test: "234" }); 120 | }); 121 | 122 | it("get throws if executor throws", async () => { 123 | await expect( 124 | manager.get("test3", async () => { 125 | throw new Error("failed"); 126 | }) 127 | ).rejects.toThrow("failed"); 128 | }); 129 | 130 | it("get returns result from executor if adapter methods throws errors", async () => { 131 | (storage.getConnectionStatus as any).mockReturnValueOnce(ConnectionStatus.DISCONNECTED); 132 | 133 | const result = await manager.get("test3", async () => ({ test: 123 })); 134 | 135 | expect(result).toEqual({ test: 123 }); 136 | }); 137 | 138 | it("get returns result from executor if storage methods throws errors", async () => { 139 | const testStorage = new TestStorage(internalStorage); 140 | 141 | testStorage.get.mockImplementation(async () => { 142 | throw new Error("Operation timeout after 200"); 143 | }); 144 | 145 | const testManager: any = new ReadThroughManager({ 146 | storage: testStorage, 147 | logger, 148 | }); 149 | 150 | await expect(testManager.get("test", async () => ({ test: 123 }))).resolves.toEqual({ test: 123 }); 151 | }); 152 | 153 | it("del calls storage.del", async () => { 154 | await manager.del("test"); 155 | 156 | expect(storage.del).toBeCalledWith("test"); 157 | }); 158 | }); 159 | -------------------------------------------------------------------------------- /src/managers/ReadThroughManager.ts: -------------------------------------------------------------------------------- 1 | import { BaseManager, ManagerOptions } from "./BaseManager"; 2 | import { Executor, runExecutor } from "../Executor"; 3 | import { WriteOptions, ReadWriteOptions } from "../storage/Storage"; 4 | import { Record } from "../storage/Record"; 5 | import deserialize from "../deserialize"; 6 | 7 | class ReadThroughManager extends BaseManager { 8 | public static getName(): string { 9 | return "read-through"; 10 | } 11 | 12 | constructor(options: ManagerOptions) { 13 | super(options); 14 | } 15 | 16 | public async get(key: string, executor: Executor, options: ReadWriteOptions = {}): Promise { 17 | let record: Record | null = null; 18 | 19 | try { 20 | record = await this.storage.get(key); 21 | } catch (e) { 22 | this.logger.error("Failed to get value from storage, falling back to executor", e); 23 | 24 | return runExecutor(executor); 25 | } 26 | 27 | if (this.isRecordValid(record) && !(await this.storage.isOutdated(record))) { 28 | return deserialize(record.value); 29 | } 30 | 31 | const executorContext = { key, executor, options }; 32 | return this.updateCacheAndGetResult(executorContext, options); 33 | } 34 | 35 | public async set(key: string, value: R, options?: WriteOptions): Promise> { 36 | return this.storage.set(key, value, options); 37 | } 38 | 39 | private isRecordValid(record: Record | null | undefined): record is Record { 40 | const currentDate: number = Date.now(); 41 | 42 | if (!record) { 43 | return false; 44 | } 45 | 46 | const recordExpireDate = Number(record.createdAt + record.expiresIn) || 0; 47 | const isExpired = !record.permanent && currentDate > recordExpireDate; 48 | 49 | if (isExpired) { 50 | return false; 51 | } 52 | 53 | return record.value !== undefined; 54 | } 55 | } 56 | 57 | export default ReadThroughManager; 58 | -------------------------------------------------------------------------------- /src/managers/RefreshAheadManager.spec.ts: -------------------------------------------------------------------------------- 1 | import RefreshAheadManager from "./RefreshAheadManager"; 2 | import TestStorage from "../storage/__mocks__/TestStorage"; 3 | import { ConnectionStatus } from "../ConnectionStatus"; 4 | 5 | const logger = { 6 | info: jest.fn(), 7 | trace: jest.fn(), 8 | warn: jest.fn(), 9 | error: jest.fn(), 10 | }; 11 | let internalStorage = {}; 12 | let storage: TestStorage; 13 | let manager: RefreshAheadManager; 14 | 15 | describe("RefreshAheadManager", () => { 16 | beforeEach(() => { 17 | internalStorage = {}; 18 | storage = new TestStorage(internalStorage); 19 | manager = new RefreshAheadManager({ 20 | storage, 21 | prefix: "cache", 22 | hashKeys: false, 23 | expiresIn: 10000, 24 | logger, 25 | }); 26 | }); 27 | 28 | it("getName returns string", () => { 29 | expect(RefreshAheadManager.getName()).toEqual(expect.any(String)); 30 | }); 31 | 32 | it("constructor throws if Refresh-Ahead factor is less or equals zero", () => { 33 | expect( 34 | () => 35 | new RefreshAheadManager({ 36 | storage, 37 | prefix: "cache", 38 | hashKeys: false, 39 | expiresIn: 10000, 40 | logger, 41 | refreshAheadFactor: -1, 42 | }) 43 | ).toThrow(); 44 | }); 45 | 46 | it("constructor throws if Refresh-Ahead factor is more or equals zero", () => { 47 | expect( 48 | () => 49 | new RefreshAheadManager({ 50 | storage, 51 | prefix: "cache", 52 | hashKeys: false, 53 | expiresIn: 10000, 54 | logger, 55 | refreshAheadFactor: 1, 56 | }) 57 | ).toThrow(); 58 | }); 59 | 60 | it("registers new expiration strategies given in options", () => { 61 | const mockLockedKeyRetrieveStrategy = { 62 | getName: (): string => "test", 63 | get: jest.fn().mockResolvedValue(true), 64 | }; 65 | const instance: any = new RefreshAheadManager({ 66 | storage: new TestStorage(internalStorage), 67 | lockedKeyRetrieveStrategies: [["test", mockLockedKeyRetrieveStrategy]], 68 | logger, 69 | }); 70 | 71 | expect(instance.lockedKeyRetrieveStrategies.get("test")).toEqual(mockLockedKeyRetrieveStrategy); 72 | }); 73 | 74 | it("getLockedKeyRetrieveStrategy throws if cannot get strategy", () => { 75 | expect(() => (manager as any).getLockedKeyRetrieveStrategy("unknown")).toThrow(); 76 | }); 77 | 78 | it("get returns result from executor if key lock throws error", async () => { 79 | await manager.set("test", undefined); 80 | 81 | storage.lockKey = (): boolean => { 82 | throw new Error("connection error"); 83 | }; 84 | expect(await manager.get("test", () => "234")).toEqual("234"); 85 | }); 86 | 87 | it("get returns result if it exists", async () => { 88 | await manager.set("test", "123", { expiresIn: 100 }); 89 | 90 | expect( 91 | await manager.get("test", () => { 92 | /* empty */ 93 | }) 94 | ).toEqual("123"); 95 | }); 96 | 97 | it("get runs executor and updates key if it not exists", async () => { 98 | storage.get.mockResolvedValueOnce(null); 99 | expect(await manager.get("test", () => "234")).toEqual("234"); 100 | expect(await storage.get("test")).toMatchObject({ value: '"234"' }); 101 | }); 102 | 103 | it("get runs executor and updates key, uses default lockedKeyRetrieveStrategy get when key is locked", async () => { 104 | storage.get.mockResolvedValueOnce(null); 105 | storage.lockKey.mockReturnValueOnce(false); 106 | expect(await manager.get("test", () => "234")).toEqual("234"); 107 | expect(await storage.get("test")).toMatchObject({}); 108 | }); 109 | 110 | it("get runs executor and updates key if storage record expired", async () => { 111 | const DATE = 1550082589777; 112 | const DATE_ADVANCED = 1550082599777; 113 | const realNow = Date.now; 114 | 115 | Date.now = jest.fn().mockReturnValue(DATE); 116 | 117 | const returnMock = { 118 | key: "test", 119 | value: JSON.stringify("234"), 120 | permanent: false, 121 | expiresIn: 100, 122 | createdAt: Date.now(), 123 | }; 124 | 125 | (Date.now as any).mockReturnValueOnce(DATE_ADVANCED); 126 | 127 | storage.get.mockReturnValueOnce(returnMock); 128 | expect(await manager.get("test", () => "234")).toEqual("234"); 129 | expect(await storage.get("test")).toMatchObject({ key: "test", value: '"234"' }); 130 | Date.now = realNow; 131 | }); 132 | 133 | it("get refreshes record if storage record will expire soon", async () => 134 | new Promise( 135 | // eslint-disable-next-line no-async-promise-executor 136 | async (resolve): Promise => { 137 | const DATE = 1550082589000; 138 | const DATE_ADVANCED = 1550082589405; 139 | const realNow = Date.now; 140 | const instanceRefresh = (manager as any).refresh; 141 | 142 | (manager as any).refresh = async (...args: any[]): Promise => { 143 | await instanceRefresh.call(manager, ...args); 144 | expect(await storage.get("test")).toMatchObject({ key: "test", value: '"234"' }); 145 | resolve(undefined); 146 | }; 147 | Date.now = jest.fn().mockReturnValue(DATE); 148 | 149 | const returnMock = { 150 | key: "test", 151 | value: JSON.stringify("234"), 152 | permanent: false, 153 | expiresIn: 500, 154 | createdAt: Date.now(), 155 | }; 156 | 157 | (Date.now as any).mockReturnValue(DATE_ADVANCED); 158 | 159 | storage.get.mockReturnValueOnce(returnMock); 160 | expect(await manager.get("test", () => "234")).toEqual("234"); 161 | Date.now = realNow; 162 | } 163 | )); 164 | 165 | it("get refreshes record if storage record will expire soon and not throws if executor throws", async () => 166 | new Promise( 167 | // eslint-disable-next-line no-async-promise-executor 168 | async (resolve): Promise => { 169 | const DATE = 1550082589000; 170 | const DATE_ADVANCED = 1550082589405; 171 | const realNow = Date.now; 172 | const instanceRefresh = (manager as any).refresh; 173 | 174 | (manager as any).refresh = async (...args: any[]): Promise => { 175 | await instanceRefresh.call(manager, ...args); 176 | resolve(undefined); 177 | }; 178 | Date.now = jest.fn().mockReturnValue(DATE); 179 | 180 | const returnMock = { 181 | key: "test", 182 | value: JSON.stringify("234"), 183 | permanent: false, 184 | expiresIn: 500, 185 | createdAt: Date.now(), 186 | }; 187 | 188 | (Date.now as any).mockReturnValue(DATE_ADVANCED); 189 | 190 | storage.get.mockReturnValueOnce(returnMock); 191 | expect( 192 | await manager.get("test", () => { 193 | throw new Error("Operation timeout"); 194 | }) 195 | ).toEqual("234"); 196 | Date.now = realNow; 197 | } 198 | )); 199 | 200 | it("get not throws unhandled rejections if record is expire soon and refresh was called", async () => 201 | new Promise( 202 | // eslint-disable-next-line no-async-promise-executor 203 | async (resolve): Promise => { 204 | const DATE = 1550082589000; 205 | const DATE_ADVANCED = 1550082589405; 206 | const realNow = Date.now; 207 | const realCatch = Promise.prototype.catch; 208 | 209 | (manager as any).refresh = jest.fn().mockImplementation(async () => { 210 | throw new Error("Operation timeout"); 211 | }); 212 | Promise.prototype.catch = function (...args: any[]): any { 213 | resolve(undefined); 214 | 215 | return realCatch.call(this, ...args); 216 | }; 217 | Date.now = jest.fn().mockReturnValue(DATE); 218 | 219 | const returnMock = { 220 | key: "test", 221 | value: JSON.stringify("234"), 222 | permanent: false, 223 | expiresIn: 500, 224 | createdAt: Date.now(), 225 | }; 226 | 227 | (Date.now as any).mockReturnValue(DATE_ADVANCED); 228 | 229 | storage.get.mockReturnValueOnce(returnMock); 230 | expect(await manager.get("test", () => "234")).toEqual("234"); 231 | Date.now = realNow; 232 | } 233 | )); 234 | 235 | it("get throws if executor throws", async () => { 236 | await expect( 237 | manager.get("test3", async () => { 238 | throw new Error("failed"); 239 | }) 240 | ).rejects.toThrow("failed"); 241 | }); 242 | 243 | it("get returns result from executor if adapter methods throws errors", async () => { 244 | (storage.getConnectionStatus as any).mockReturnValueOnce(ConnectionStatus.DISCONNECTED); 245 | 246 | const result = await manager.get("test3", async () => ({ test: 123 })); 247 | 248 | expect(result).toEqual({ test: 123 }); 249 | }); 250 | 251 | it("get returns result from executor if storage methods throws errors", async () => { 252 | const testStorage = new TestStorage(internalStorage); 253 | 254 | testStorage.get.mockImplementation(async () => { 255 | throw new Error("Operation timeout after 200"); 256 | }); 257 | 258 | const testManager: any = new RefreshAheadManager({ 259 | storage: testStorage, 260 | prefix: "cache", 261 | hashKeys: false, 262 | expiresIn: 10000, 263 | logger, 264 | refreshAheadFactor: 0.5, 265 | }); 266 | 267 | await expect(testManager.get("test", async () => ({ test: 123 }))).resolves.toEqual({ test: 123 }); 268 | }); 269 | 270 | it("isRecordExpireSoon returns false if record is null", () => { 271 | expect((manager as any).isRecordExpireSoon(null)).toEqual(false); 272 | }); 273 | }); 274 | -------------------------------------------------------------------------------- /src/managers/RefreshAheadManager.ts: -------------------------------------------------------------------------------- 1 | import { BaseManager, ManagerOptions } from "./BaseManager"; 2 | import { Executor, ExecutorContext, runExecutor } from "../Executor"; 3 | import { WriteOptions, ReadWriteOptions } from "../storage/Storage"; 4 | import { Record } from "../storage/Record"; 5 | import deserialize from "../deserialize"; 6 | 7 | export const DEFAULT_REFRESH_AHEAD_FACTOR = 0.8; 8 | 9 | export interface RefreshAheadManagerOptions extends ManagerOptions { 10 | refreshAheadFactor?: number; 11 | } 12 | 13 | class RefreshAheadManager extends BaseManager { 14 | public static getName(): string { 15 | return "refresh-ahead"; 16 | } 17 | 18 | constructor(options: RefreshAheadManagerOptions) { 19 | super(options); 20 | 21 | this.refreshAheadFactor = options.refreshAheadFactor || DEFAULT_REFRESH_AHEAD_FACTOR; 22 | 23 | if (isFinite(Number(this.refreshAheadFactor))) { 24 | if (this.refreshAheadFactor <= 0) { 25 | throw new Error("Refresh-Ahead factor should be more than 0"); 26 | } 27 | 28 | if (this.refreshAheadFactor >= 1) { 29 | throw new Error("Refresh-Ahead factor should be under 1"); 30 | } 31 | } 32 | } 33 | 34 | private readonly refreshAheadFactor: number; 35 | 36 | public async get(key: string, executor: Executor, options: ReadWriteOptions = {}): Promise { 37 | let record: Record | null = null; 38 | 39 | try { 40 | record = await this.storage.get(key); 41 | } catch (e) { 42 | this.logger.error("Failed to get value from storage, falling back to executor", e); 43 | 44 | return runExecutor(executor); 45 | } 46 | 47 | const executorContext = { key, executor, options }; 48 | 49 | if (this.isRecordValid(record) && !(await this.storage.isOutdated(record))) { 50 | const result = deserialize(record.value); 51 | 52 | if (this.isRecordExpireSoon(record)) { 53 | this.refresh(key, executorContext, options).catch((err) => this.logger.error(err)); 54 | } 55 | 56 | return result; 57 | } 58 | 59 | return this.updateCacheAndGetResult(executorContext, options); 60 | } 61 | 62 | public async set(key: string, value: R, options?: WriteOptions): Promise> { 63 | return this.storage.set(key, value, options); 64 | } 65 | 66 | private isRecordValid(record: Record | null | void): record is Record { 67 | const currentDate: number = Date.now(); 68 | 69 | if (!record) { 70 | return false; 71 | } 72 | 73 | const recordExpireDate = Number(record.createdAt + record.expiresIn) || 0; 74 | const isExpired = !record.permanent && currentDate > recordExpireDate; 75 | 76 | if (isExpired) { 77 | return false; 78 | } 79 | 80 | return record.value !== undefined; 81 | } 82 | 83 | private isRecordExpireSoon(record: Record | null): boolean { 84 | const currentDate: number = Date.now(); 85 | 86 | if (!record) { 87 | return false; 88 | } 89 | 90 | const recordExpireDate = Number(record.createdAt + record.expiresIn * this.refreshAheadFactor) || 0; 91 | 92 | return !record.permanent && currentDate > recordExpireDate; 93 | } 94 | 95 | private async refresh( 96 | key: string, 97 | context: ExecutorContext, 98 | options: WriteOptions 99 | ): Promise { 100 | const refreshAheadKey = `refreshAhead:${key}`; 101 | const isExecutorLockSuccessful = await this.storage.lockKey(refreshAheadKey); 102 | 103 | if (isExecutorLockSuccessful) { 104 | try { 105 | this.logger.trace(`refresh "${key}"`); 106 | 107 | const executorResult = await runExecutor(context.executor); 108 | 109 | await this.storage.set(key, executorResult, options); 110 | } catch (e) { 111 | this.logger.error(e); 112 | } finally { 113 | await this.storage.releaseKey(refreshAheadKey); 114 | } 115 | } 116 | } 117 | } 118 | 119 | export default RefreshAheadManager; 120 | -------------------------------------------------------------------------------- /src/managers/WriteThroughManager.spec.ts: -------------------------------------------------------------------------------- 1 | import WriteThroughManager from "./WriteThroughManager"; 2 | import TestStorage from "../storage/__mocks__/TestStorage"; 3 | import { ConnectionStatus } from "../ConnectionStatus"; 4 | 5 | const logger = { 6 | info: jest.fn(), 7 | trace: jest.fn(), 8 | warn: jest.fn(), 9 | error: jest.fn(), 10 | }; 11 | let internalStorage = {}; 12 | let storage: TestStorage; 13 | let manager: WriteThroughManager; 14 | 15 | describe("WriteThroughManager", () => { 16 | beforeEach(() => { 17 | internalStorage = {}; 18 | storage = new TestStorage(internalStorage); 19 | manager = new WriteThroughManager({ 20 | storage, 21 | prefix: "cache", 22 | hashKeys: false, 23 | expiresIn: 10000, 24 | logger, 25 | }); 26 | }); 27 | 28 | it("getName returns string", () => { 29 | expect(WriteThroughManager.getName()).toEqual(expect.any(String)); 30 | }); 31 | 32 | it("registers new expiration strategies given in options", () => { 33 | const mockLockedKeyRetrieveStrategy = { 34 | getName: (): string => "test", 35 | get: jest.fn().mockResolvedValue(true), 36 | }; 37 | const instance: any = new WriteThroughManager({ 38 | storage: new TestStorage(internalStorage), 39 | lockedKeyRetrieveStrategies: [["test", mockLockedKeyRetrieveStrategy]], 40 | logger, 41 | }); 42 | 43 | expect(instance.lockedKeyRetrieveStrategies.get("test")).toEqual(mockLockedKeyRetrieveStrategy); 44 | }); 45 | 46 | it("getLockedKeyRetrieveStrategy throws if cannot get strategy", () => { 47 | expect(() => (manager as any).getLockedKeyRetrieveStrategy("unknown")).toThrow(); 48 | }); 49 | 50 | it("get returns result from executor if key lock throws error", async () => { 51 | await manager.set("test", undefined); 52 | 53 | storage.lockKey = (): boolean => { 54 | throw new Error("connection error"); 55 | }; 56 | expect(await manager.get("test", () => "234")).toEqual("234"); 57 | }); 58 | 59 | it("get returns result if it exists", async () => { 60 | await manager.set("test", "123", { expiresIn: 100 }); 61 | 62 | expect( 63 | await manager.get("test", () => { 64 | /* empty */ 65 | }) 66 | ).toEqual("123"); 67 | }); 68 | 69 | it("get runs executor and updates key if it not exists", async () => { 70 | storage.get.mockResolvedValueOnce(null); 71 | expect(await manager.get("test", () => "234")).toEqual("234"); 72 | expect(await storage.get("test")).toMatchObject({ value: '"234"' }); 73 | }); 74 | 75 | it("get return value even if key is expired", async () => { 76 | const DATE = 1550082589777; 77 | const DATE_ADVANCED = 1550082599777; 78 | const realNow = Date.now; 79 | 80 | Date.now = jest.fn().mockReturnValue(DATE); 81 | 82 | const returnMock = { 83 | key: "test", 84 | value: JSON.stringify("234"), 85 | permanent: false, 86 | expiresIn: 100, 87 | createdAt: Date.now(), 88 | }; 89 | 90 | (Date.now as any).mockReturnValueOnce(DATE_ADVANCED); 91 | 92 | storage.get.mockResolvedValueOnce(returnMock); 93 | expect(await manager.get("test", () => "234")).toEqual("234"); 94 | Date.now = realNow; 95 | }); 96 | 97 | it("get don't run executor when tags outdated, and returns old result", async () => { 98 | storage.getTags.mockResolvedValueOnce([{ name: "tag1", version: 2 }]); 99 | storage.get.mockResolvedValueOnce({ 100 | key: "test", 101 | value: JSON.stringify("234"), 102 | permanent: true, 103 | tags: [{ name: "tag1", version: 1 }], 104 | }); 105 | 106 | expect(await manager.get("test", () => "234")).toEqual("234"); 107 | expect(storage.storage).toEqual({}); 108 | }); 109 | 110 | it("get throws if executor throws", async () => { 111 | await expect( 112 | manager.get("test3", async () => { 113 | throw new Error("failed"); 114 | }) 115 | ).rejects.toThrow("failed"); 116 | }); 117 | 118 | it("get returns result from executor if adapter methods throws errors", async () => { 119 | (storage.getConnectionStatus as any).mockReturnValueOnce(ConnectionStatus.DISCONNECTED); 120 | 121 | const result = await manager.get("test3", async () => ({ test: 123 })); 122 | 123 | expect(result).toEqual({ test: 123 }); 124 | }); 125 | 126 | it("get returns result from executor if storage methods throws errors", async () => { 127 | const testStorage = new TestStorage(internalStorage); 128 | 129 | testStorage.get.mockImplementation(async () => { 130 | throw new Error("Operation timeout after 200"); 131 | }); 132 | 133 | const testManager: any = new WriteThroughManager({ 134 | storage: testStorage, 135 | prefix: "cache", 136 | hashKeys: false, 137 | expiresIn: 10000, 138 | logger, 139 | }); 140 | 141 | await expect(testManager.get("test", async () => ({ test: 123 }))).resolves.toEqual({ test: 123 }); 142 | }); 143 | }); 144 | -------------------------------------------------------------------------------- /src/managers/WriteThroughManager.ts: -------------------------------------------------------------------------------- 1 | import { BaseManager, ManagerOptions } from "./BaseManager"; 2 | import { Executor, runExecutor } from "../Executor"; 3 | import { ReadWriteOptions, WriteOptions } from "../storage/Storage"; 4 | import { Record } from "../storage/Record"; 5 | import deserialize from "../deserialize"; 6 | 7 | class WriteThroughManager extends BaseManager { 8 | public static getName(): string { 9 | return "write-through"; 10 | } 11 | 12 | constructor(options: ManagerOptions) { 13 | super(options); 14 | } 15 | 16 | public async get(key: string, executor: Executor, options: ReadWriteOptions = {}): Promise { 17 | let record: Record | null = null; 18 | 19 | try { 20 | record = await this.storage.get(key); 21 | } catch (e) { 22 | this.logger.error("Failed to get value from storage, falling back to executor", e); 23 | 24 | return runExecutor(executor); 25 | } 26 | 27 | if (this.isRecordValid(record)) { 28 | this.logger.trace("hit", key); 29 | 30 | return deserialize(record.value); 31 | } 32 | 33 | this.logger.trace("miss", key); 34 | 35 | const executorContext = { key, executor, options }; 36 | return this.updateCacheAndGetResult(executorContext, options); 37 | } 38 | 39 | public async set(key: string, value: R, options?: WriteOptions): Promise> { 40 | return this.storage.set(key, value, { ...options, permanent: true }); 41 | } 42 | 43 | private isRecordValid(record: Record | null | void): record is Record { 44 | if (!record) { 45 | return false; 46 | } 47 | 48 | return record.value !== undefined; 49 | } 50 | } 51 | 52 | export default WriteThroughManager; 53 | -------------------------------------------------------------------------------- /src/managers/__mocks__/RefreshAheadManager.ts: -------------------------------------------------------------------------------- 1 | export default class RefreshAheadManager { 2 | static getName = (): string => "refresh-ahead"; 3 | get: any = jest.fn(); 4 | set: any = jest.fn(); 5 | } 6 | -------------------------------------------------------------------------------- /src/storage/BaseStorage.spec.ts: -------------------------------------------------------------------------------- 1 | import TestStorageAdapter from "../adapters/TestStorageAdapter"; 2 | import { ConnectionStatus } from "../ConnectionStatus"; 3 | import { operationTimeoutError } from "../errors/errors"; 4 | import timeout from "../timeout"; 5 | import { BaseStorage, TAGS_VERSIONS_ALIAS } from "./BaseStorage"; 6 | import { Record } from "./Record"; 7 | 8 | interface TestStorage { 9 | [key: string]: string; 10 | } 11 | 12 | let testStorage: TestStorage; 13 | let testAdapter: TestStorageAdapter; 14 | let storage: BaseStorage; 15 | 16 | describe("BaseStorage", () => { 17 | beforeEach(() => { 18 | testStorage = {}; 19 | testAdapter = new TestStorageAdapter(testStorage, true); 20 | storage = new BaseStorage({ 21 | adapter: testAdapter, 22 | prefix: "cache", 23 | hashKeys: false, 24 | expiresIn: 10000, 25 | }); 26 | }); 27 | 28 | it("set creates record without value and tags if value === undefined", async () => { 29 | await storage.set("test", undefined, { tags: ["tag"] }); 30 | 31 | const value = JSON.parse(testStorage["cache-test"]); 32 | 33 | expect(value).toMatchObject({ 34 | key: "test", 35 | permanent: true, 36 | }); 37 | expect(value.tags).toHaveLength(0); 38 | }); 39 | 40 | it("default prefix is empty string", async () => { 41 | storage = new BaseStorage({ 42 | adapter: testAdapter, 43 | hashKeys: false, 44 | expiresIn: 10000, 45 | }); 46 | 47 | await storage.set("test", "123"); 48 | 49 | expect((testStorage as any).test).toEqual(expect.any(String)); 50 | }); 51 | 52 | it("creates hashed by md5 keys if hashKeys set to true", async () => { 53 | storage = new BaseStorage({ 54 | adapter: testAdapter, 55 | hashKeys: true, 56 | expiresIn: 10000, 57 | }); 58 | 59 | await storage.set("test", "123"); 60 | 61 | expect(testStorage as any).toMatchObject({ 62 | "098f6bcd4621d373cade4e832627b4f6": expect.any(String), 63 | }); 64 | }); 65 | 66 | it("setOptions sets options to adapter", () => { 67 | const options = { 68 | adapter: testAdapter, 69 | prefix: "cache", 70 | hashKeys: false, 71 | expiresIn: 10000, 72 | }; 73 | 74 | testAdapter.setOptions = jest.fn(); 75 | storage = new BaseStorage(options); 76 | expect(testAdapter.setOptions).toBeCalledWith(options); 77 | }); 78 | 79 | it("set sets key to storage adapter", async () => { 80 | await storage.set("test", "123"); 81 | 82 | const value = JSON.parse(testStorage["cache-test"]); 83 | 84 | expect(value).toMatchObject({ 85 | key: "test", 86 | permanent: true, 87 | value: '"123"', 88 | }); 89 | expect(value.tags).toEqual([]); 90 | expect(value.expiresIn).toEqual(expect.any(Number)); 91 | }); 92 | 93 | it("set sets key to storage adapter with dynamic tags", async () => { 94 | await storage.set("test", "123", { getTags: (result) => [result] }); 95 | 96 | const value = JSON.parse(testStorage["cache-test"]); 97 | 98 | expect(value).toMatchObject({ 99 | key: "test", 100 | permanent: true, 101 | value: '"123"', 102 | }); 103 | expect(value.tags).toMatchObject([{ name: "123" }]); 104 | expect(value.expiresIn).toEqual(expect.any(Number)); 105 | }); 106 | 107 | it("set sets key to storage adapter with uniq array of concatenated dynamic tags and simple tags", async () => { 108 | await storage.set("test", "123", { tags: ["tag1", "123"], getTags: (result) => [result] }); 109 | 110 | const value = JSON.parse(testStorage["cache-test"]); 111 | 112 | expect(value).toMatchObject({ 113 | key: "test", 114 | permanent: true, 115 | value: '"123"', 116 | }); 117 | expect(value.tags).toMatchObject([{ name: "tag1" }, { name: "123" }]); 118 | expect(value.expiresIn).toEqual(expect.any(Number)); 119 | }); 120 | 121 | it("set throws if dynamic tags Fn returns non-array value", async () => { 122 | await expect(storage.set("test", "123", { getTags: () => false } as any)).rejects.toThrow(); 123 | }); 124 | 125 | it("set sets object key to storage adapter with dynamic tags", async () => { 126 | await storage.set("test", { id: "uuid" }, { getTags: ({ id }) => [id] }); 127 | 128 | const value = JSON.parse(testStorage["cache-test"]); 129 | 130 | expect(value).toMatchObject({ 131 | key: "test", 132 | permanent: true, 133 | value: '{"id":"uuid"}', 134 | }); 135 | expect(value.tags).toMatchObject([{ name: "uuid" }]); 136 | expect(value.expiresIn).toEqual(expect.any(Number)); 137 | }); 138 | 139 | it("set sets key to storage adapter with given options", async () => { 140 | await storage.set("test", "123", { expiresIn: 0 }); 141 | 142 | const value = JSON.parse(testStorage["cache-test"]); 143 | 144 | expect(value).toMatchObject({ 145 | key: "test", 146 | permanent: true, 147 | value: '"123"', 148 | }); 149 | expect(value.tags).toEqual([]); 150 | expect(value.expiresIn).toEqual(expect.any(Number)); 151 | }); 152 | 153 | it("get returns value from adapter", async () => { 154 | await storage.set("test", "123", { expiresIn: 0 }); 155 | expect(await storage.get("test")).toEqual({ 156 | createdAt: expect.any(Number), 157 | expiresIn: 0, 158 | key: "test", 159 | permanent: true, 160 | tags: [], 161 | value: '"123"', 162 | }); 163 | }); 164 | 165 | it("touch updates cache tags", async () => { 166 | await storage.set("test", "123", { expiresIn: 0, tags: ["sometag"] }); 167 | 168 | const TIMEOUT = 10; 169 | const tagsBefore = testStorage["cache-cache-tags-versions:sometag"]; 170 | 171 | await timeout(TIMEOUT); 172 | await storage.touch(["sometag"]); 173 | 174 | expect(testStorage["cache-cache-tags-versions:sometag"]).not.toEqual(tagsBefore); 175 | }); 176 | 177 | it("touch does nothing if tag list is empty", async () => { 178 | await storage.set("test", "123", { expiresIn: 0, tags: ["sometag"] }); 179 | 180 | const TIMEOUT = 10; 181 | const tagsBefore = testStorage["cache-cache-tags-versions:sometag"]; 182 | 183 | await timeout(TIMEOUT); 184 | await storage.touch([]); 185 | 186 | expect(testStorage["cache-cache-tags-versions:sometag"]).toEqual(tagsBefore); 187 | }); 188 | 189 | it("getLockedKeyRetrieveStrategy throws if cannot get strategy", () => { 190 | expect(() => (storage as any).getLockedKeyRetrieveStrategy("unknown")).toThrow(); 191 | }); 192 | 193 | it("get returns result if value exists in storage", async () => { 194 | await storage.set("test", "123", { expiresIn: 100 }); 195 | 196 | expect(await storage.get("test")).toMatchObject({ value: '"123"' }); 197 | }); 198 | 199 | it("get returns null if value not exists in storage", async () => { 200 | expect(await storage.get("test")).toBeNull(); 201 | }); 202 | 203 | it("get throws if storage returns invalid record", async () => { 204 | (testStorage as any)["cache-test"] = `{"a":1}`; 205 | 206 | expect(await storage.get("test")).toBeNull(); 207 | }); 208 | 209 | it("del deletes key from storage", async () => { 210 | await storage.set("test", "123", { expiresIn: 500 }); 211 | await storage.set("test1", "1234", { expiresIn: 500 }); 212 | await storage.set("test2", "1234", { expiresIn: 500 }); 213 | 214 | expect(await storage.get("test")).toMatchObject({ value: '"123"' }); 215 | await storage.del("test"); 216 | await expect(storage.get("test")).resolves.toBeNull(); 217 | }); 218 | 219 | it("getTags returns actual tag versions", async () => { 220 | const tag1 = { name: "tag1", version: 1537176259547 }; 221 | const tag2 = { name: "tag2", version: 1537176259572 }; 222 | const tag3 = { name: "tag3", version: 1537176259922 }; 223 | 224 | testStorage[`cache-${TAGS_VERSIONS_ALIAS}:${tag1.name}`] = tag1.version.toString(); 225 | testStorage[`cache-${TAGS_VERSIONS_ALIAS}:${tag2.name}`] = tag2.version.toString(); 226 | testStorage[`cache-${TAGS_VERSIONS_ALIAS}:${tag3.name}`] = tag3.version.toString(); 227 | 228 | expect(await storage.getTags(["tag2", "tag3"])).toEqual([tag2, tag3]); 229 | expect(await storage.getTags(["tag1", "tag3"])).toEqual([tag1, tag3]); 230 | expect(await storage.getTags(["tag3", "tag2"])).toEqual([tag3, tag2]); 231 | }); 232 | 233 | it("getTags creates tag with zero version if it not exists", async () => { 234 | const tag1 = { name: "tag1", version: 1537176259547 }; 235 | 236 | testStorage[`cache-${TAGS_VERSIONS_ALIAS}:${tag1.name}`] = tag1.version.toString(); 237 | 238 | expect(await storage.getTags(["tag1", "tag3"])).toEqual([ 239 | tag1, 240 | { 241 | name: "tag3", 242 | version: 0, 243 | }, 244 | ]); 245 | }); 246 | 247 | it("getTags does nothing if tag list is empty", async () => { 248 | const tag1 = { name: "tag1", version: 1537176259547 }; 249 | 250 | testStorage[`cache-${TAGS_VERSIONS_ALIAS}:${tag1.name}`] = tag1.version.toString(); 251 | 252 | expect(await (storage as any).getTags([])).toEqual([]); 253 | }); 254 | 255 | it("lockKey returns true if lock exists", async () => { 256 | await storage.set("test", "123", { expiresIn: 0 }); 257 | 258 | expect(await (storage as any).lockKey("test")).toEqual(true); 259 | expect(testStorage["cache-test_lock"]).toEqual(""); 260 | }); 261 | 262 | it("lockKey returns false if lock exists", async () => { 263 | await storage.set("test", "123", { expiresIn: 0 }); 264 | 265 | testAdapter.acquireLock = async () => false; 266 | 267 | expect(await (storage as any).lockKey("test")).toEqual(false); 268 | expect(testStorage["cache-test_lock"]).toEqual(undefined); 269 | }); 270 | 271 | it("releaseLock releases lock", async () => { 272 | (testStorage as any)["cache-test_lock"] = '{"key":"cache-test_lock"}'; 273 | 274 | expect(await storage.get("test_lock")).toMatchObject({ key: "cache-test_lock" }); 275 | await storage.releaseKey("test"); 276 | expect(await storage.get("test_lock")).toBeNull(); 277 | }); 278 | 279 | it("keyIsLocked returns true if lock exists", async () => { 280 | (testStorage as any)["cache-test_lock"] = '{"key":"cache-test_lock"}'; 281 | expect(await storage.keyIsLocked("test")).toEqual(true); 282 | }); 283 | 284 | it("cachedCommand throws if function is undefined", async () => { 285 | await expect((storage as any).cachedCommand(undefined, 1, "hello")).rejects.toThrow(); 286 | }); 287 | 288 | it("getConnectionStatus returns current connection status", () => { 289 | expect(storage.getConnectionStatus()).toEqual(ConnectionStatus.CONNECTED); 290 | }); 291 | 292 | it("cachedCommand pushes command to command queue if status is not CONNECTED", async () => { 293 | const command = jest.fn(); 294 | testAdapter.getConnectionStatus = (): ConnectionStatus => ConnectionStatus.DISCONNECTED; 295 | 296 | await expect((storage as any).cachedCommand(command, 1, "hello")).resolves.toEqual(undefined); 297 | expect((storage as any).commandsQueue).toEqual([{ fn: command, params: [1, "hello"] }]); 298 | }); 299 | 300 | it("cachedCommand pushes command to command queue if execution timed out", async () => { 301 | const error = operationTimeoutError(1); 302 | const command = jest.fn().mockRejectedValue(error); 303 | 304 | await expect((storage as any).cachedCommand(command, 1, "hello")).resolves.toEqual(undefined); 305 | expect((storage as any).commandsQueue).toEqual([{ fn: command, params: [1, "hello"] }]); 306 | }); 307 | 308 | it("cachedCommand throws if command execution fails and not timed out", async () => { 309 | const error = new Error(); 310 | const command = jest.fn().mockRejectedValue(error); 311 | 312 | await expect((storage as any).cachedCommand(command, 1, "hello")).rejects.toThrowError(error); 313 | expect((storage as any).commandsQueue.length).toEqual(0); 314 | }); 315 | 316 | it("executeCommandsFromQueue does nothing if queue is empty", async () => { 317 | await expect((storage as any).executeCommandsFromQueue()).resolves.not.toThrow(); 318 | }); 319 | 320 | it("executeCommandsFromQueue executes commands and saves unsuccessfull commands to queue", async () => { 321 | const command1 = async (a: number): Promise => a; 322 | const command2 = async (): Promise => { 323 | throw new Error("error!"); 324 | }; 325 | const command3 = async (a: number, b: number): Promise => a + b; 326 | (storage as any).commandsQueue = [ 327 | { 328 | fn: command1, 329 | params: [1], 330 | }, 331 | { 332 | fn: command2, 333 | params: [1, 1], 334 | }, 335 | { 336 | fn: command3, 337 | params: [1, 1], 338 | }, 339 | ]; 340 | await expect((storage as any).executeCommandsFromQueue()).resolves.not.toThrow(); 341 | expect((storage as any).commandsQueue).toEqual([ 342 | { 343 | fn: command2, 344 | params: [1, 1], 345 | }, 346 | ]); 347 | }); 348 | 349 | it("set creates record with static tags calculated by function", async () => { 350 | await storage.set("test", "test", { tags: () => ["tag"] }); 351 | 352 | const value = JSON.parse(testStorage["cache-test"]); 353 | 354 | expect(value).toMatchObject({ 355 | key: "test", 356 | permanent: true, 357 | value: '"test"', 358 | }); 359 | 360 | expect(value.tags).toMatchObject([{ name: "tag" }]); 361 | expect(value.expiresIn).toEqual(expect.any(Number)); 362 | }); 363 | 364 | it("uses separate adapter for tags", async () => { 365 | const tag1 = { name: "tag1", version: 1 }; 366 | const tagsTestStorage: TestStorage = {}; 367 | const tagsTestAdapter = new TestStorageAdapter(tagsTestStorage, true); 368 | tagsTestStorage[`cache-${TAGS_VERSIONS_ALIAS}:tag1`] = tag1.version.toString(); 369 | storage = new BaseStorage({ 370 | adapter: testAdapter, 371 | tagsAdapter: tagsTestAdapter, 372 | prefix: "cache", 373 | hashKeys: false, 374 | expiresIn: 10000, 375 | }); 376 | 377 | const tags = await storage.getTags([tag1.name]); 378 | expect(tags).toEqual([tag1]); 379 | 380 | const tagV2 = { ...tag1, version: 2 }; 381 | await storage.setTagVersions([tagV2]); 382 | await expect(storage.getTags([tag1.name])).resolves.toEqual([tagV2]); 383 | }); 384 | 385 | it("isOutdated returns true if cannot get tags", async () => { 386 | storage.getTags = jest.fn().mockImplementationOnce(() => { 387 | throw new Error("Operation timeout"); 388 | }); 389 | 390 | const record = new Record("test", "value", [{ name: "tag1", version: 1 }]); 391 | await expect(storage.isOutdated(record)).resolves.toEqual(true); 392 | }); 393 | 394 | it("isOutdated returns true if tags outdated", async () => { 395 | storage.getTags = jest.fn().mockResolvedValueOnce([ 396 | { 397 | name: "tag1", 398 | version: 2, 399 | }, 400 | ]); 401 | 402 | const record = new Record("test", "value", [{ name: "tag1", version: 1 }]); 403 | await expect(storage.isOutdated(record)).resolves.toEqual(true); 404 | }); 405 | 406 | it("isOutdated returns false if tags not outdated", async () => { 407 | storage.getTags = jest.fn().mockResolvedValue([ 408 | { 409 | name: "tag1", 410 | version: 2, 411 | }, 412 | ]); 413 | 414 | const record = new Record("test", "value", [{ name: "tag1", version: 3 }]); 415 | await expect(storage.isOutdated(record)).resolves.toEqual(false); 416 | const record2 = new Record("test2", "value2", [{ name: "tag1", version: 2 }]); 417 | await expect(storage.isOutdated(record2)).resolves.toEqual(false); 418 | }); 419 | 420 | it("isOutdated returns false no tags present in record", async () => { 421 | storage.getTags = jest.fn().mockResolvedValue([ 422 | { 423 | name: "tag1", 424 | version: 2, 425 | }, 426 | ]); 427 | 428 | const record = new Record("test", "value", []); 429 | await expect(storage.isOutdated(record)).resolves.toEqual(false); 430 | }); 431 | 432 | it("isOutdated returns false if no actual tags", async () => { 433 | storage.getTags = jest.fn().mockResolvedValue([]); 434 | 435 | const record = new Record("test", "value", [{ name: "tag1", version: 1 }]); 436 | await expect(storage.isOutdated(record)).resolves.toEqual(false); 437 | }); 438 | 439 | it("isOutdated returns true if there is new actual tag", async () => { 440 | storage.getTags = jest.fn().mockResolvedValue([]); 441 | 442 | const record = new Record("test", "value", [{ name: "tag1", version: 1 }]); 443 | await expect(storage.isOutdated(record)).resolves.toEqual(false); 444 | }); 445 | }); 446 | -------------------------------------------------------------------------------- /src/storage/BaseStorage.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from "crypto"; 2 | import { ConnectionStatus } from "../ConnectionStatus"; 3 | import deserialize from "../deserialize"; 4 | import { isOperationTimeoutError } from "../errors/errors"; 5 | import { Storage, Tag, WriteOptions } from "./Storage"; 6 | import { StorageAdapter } from "../StorageAdapter"; 7 | import { Record } from "./Record"; 8 | 9 | export const TAGS_VERSIONS_ALIAS = "cache-tags-versions"; 10 | 11 | function uniq(arr: T[]): T[] { 12 | return [...new Set(arr)]; 13 | } 14 | 15 | function tagVersionByName(tags: Tag[]): { [key: string]: number } { 16 | return tags.reduce<{ [key: string]: number }>((acc, tag) => { 17 | acc[tag.name] = tag.version; 18 | return acc; 19 | }, {}); 20 | } 21 | 22 | export type BaseStorageOptions = { 23 | adapter: StorageAdapter; 24 | tagsAdapter?: StorageAdapter; 25 | prefix?: string; 26 | hashKeys?: boolean; 27 | expiresIn?: number; 28 | }; 29 | 30 | /** 31 | * Command is used as item of offline queue, which is used when adapter status becomes offline. 32 | * When adapter status becomes online, Storage flushes the queue and executes this commands. 33 | * Commands which was not executed successfully will be re-queued. 34 | */ 35 | export interface Command { 36 | fn: CommandFn; 37 | params: unknown[]; 38 | } 39 | 40 | /** 41 | * CommandFn is a function (usually a Storage method bind to its context) which is stored in 42 | * Command object for further execution. 43 | */ 44 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 45 | export type CommandFn = (...args: any[]) => unknown; 46 | 47 | /** 48 | * BaseStorage is the default Storage implementation for Manager. 49 | * 50 | * It provides functionality of get, set, touching tags, locking keys etc. 51 | * It also manages command execution, supporting an "offline" queue of commands. 52 | */ 53 | export class BaseStorage implements Storage { 54 | constructor(options: BaseStorageOptions) { 55 | this.adapter = options.adapter; 56 | this.tagsAdapter = options.tagsAdapter || options.adapter; 57 | this.prefix = options.prefix || ""; 58 | this.hashKeys = options.hashKeys || false; 59 | 60 | this.adapter.setOptions?.(options); 61 | 62 | this.adapter.onConnect(async () => this.executeCommandsFromQueue()); 63 | 64 | this.setTagVersions = this.setTagVersions.bind(this); 65 | } 66 | 67 | /** 68 | * An offline commands queue. 69 | */ 70 | private commandsQueue: Command[] = []; 71 | 72 | /** 73 | * The prefix is used to prevent conflicts in the key names of different BaseStorage instances. 74 | */ 75 | private readonly prefix: string; 76 | 77 | /** 78 | * Does it affect whether the keys are kept "as is" or they will be hashed. Hashing can be useful 79 | * for partitioning some databases. 80 | */ 81 | private readonly hashKeys: boolean; 82 | 83 | /** 84 | * The adapter is an layer between the underlying storage and the BaseStorage class. 85 | * Implements the StorageAdapter interface. 86 | */ 87 | private readonly adapter: StorageAdapter; 88 | 89 | /** 90 | * Adapter for tags should be provided if your primary adapter uses eviction policy. 91 | * This adapter should not use any eviction policy. Records should be deleted only by demand or expiration. 92 | */ 93 | private readonly tagsAdapter: StorageAdapter; 94 | 95 | /** 96 | * Gets a record using an adapter. It is expected that the adapter returns or null (value not found) 97 | * or serialized Record. 98 | */ 99 | public async get(key: string): Promise | null> { 100 | const value = await this.adapter.get(this.createKey(key)); 101 | if (value == null) { 102 | return null; 103 | } 104 | 105 | const record = deserialize>(value); 106 | 107 | if (!Record.isRecord(record)) { 108 | return null; 109 | } 110 | 111 | return record; 112 | } 113 | 114 | /** 115 | * Creates new set of tag records and updates them. 116 | */ 117 | public async setTagVersions(tags: Tag[]): Promise { 118 | const values = new Map(tags.map((tag) => [this.createTagKey(tag.name), `${tag.version}`])); 119 | return this.tagsAdapter.mset(values); 120 | } 121 | 122 | /** 123 | * Invalidates tags given as array of strings. 124 | */ 125 | public async touch(tags: string[]): Promise { 126 | if (tags.length > 0) { 127 | await this.cachedCommand(this.setTagVersions.bind(this), tags.map(this.createTag)); 128 | } 129 | } 130 | 131 | /** 132 | * Causes the adapter to acquireLock and resolves to true if the adapter responds that the lock is successful. 133 | */ 134 | public async lockKey(key: string): Promise { 135 | return this.adapter.acquireLock(this.createKey(key)); 136 | } 137 | 138 | /** 139 | * Releases the lock of key. 140 | */ 141 | public async releaseKey(key: string): Promise { 142 | return this.adapter.releaseLock(this.createKey(key)); 143 | } 144 | 145 | /** 146 | * Checks if key is locked and returns true/false. 147 | */ 148 | public async keyIsLocked(key: string): Promise { 149 | return this.adapter.isLockExists(this.createKey(key)); 150 | } 151 | 152 | /** 153 | * Deletes record by key. 154 | */ 155 | public async del(key: string): Promise { 156 | return this.adapter.del(this.createKey(key)); 157 | } 158 | 159 | /** 160 | * Retrieves actual versions of tags from storage. Tags which was not found in storage will be created with 0 161 | * version. 162 | */ 163 | public async getTags(tagNames: string[]): Promise { 164 | if (tagNames.length === 0) { 165 | return []; 166 | } 167 | 168 | const existingTags = await this.tagsAdapter.mget(tagNames.map((tagName) => this.createTagKey(tagName))); 169 | 170 | return tagNames.map((tagName, index) => ({ 171 | name: tagName, 172 | version: Number(existingTags[index]) || 0, 173 | })); 174 | } 175 | 176 | /** 177 | * set creates new record with provided options and sets it to storage using the adapter. 178 | */ 179 | public async set(key: string, value: R, options: WriteOptions = {}): Promise> { 180 | let tags: string[] = []; 181 | 182 | if (Array.isArray(options.tags)) { 183 | tags = options.tags; 184 | } else if (options.tags !== undefined) { 185 | tags = options.tags(); 186 | } 187 | 188 | const dynamicTags = options.getTags?.(value) ?? []; 189 | 190 | if (!Array.isArray(dynamicTags)) { 191 | throw new TypeError(`getTags should return an array of strings, got ${typeof dynamicTags}`); 192 | } 193 | 194 | const record = new Record(key, value, uniq(tags.concat(dynamicTags)).map(this.createTag), options); 195 | 196 | await this.adapter.set( 197 | this.createKey(key), 198 | JSON.stringify({ ...record, value: JSON.stringify(record.value) }), 199 | record.expiresIn 200 | ); 201 | 202 | return record; 203 | } 204 | 205 | /** 206 | * Checks if record is outdated by tags 207 | * 208 | * @param record 209 | */ 210 | public async isOutdated(record: Record): Promise { 211 | if (record.tags && record.tags.length) { 212 | let actualTags: Tag[] = []; 213 | 214 | try { 215 | actualTags = await this.getTags(record.tags.map((tag) => tag.name)); 216 | } catch (err) { 217 | return true; 218 | } 219 | 220 | // if no tag touched, record is not outdated 221 | if (actualTags.length === 0) { 222 | return false; 223 | } 224 | 225 | const recordTags = tagVersionByName(record.tags); 226 | 227 | // at least one actualTag should have greater version 228 | return actualTags.some((actualTag) => actualTag.version > recordTags[actualTag.name]); 229 | } 230 | 231 | return false; 232 | } 233 | 234 | /** 235 | * Returns current connection status of storage. 236 | */ 237 | public getConnectionStatus(): ConnectionStatus { 238 | return this.adapter.getConnectionStatus(); 239 | } 240 | 241 | /** 242 | * Depending on the option, [hashKeys] generates an MD5 hash "key" for the storage or gives it "as is". 243 | * The key is generated from the "prefix" of the storage defined at the time of the creation of 244 | * the BaseStorage instance and identifier passed in the key parameter string. 245 | */ 246 | private createKey(key: string): string { 247 | const rawKey = this.prefix ? `${this.prefix}-${key}` : key; 248 | 249 | return this.hashKeys ? createHash("md5").update(rawKey).digest("hex") : rawKey; 250 | } 251 | 252 | private createTagKey(tagName: string): string { 253 | return this.createKey(`${TAGS_VERSIONS_ALIAS}:${tagName}`); 254 | } 255 | 256 | private createTag(tagName: string): Tag { 257 | return { 258 | name: tagName, 259 | version: Date.now(), 260 | }; 261 | } 262 | 263 | /** 264 | * Executes commands from offline queue. Re-queues commands which was not successfully executed. 265 | */ 266 | private async executeCommandsFromQueue(): Promise { 267 | if (!this.commandsQueue.length) { 268 | return; 269 | } 270 | 271 | const unsuccessfullyExecutedCommands: Command[] = []; 272 | 273 | await Promise.all( 274 | this.commandsQueue.map(async ({ fn, params }) => { 275 | try { 276 | await fn(...params); 277 | } catch (executionError) { 278 | unsuccessfullyExecutedCommands.push({ fn, params }); 279 | } 280 | }) 281 | ); 282 | 283 | this.commandsQueue = unsuccessfullyExecutedCommands; 284 | } 285 | 286 | /** 287 | * All commands wrapped with this method will be "cached". This means that if there are problems with the connection 288 | * the response will be sent immediately and the command will be executed later when the connection is restored 289 | * or current execution timed out. 290 | */ 291 | private async cachedCommand(fn: CommandFn, ...args: unknown[]): Promise { 292 | if (!fn) { 293 | throw new Error("Cached function is required"); 294 | } 295 | 296 | const connectionStatus = this.adapter.getConnectionStatus(); 297 | 298 | if (connectionStatus !== ConnectionStatus.CONNECTED) { 299 | this.queueCommand(fn, args); 300 | } else { 301 | try { 302 | await fn(...args); 303 | } catch (error) { 304 | if (isOperationTimeoutError(error)) { 305 | this.queueCommand(fn, args); 306 | } else { 307 | throw error; 308 | } 309 | } 310 | } 311 | } 312 | 313 | private queueCommand(fn: CommandFn, params: unknown[]): void { 314 | this.commandsQueue.push({ fn, params }); 315 | } 316 | } 317 | -------------------------------------------------------------------------------- /src/storage/Record.ts: -------------------------------------------------------------------------------- 1 | import { Tag, WriteOptions } from "./Storage"; 2 | 3 | export class Record { 4 | /** 5 | * Checks if provided value is valid Record. 6 | */ 7 | static isRecord(value: unknown): value is Record { 8 | return typeof value === "object" && Object.prototype.hasOwnProperty.call(value, "key"); 9 | } 10 | /** 11 | * Record key 12 | */ 13 | key: string; 14 | /** 15 | * Is the key is "permanent". Permanent key is not treats as invalid when it expires 16 | */ 17 | permanent: boolean; 18 | /** 19 | * Key lifetime in milliseconds 20 | */ 21 | expiresIn: number; 22 | /** 23 | * The time in unixtime when the key was created 24 | */ 25 | createdAt: number; 26 | /** 27 | * Key value 28 | */ 29 | value: R; 30 | /** 31 | * Cache tags Array with pairs of tag name and version. The version is stored as unixtime. 32 | */ 33 | tags: Tag[]; 34 | 35 | constructor(key: string, value: R, tags: Tag[], options: WriteOptions = {}) { 36 | const { expiresIn = 0 } = options; 37 | 38 | this.key = key; 39 | this.value = value; 40 | this.tags = tags; 41 | this.permanent = expiresIn === 0; 42 | this.expiresIn = expiresIn; 43 | this.createdAt = Date.now(); 44 | 45 | if (value === undefined) { 46 | this.tags = []; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/storage/Storage.ts: -------------------------------------------------------------------------------- 1 | import { LockedKeyRetrieveStrategyType } from "../LockedKeyRetrieveStrategy"; 2 | import { ConnectionStatus } from "../ConnectionStatus"; 3 | import { Record } from "./Record"; 4 | 5 | /** 6 | * Storage is an abstraction over different operations with records 7 | * It manipulates with it's own Record type which is abstraction 8 | * over simple storage keys 9 | */ 10 | export interface Storage { 11 | get(key: string): Promise | null>; 12 | touch(tags: string[]): Promise; 13 | lockKey(key: string): Promise; 14 | releaseKey(key: string): Promise; 15 | keyIsLocked(key: string): Promise; 16 | del(key: string): Promise; 17 | getTags(tagNames: string[]): Promise; 18 | isOutdated(record: Record): Promise; 19 | set(key: string, value: R, options?: WriteOptions): Promise>; 20 | getConnectionStatus(): ConnectionStatus; 21 | } 22 | 23 | /** 24 | * Cache key tag In this form, tags are stored in the adapter's storage. 25 | */ 26 | export interface Tag { 27 | /** 28 | * Tag ID 29 | */ 30 | name: string; 31 | /** 32 | * Tag version in unixtime 33 | */ 34 | version: number; 35 | } 36 | 37 | export type Tags = { 38 | [tagName: string]: Tag; 39 | }; 40 | 41 | /** 42 | * Settings for getting the Record. Used in get 43 | */ 44 | export interface ReadOptions { 45 | /** 46 | * When reading a key, it is possible to set a strategy for behavior when a key expires. lockedKeyRetrieveStrategyType sets 47 | * name of the strategy used. If not specified, the default strategy will be used. 48 | */ 49 | lockedKeyRetrieveStrategyType?: LockedKeyRetrieveStrategyType | string; 50 | } 51 | 52 | export interface ExpireOptions { 53 | /** 54 | * The number of milliseconds after which the key values are considered obsolete 55 | */ 56 | expiresIn?: number; 57 | /** 58 | * Is the key "permanent"? Permanent key is not disabled when expiresIn 59 | */ 60 | permanent?: boolean; 61 | } 62 | 63 | export interface WriteOptions extends ExpireOptions { 64 | /** 65 | * Tags - are keys for which the manager checks the validity of a particular entry. 66 | * If the tag value is in the cache and invalidation time < current time, the tag will be considered invalid and 67 | * the record will need to be obtained using the executor 68 | */ 69 | tags?: string[] | (() => string[]); 70 | /** 71 | * getTags allows to detect tags for record depending on executor result 72 | */ 73 | getTags?: (executorResult: R) => string[]; 74 | } 75 | 76 | export type ReadWriteOptions = ReadOptions & WriteOptions; 77 | -------------------------------------------------------------------------------- /src/storage/__mocks__/BaseStorage.ts: -------------------------------------------------------------------------------- 1 | import { Storage } from '../Storage'; 2 | import { ConnectionStatus } from '../../ConnectionStatus'; 3 | 4 | export class BaseStorage implements Storage { 5 | storage: any; 6 | get: any = jest.fn().mockImplementation((key: string) => ({ key, value: JSON.stringify(this.storage[key]), permanent: true })); 7 | touch: any = jest.fn(); 8 | lockKey: any = jest.fn().mockResolvedValue(true); 9 | releaseKey: any = jest.fn(); 10 | keyIsLocked: any = jest.fn().mockReturnValue(false); 11 | del: any = jest.fn(); 12 | getTags: any = jest.fn(); 13 | set: any = jest.fn().mockImplementation((key: string, value: any) => { 14 | this.storage[key] = value; 15 | }); 16 | getConnectionStatus: any = jest.fn().mockReturnValue(ConnectionStatus.CONNECTED); 17 | isOutdated = jest.fn().mockReturnValue(false); 18 | 19 | constructor(storage: unknown) { 20 | this.storage = storage; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/storage/__mocks__/TestStorage.ts: -------------------------------------------------------------------------------- 1 | import { Storage } from '../Storage'; 2 | import { ConnectionStatus } from '../../ConnectionStatus'; 3 | 4 | export default class TestStorage implements Storage { 5 | storage: any; 6 | get: any = jest.fn().mockImplementation((key: string) => ({ key, value: JSON.stringify(this.storage[key]), permanent: true })); 7 | touch: any = jest.fn(); 8 | lockKey: any = jest.fn().mockResolvedValue(true); 9 | releaseKey: any = jest.fn(); 10 | keyIsLocked: any = jest.fn().mockReturnValue(false); 11 | del: any = jest.fn(); 12 | getTags: any = jest.fn(); 13 | set: any = jest.fn().mockImplementation((key: string, value: any) => { 14 | this.storage[key] = value; 15 | }); 16 | getConnectionStatus: any = jest.fn().mockReturnValue(ConnectionStatus.CONNECTED); 17 | isOutdated = jest.fn().mockReturnValue(false); 18 | 19 | constructor(storage: unknown) { 20 | this.storage = storage; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/timeout.spec.ts: -------------------------------------------------------------------------------- 1 | import timeout from "./timeout"; 2 | 3 | describe("timeout", () => { 4 | it("resolves after given time in ms", () => { 5 | const TEST_TIMEOUT = 2000; 6 | const originalTimeout = setTimeout; 7 | 8 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 9 | // @ts-ignore useFakeTimers broken in jest 27 10 | 11 | setTimeout = jest.fn(); 12 | timeout(TEST_TIMEOUT); 13 | 14 | expect(setTimeout).toHaveBeenCalledTimes(1); 15 | expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), TEST_TIMEOUT); 16 | 17 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 18 | // @ts-ignore useFakeTimers broken in jest 27 19 | setTimeout = originalTimeout; 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/timeout.ts: -------------------------------------------------------------------------------- 1 | import Timer = NodeJS.Timer; 2 | 3 | export default async (time: number): Promise => 4 | new Promise((resolve: (timeout: Timer) => void): void => { 5 | const timeout: Timer = setTimeout(() => resolve(timeout), time); 6 | }); 7 | -------------------------------------------------------------------------------- /src/with-timeout.spec.ts: -------------------------------------------------------------------------------- 1 | import timeout from "./timeout"; 2 | import { withTimeout } from "./with-timeout"; 3 | 4 | describe("withTimeout", () => { 5 | it("rejects with OperationTimeoutError if promise take too long time to resolve", async () => { 6 | const PROMISE_TIMEOUT = 1000; 7 | const OPERATION_TIMEOUT = 100; 8 | 9 | await expect(withTimeout(timeout(PROMISE_TIMEOUT), OPERATION_TIMEOUT)).rejects.toThrowError(); 10 | }); 11 | 12 | it("resolves if promise resolved in time", async () => { 13 | const PROMISE_TIMEOUT = 100; 14 | const OPERATION_TIMEOUT = 1000; 15 | 16 | jest.useFakeTimers(); 17 | 18 | const promise = withTimeout(timeout(PROMISE_TIMEOUT), OPERATION_TIMEOUT); 19 | 20 | jest.advanceTimersByTime(PROMISE_TIMEOUT); 21 | 22 | await expect(promise).resolves.not.toThrow(); 23 | 24 | jest.advanceTimersByTime(OPERATION_TIMEOUT); 25 | jest.clearAllTimers(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/with-timeout.ts: -------------------------------------------------------------------------------- 1 | import { operationTimeoutError } from "./errors/errors"; 2 | 3 | export const withTimeout = async (promise: Promise, timeout: number): Promise => { 4 | const timeoutPromise = new Promise((resolveTimeout, rejectTimeout): void => { 5 | setTimeout(() => { 6 | rejectTimeout(operationTimeoutError(timeout)); 7 | }, timeout); 8 | }); 9 | 10 | return Promise.race([timeoutPromise, promise]); 11 | }; 12 | -------------------------------------------------------------------------------- /tests/integration/adapter-agnostic.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionStatus } from "../../src/ConnectionStatus"; 2 | import { v4 as uuid } from "uuid"; 3 | import { StorageAdapter } from "../../src"; 4 | 5 | export interface Getter { 6 | (key: K): Promise; 7 | } 8 | export interface Setter { 9 | (key: K, value: string): Promise; 10 | } 11 | 12 | function delay(duration: number): Promise { 13 | return new Promise((resolve) => setTimeout(resolve, duration + 1)); 14 | } 15 | 16 | export const runAdapterTests = (get: Getter, set: Setter, adapter: StorageAdapter): void => { 17 | it('Sets connection status to "connected" if adapter executes some command', async () => { 18 | await adapter.get("test"); 19 | expect(adapter.getConnectionStatus()).toEqual(ConnectionStatus.CONNECTED); 20 | }); 21 | 22 | describe("set", () => { 23 | it("set returns true if operation is successful", async () => { 24 | const key = uuid(); 25 | const value = uuid(); 26 | 27 | await expect(adapter.set(key, value)).resolves.toEqual(true); 28 | await expect(adapter.get(key)).resolves.toEqual(value); 29 | }); 30 | 31 | it("set adds cache prefix", async () => { 32 | const key = uuid(); 33 | const value = uuid(); 34 | 35 | await adapter.set(key, value); 36 | await expect(get(key)).resolves.toEqual(value); 37 | }); 38 | 39 | it("set calls set with cache prefix and expires when expires set", async () => { 40 | const key = uuid(); 41 | const value = uuid(); 42 | 43 | await adapter.set(key, value, 1000); 44 | await delay(2000); 45 | await expect(get(key)).resolves.toBeNull(); 46 | }); 47 | }); 48 | 49 | describe("get", () => { 50 | it("get returns value", async () => { 51 | const key = uuid(); 52 | const value = uuid(); 53 | 54 | await expect(adapter.set(key, value)); 55 | await expect(adapter.get(key)).resolves.toEqual(value); 56 | }); 57 | 58 | it("get returns null if key does not set", async () => { 59 | const key = uuid(); 60 | 61 | await expect(adapter.get(key)).resolves.toBeNull(); 62 | }); 63 | 64 | it("get adds cache prefix", async () => { 65 | const key = uuid(); 66 | const value = uuid(); 67 | 68 | await set(key, value); 69 | await expect(adapter.get(key)).resolves.toEqual(value); 70 | }); 71 | }); 72 | 73 | describe("del", () => { 74 | it("del calls del with cache prefix", async () => { 75 | const key = uuid(); 76 | const value = uuid(); 77 | 78 | await set(key, value); 79 | await adapter.del(key); 80 | await expect(get(key)).resolves.toBeNull(); 81 | }); 82 | 83 | it("del does nothing if key does not exist", async () => { 84 | const key = uuid(); 85 | const keyWithPrefix = key; 86 | 87 | await expect(get(keyWithPrefix)).resolves.toBeNull(); 88 | await adapter.del(key); 89 | await expect(get(keyWithPrefix)).resolves.toBeNull(); 90 | }); 91 | }); 92 | 93 | describe("acquireLock", () => { 94 | it("acquireLock returns true if lock is successful", async () => { 95 | const key = uuid(); 96 | const lockResult = await adapter.acquireLock(key); 97 | 98 | expect(lockResult).toEqual(true); 99 | }); 100 | 101 | it("acquireLock calls set with generated key name", async () => { 102 | const key = uuid(); 103 | const lockResult = await adapter.acquireLock(key, 1000); 104 | 105 | expect(lockResult).toEqual(true); 106 | 107 | await delay(2000); 108 | await expect(get(`${key}_lock`)).resolves.toBeNull(); 109 | }); 110 | }); 111 | 112 | describe("releaseLock", () => { 113 | it("releaseLock returns false if lock does not exist", async () => { 114 | const key = uuid(); 115 | const releaseLockResult = await adapter.releaseLock(key); 116 | 117 | expect(releaseLockResult).toEqual(false); 118 | }); 119 | 120 | it("releaseLock delete lock record with appropriate key, and returns true on success", async () => { 121 | const key = uuid(); 122 | 123 | await set(`${key}_lock`, ""); 124 | 125 | await delay(50); 126 | 127 | const releaseLockResult = await adapter.releaseLock(key); 128 | 129 | expect(releaseLockResult).toEqual(true); 130 | }); 131 | 132 | it("releaseLock delete lock record set by acquireLock", async () => { 133 | const key = uuid(); 134 | 135 | await adapter.acquireLock(key); 136 | await expect(adapter.releaseLock(key)).resolves.toEqual(true); 137 | }); 138 | }); 139 | 140 | describe("isLockExists", () => { 141 | it("isLockExists returns true if lock exists", async () => { 142 | const key = uuid(); 143 | 144 | await adapter.acquireLock(key); 145 | await expect(adapter.isLockExists(key)).resolves.toEqual(true); 146 | }); 147 | 148 | it("isLockExists returns false if lock does not exist", async () => { 149 | const key = uuid(); 150 | 151 | await expect(adapter.isLockExists(key)).resolves.toEqual(false); 152 | }); 153 | }); 154 | 155 | describe("mset", () => { 156 | it("mset sets values", async () => { 157 | const values = new Map([ 158 | [uuid(), uuid()], 159 | [uuid(), uuid()], 160 | ]); 161 | 162 | await adapter.mset(values); 163 | 164 | for (const [key, value] of values.entries()) { 165 | await expect(get(key)).resolves.toEqual(value); 166 | } 167 | }); 168 | 169 | it("mset throws error on empty values", async () => { 170 | await expect(adapter.mset(new Map())).rejects.toThrowError( 171 | "ERR wrong number of arguments for 'mset' command" 172 | ); 173 | }); 174 | }); 175 | 176 | describe("mget", () => { 177 | it("mget gets values", async () => { 178 | const values = new Map([ 179 | [uuid(), uuid()], 180 | [uuid(), uuid()], 181 | ]); 182 | 183 | for (const [key, value] of values.entries()) { 184 | await set(key, value); 185 | } 186 | 187 | const result = await adapter.mget(Array.from(values.keys())); 188 | 189 | expect(result).toEqual(Array.from(values.values())); 190 | }); 191 | 192 | it("mget returns null for non-existing keys", async () => { 193 | const values = new Map([ 194 | [uuid(), uuid()], 195 | [uuid(), uuid()], 196 | ]); 197 | 198 | for (const [key, value] of values.entries()) { 199 | await set(key, value); 200 | } 201 | 202 | const keys = Array.from(values.keys()); 203 | const nonExistingKey = uuid(); 204 | 205 | keys.push(nonExistingKey); 206 | 207 | const result = await adapter.mget(keys); 208 | 209 | expect(result).toEqual([...Array.from(values.values()), null]); 210 | }); 211 | }); 212 | }; 213 | -------------------------------------------------------------------------------- /tests/integration/base-agnostic.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuid } from "uuid"; 2 | import { Storage } from "../../src/storage/Storage"; 3 | import { StorageAdapter } from "../../src"; 4 | 5 | export const runStorageTests = (storage: Storage, adapter: StorageAdapter): void => { 6 | describe("set", () => { 7 | it("does not modify not touched if all tags exist", async () => { 8 | const key = uuid(); 9 | const value = uuid(); 10 | const existingTag = "existingTag"; 11 | const tagVersionKey = `cache-cache-tags-versions:${existingTag}`; 12 | 13 | await adapter.set(tagVersionKey, "1"); 14 | await storage.set(key, value, { tags: [existingTag] }); 15 | await expect(adapter.get(tagVersionKey)).resolves.toEqual("1"); 16 | }); 17 | }); 18 | 19 | describe("touch", () => { 20 | it("removes tag from not touched", async () => { 21 | const tag = "notTouchedTag"; 22 | 23 | await storage.touch([tag]); 24 | await expect(adapter.get(`cache-cache-tags-versions:${tag}`)).resolves.not.toEqual("0"); 25 | }); 26 | 27 | it("preserves not touched if touched only existing tags", async () => { 28 | const existingTag = "existingTag"; 29 | const tagVersionKey = `cache-cache-tags-versions:${existingTag}`; 30 | 31 | await adapter.set(tagVersionKey, "1"); 32 | await storage.touch([existingTag]); 33 | await expect(adapter.get(tagVersionKey)).resolves.not.toEqual("0"); 34 | }); 35 | }); 36 | 37 | describe("getTags", () => { 38 | it("returns correct version for unknown tag", async () => { 39 | const tag = "unknownTag"; 40 | 41 | await expect(storage.getTags([tag])).resolves.toEqual([{ name: tag, version: 0 }]); 42 | }); 43 | 44 | it("returns correct version for not touched tag", async () => { 45 | const tag = "notTouchedTag"; 46 | const tags = await storage.getTags([tag]); 47 | 48 | expect(tags).toEqual([{ name: tag, version: 0 }]); 49 | }); 50 | 51 | it("returns correct version for existing tag", async () => { 52 | const existingTag = "existingTag"; 53 | const tagVersionKey = `cache-cache-tags-versions:${existingTag}`; 54 | 55 | await adapter.set(tagVersionKey, "1"); 56 | 57 | const tags = await storage.getTags([existingTag]); 58 | 59 | expect(tags).toEqual([{ name: existingTag, version: 1 }]); 60 | }); 61 | 62 | it("returns correct version for mixed tags", async () => { 63 | const existingTag = "existingTag"; 64 | const tagVersionKey = `cache-cache-tags-versions:${existingTag}`; 65 | 66 | await adapter.set(tagVersionKey, "1"); 67 | 68 | const notTouchedTag = "notTouchedTag"; 69 | const tags = await storage.getTags([existingTag, notTouchedTag]); 70 | 71 | expect(tags).toEqual([ 72 | { name: existingTag, version: 1 }, 73 | { name: notTouchedTag, version: 0 }, 74 | ]); 75 | }); 76 | }); 77 | 78 | describe("Combo", () => { 79 | it("works", async () => { 80 | const tag1 = "tag1"; 81 | 82 | await storage.set(uuid(), uuid(), { tags: [tag1] }); 83 | await expect(storage.getTags([tag1])).resolves.toEqual([{ name: tag1, version: 0 }]); 84 | await storage.touch([tag1]); 85 | 86 | const tags = await storage.getTags([tag1]); 87 | 88 | expect(tags).toHaveLength(1); 89 | expect(tags[0].name).toEqual(tag1); 90 | expect(tags[0].version).toBeGreaterThan(0); 91 | 92 | const tag2 = "tag2"; 93 | 94 | await storage.set(uuid(), uuid(), { tags: [tag2] }); 95 | 96 | const tags2 = await storage.getTags([tag1, tag2]); 97 | 98 | expect(tags2).toHaveLength(2); 99 | expect(tags2[0]).toEqual(tags[0]); 100 | expect(tags2[1]).toEqual({ name: tag2, version: 0 }); 101 | 102 | await storage.touch([tag1, tag2]); 103 | 104 | const tags3 = await storage.getTags([tag1, tag2]); 105 | 106 | expect(tags3).toHaveLength(2); 107 | expect(tags3[0].name).toEqual(tag1); 108 | expect(tags3[0].version).not.toEqual(tags[0].version); 109 | expect(tags3[1].name).toEqual(tag2); 110 | expect(tags3[1].version).toBeGreaterThan(0); 111 | }); 112 | }); 113 | }; 114 | -------------------------------------------------------------------------------- /tests/integration/base-memcached.spec.ts: -------------------------------------------------------------------------------- 1 | import Memcached from "memcached"; 2 | import { runStorageTests } from "./base-agnostic"; 3 | import MemcachedStorageAdapter from "../../src/adapters/MemcachedStorageAdapter"; 4 | import { BaseStorage } from "../../src/storage/BaseStorage"; 5 | 6 | const memcached = new Memcached("127.0.0.1:11211"); 7 | const adapter = new MemcachedStorageAdapter(memcached); 8 | const prefix = "cache"; 9 | const storage = new BaseStorage({ adapter, prefix }); 10 | 11 | describe("Base storage - memcached", () => { 12 | beforeEach(async () => { 13 | await new Promise((resolve, reject) => { 14 | memcached.flush((err, results) => { 15 | if (err) { 16 | return reject(err); 17 | } 18 | 19 | resolve(results); 20 | }); 21 | }); 22 | }); 23 | 24 | runStorageTests(storage, adapter); 25 | }); 26 | -------------------------------------------------------------------------------- /tests/integration/base-redis.spec.ts: -------------------------------------------------------------------------------- 1 | import Redis from "ioredis"; 2 | import { runStorageTests } from "./base-agnostic"; 3 | import RedisStorageAdapter from "../../src/adapters/RedisStorageAdapter"; 4 | import { BaseStorage } from "../../src/storage/BaseStorage"; 5 | 6 | const redis = new Redis(); 7 | const adapter = new RedisStorageAdapter(redis, { operationTimeout: 9000 }); 8 | const prefix = "cache"; 9 | const storage = new BaseStorage({ adapter, prefix }); 10 | 11 | describe("Base storage - redis", () => { 12 | beforeEach(async () => { 13 | await redis.flushall(); 14 | }); 15 | 16 | runStorageTests(storage, adapter); 17 | }); 18 | -------------------------------------------------------------------------------- /tests/integration/cache.spec.ts: -------------------------------------------------------------------------------- 1 | import Redis from "ioredis"; 2 | import { v4 as uuid } from "uuid"; 3 | import Cache from "../../src/Cache"; 4 | import RedisStorageAdapter from "../../src/adapters/RedisStorageAdapter"; 5 | import MemcachedStorageAdapter from "../../src/adapters/MemcachedStorageAdapter"; 6 | import timeout from "../../src/timeout"; 7 | import Memcached from "memcached"; 8 | 9 | const logger = { 10 | info: jest.fn(), 11 | trace: jest.fn(), 12 | warn: jest.fn(), 13 | error: jest.fn(), 14 | }; 15 | 16 | const redis = new Redis({ 17 | enableOfflineQueue: false, 18 | enableReadyCheck: true, 19 | autoResendUnfulfilledCommands: false, 20 | }); 21 | const memcached = new Memcached("localhost:11211"); 22 | const cache = new Cache({ 23 | adapter: new RedisStorageAdapter(redis, { operationTimeout: 9000 }), 24 | logger, 25 | }); 26 | const memcache = new Cache({ 27 | adapter: new MemcachedStorageAdapter(memcached), 28 | logger, 29 | }); 30 | 31 | const REDIS_OPERATION_DELAY = 1000; 32 | 33 | describe("Cache", () => { 34 | beforeEach( 35 | () => 36 | new Promise((resolve) => { 37 | redis.connect().catch(() => { 38 | /* ignore */ 39 | }); 40 | 41 | if (redis.status === "ready") { 42 | redis.flushall(); 43 | return resolve(undefined); 44 | } 45 | 46 | redis.on("ready", () => { 47 | resolve(undefined); 48 | redis.flushall(); 49 | }); 50 | }) 51 | ); 52 | 53 | afterAll(() => { 54 | redis.disconnect(); 55 | }); 56 | 57 | it("is able to set value and get it", async () => { 58 | const struct = { a: 1, b: 2 }; 59 | const key = uuid(); 60 | 61 | await cache.set(key, struct); 62 | await expect(cache.get(key, jest.fn)).resolves.toEqual(struct); 63 | }); 64 | 65 | it("runs executor and saves it result to storage on miss", async () => { 66 | const struct = { a: 1, b: 2 }; 67 | const key = uuid(); 68 | 69 | await expect(cache.get(key, () => struct)).resolves.toEqual(struct); 70 | await timeout(REDIS_OPERATION_DELAY); 71 | await expect(cache.get(key, jest.fn)).resolves.toEqual(struct); 72 | }); 73 | 74 | it("retrieves data from base storage if cache is down", async () => { 75 | const struct = { a: 1, b: 2 }; 76 | const key = uuid(); 77 | 78 | await cache.set(key, struct); 79 | redis.disconnect(); 80 | await timeout(REDIS_OPERATION_DELAY); 81 | await expect(cache.get(key, async () => struct)).resolves.toEqual(struct); 82 | }); 83 | 84 | it("throws if both base storage and cache is unavailable", async () => { 85 | const struct = { a: 1, b: 2 }; 86 | const key = uuid(); 87 | const executor = (): never => { 88 | throw new Error("connection timed out"); 89 | }; 90 | 91 | await cache.set(key, struct); 92 | redis.disconnect(); 93 | await timeout(REDIS_OPERATION_DELAY); 94 | await expect(cache.get(key, executor)).rejects.toThrowErrorMatchingInlineSnapshot( 95 | `"connection timed out"` 96 | ); 97 | }); 98 | 99 | it("gets value from cache if tags valid", async () => { 100 | const struct = { a: 1, b: 2 }; 101 | const tags = [uuid()]; 102 | const key = uuid(); 103 | 104 | await expect(cache.get(key, () => struct, { tags })).resolves.toEqual(struct); 105 | await timeout(REDIS_OPERATION_DELAY); 106 | await expect(cache.get(key, jest.fn, { tags })).resolves.toEqual(struct); 107 | }); 108 | 109 | it("gets value from storage if tags not valid", async () => { 110 | const struct = { a: 1, b: 2 }; 111 | const executor = (): typeof struct => struct; 112 | const tags = [uuid()]; 113 | const key = uuid(); 114 | 115 | await expect(cache.get(key, executor, { tags })).resolves.toEqual(struct); 116 | await timeout(REDIS_OPERATION_DELAY); 117 | await expect(cache.get(key, jest.fn, { tags })).resolves.toEqual(struct); 118 | await cache.touch(tags); 119 | await timeout(REDIS_OPERATION_DELAY); 120 | 121 | struct.a = 5; 122 | 123 | await expect(cache.get(key, executor, { tags })).resolves.toEqual(struct); 124 | }); 125 | 126 | it("Redis concurrent test", async () => { 127 | const tasks = []; 128 | const executor = async () => new Date().toISOString(); 129 | 130 | for (let j = 0; j < 500; j++) { 131 | tasks.push(cache.get("TEST", executor, { lockedKeyRetrieveStrategyType: "waitForResult" })); 132 | } 133 | expect(new Set(await Promise.all(tasks)).size).toEqual(1); 134 | }, 3000); 135 | 136 | it("Memcached concurrent test", async () => { 137 | const tasks = []; 138 | const executor = async () => new Date().toISOString(); 139 | 140 | for (let j = 0; j < 500; j++) { 141 | tasks.push(memcache.get("TEST", executor, { lockedKeyRetrieveStrategyType: "waitForResult" })); 142 | } 143 | expect(new Set(await Promise.all(tasks)).size).toEqual(1); 144 | }, 3000); 145 | }); 146 | -------------------------------------------------------------------------------- /tests/integration/memcached.spec.ts: -------------------------------------------------------------------------------- 1 | import Memcached from "memcached"; 2 | import MemcachedStorageAdapter from "../../src/adapters/MemcachedStorageAdapter"; 3 | import { Getter, runAdapterTests } from "./adapter-agnostic"; 4 | 5 | const memcached = new Memcached("127.0.0.1:11211"); 6 | const adapter = new MemcachedStorageAdapter(memcached); 7 | 8 | const get: Getter = (key: string) => 9 | new Promise((resolve, reject) => { 10 | memcached.get(key, (err, data) => { 11 | if (err) { 12 | return reject(err); 13 | } 14 | 15 | if (data === undefined) { 16 | return resolve(null); 17 | } 18 | 19 | resolve(data); 20 | }); 21 | }); 22 | 23 | const set = (key: string, value: string): Promise => 24 | new Promise((resolve, reject) => { 25 | memcached.set(key, value, 0, (err, data) => { 26 | if (err) { 27 | return reject(err); 28 | } 29 | 30 | resolve(data); 31 | }); 32 | }); 33 | 34 | describe("Memcached adapter", () => { 35 | beforeEach(async () => { 36 | await new Promise((resolve, reject) => { 37 | memcached.flush((err, results) => { 38 | if (err) { 39 | return reject(err); 40 | } 41 | 42 | resolve(results); 43 | }); 44 | }); 45 | }); 46 | 47 | runAdapterTests(get, set, adapter); 48 | }); 49 | -------------------------------------------------------------------------------- /tests/integration/redis.spec.ts: -------------------------------------------------------------------------------- 1 | import Redis from "ioredis"; 2 | import RedisStorageAdapter from "../../src/adapters/RedisStorageAdapter"; 3 | import { runAdapterTests } from "./adapter-agnostic"; 4 | 5 | const redis = new Redis(); 6 | const adapter = new RedisStorageAdapter(redis, { lockExpireTimeout: 50, operationTimeout: 9000 }); 7 | 8 | describe("Redis adapter", () => { 9 | beforeEach(() => { 10 | redis.flushall(); 11 | }); 12 | afterAll(() => { 13 | redis.disconnect(); 14 | }); 15 | 16 | runAdapterTests(redis.get.bind(redis), redis.set.bind(redis), adapter); 17 | }); 18 | -------------------------------------------------------------------------------- /tests/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | globals: { 3 | 'ts-jest': { 4 | tsConfig: '/../tsconfig.json', 5 | diagnostics: true, 6 | }, 7 | }, 8 | testMatch: ['/integration/**/*.spec.ts'], 9 | testEnvironment: 'node', 10 | transform: { 11 | '^.+\\.ts$': 'ts-jest', 12 | }, 13 | testTimeout: 10000, 14 | }; 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "module": "commonjs", 5 | "target": "es2018", 6 | "lib": ["es6", "es2017"], 7 | "sourceMap": true, 8 | "types": ["node", "jest"], 9 | "moduleResolution": "node", 10 | "outDir": "dist", 11 | "rootDir": "src", 12 | "forceConsistentCasingInFileNames": true, 13 | "allowSyntheticDefaultImports": true, 14 | "emitDecoratorMetadata": true, 15 | "experimentalDecorators": true, 16 | "esModuleInterop": true, 17 | "noUnusedLocals": true, 18 | "strict": true, 19 | "noUnusedParameters": false, 20 | "skipLibCheck": true, 21 | "resolveJsonModule": true, 22 | "noImplicitReturns": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "downlevelIteration": true 25 | }, 26 | "include": [ 27 | "src/**/*" 28 | ], 29 | "exclude": [ 30 | "node_modules", 31 | "dist", 32 | "jest", 33 | "src/**/*.spec.ts", 34 | "src/**/__mocks__/*.ts" 35 | ] 36 | } 37 | --------------------------------------------------------------------------------