Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: improve MemoryStore #378

Merged
merged 10 commits into from
Sep 3, 2023
157 changes: 120 additions & 37 deletions source/memory-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,15 @@

import type { Store, Options, IncrementResponse } from './types.js'

/**
* Calculates the time when all hit counters will be reset.
*
* @param windowMs {number} - The duration of a window (in milliseconds).
*
* @returns {Date}
*
* @private
*/
const calculateNextResetTime = (windowMs: number): Date => {
const resetTime = new Date()
resetTime.setMilliseconds(resetTime.getMilliseconds() + windowMs)
return resetTime
// Client is similar to IncrementResponse, except that resetTime is always defined
type Client = {
totalHits: number
resetTime: Date
}

const average = (array: number[]) =>
array.reduce((a, b) => a + b) / array.length

/**
* A `Store` that stores the hit count for each client in memory.
*
Expand All @@ -30,16 +24,33 @@ export default class MemoryStore implements Store {
windowMs!: number

/**
* The map that stores the number of hits for each client in memory.
* These two maps store usage (requests) and reset time by key (IP)
*
* They are split into two to avoid having to iterate through the entire set to determine which ones need reset.
* Instead, Clients are moved from previous to current as new requests come in.
* Once windowMS has elapsed, all clients left in previous are known to be expired.
* At that point, the cache pool is filled from previous, and any remaining clients are cleared.
*
*/
hits!: {
[key: string]: number | undefined
}
previous = new Map<string, Client>()
current = new Map<string, Client>()

/**
* The time at which all hit counts will be reset.
* Cache of unused clients, kept to reduce the number of objects created and destroyed.
*
* Improves performance, at the expense of a small amount of RAM.
*
* Each individual entry takes up 152 bytes.
* In one benchmark, the total time taken to handle 100M requests was reduced from 70.184s to 47.862s (~32% improvement) with ~5.022 MB extra RAM used.
gamemaker1 marked this conversation as resolved.
Show resolved Hide resolved
* .
*/
pool: Client[] = []

/**
* Used to calculate how many entries to keep in the pool
*/
resetTime!: Date
newClients = 0
recentNew: number[] = []

/**
* Reference to the active timer.
Expand All @@ -60,17 +71,18 @@ export default class MemoryStore implements Store {
init(options: Options): void {
// Get the duration of a window from the options.
this.windowMs = options.windowMs
// Then calculate the reset time using that.
this.resetTime = calculateNextResetTime(this.windowMs)

// Initialise the hit counter map.
this.hits = {}
// Indicates that init was called more than once.
// Could happen if a store was shared between multiple instances.
if (this.interval) {
clearTimeout(this.interval)
}
Comment on lines +61 to +65
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixes #326


// Reset hit counts for ALL clients every `windowMs` - this will also
// re-calculate the `resetTime`
this.interval = setInterval(async () => {
await this.resetAll()
// Reset all clients left in previous every `windowMs`.
this.interval = setInterval(() => {
this.clearExpired()
}, this.windowMs)

// Cleaning up the interval will be taken care of by the `shutdown` method.
if (this.interval.unref) this.interval.unref()
}
Expand All @@ -85,13 +97,16 @@ export default class MemoryStore implements Store {
* @public
*/
async increment(key: string): Promise<IncrementResponse> {
const totalHits = (this.hits[key] ?? 0) + 1
this.hits[key] = totalHits
const client = this.getClient(key)

return {
totalHits,
resetTime: this.resetTime,
const now = Date.now()
if (client.resetTime.getTime() <= now) {
this.resetClient(client, now)
}

client.totalHits++

return client
}

/**
Expand All @@ -102,9 +117,9 @@ export default class MemoryStore implements Store {
* @public
*/
async decrement(key: string): Promise<void> {
const current = this.hits[key]
const client = this.getClient(key)

if (current) this.hits[key] = current - 1
if (client.totalHits > 1) client.totalHits--
gamemaker1 marked this conversation as resolved.
Show resolved Hide resolved
}

/**
Expand All @@ -115,7 +130,8 @@ export default class MemoryStore implements Store {
* @public
*/
async resetKey(key: string): Promise<void> {
delete this.hits[key]
this.current.delete(key)
this.previous.delete(key)
}

/**
Expand All @@ -124,8 +140,8 @@ export default class MemoryStore implements Store {
* @public
*/
async resetAll(): Promise<void> {
this.hits = {}
this.resetTime = calculateNextResetTime(this.windowMs)
this.current.clear()
this.previous.clear()
}

/**
Expand All @@ -136,5 +152,72 @@ export default class MemoryStore implements Store {
*/
shutdown(): void {
clearInterval(this.interval)
void this.resetAll()
}

private resetClient(client: Client, now = Date.now()) {
client.totalHits = 0
client.resetTime.setTime(now + this.windowMs)
}

/**
* Refill the pool, set previous to current, reset current
*/
private clearExpired() {
// At this point, all clients in previous are expired

// calculate the target pool size
this.recentNew.push(this.newClients)
if (this.recentNew.length > 10) this.recentNew.shift()
this.newClients = 0
const targetPoolSize = Math.round(average(this.recentNew))

// Calculate how many entries to potentially copy to the pool
let poolSpace = targetPoolSize - this.pool.length

// Fill up the pool with expired clients
for (const client of this.previous.values()) {
if (poolSpace > 0) {
this.pool.push(client)
gamemaker1 marked this conversation as resolved.
Show resolved Hide resolved
poolSpace--
} else {
break
}
}

// Clear all expired clients from previous
this.previous.clear()

// Swap previous and temporary
const temporary = this.previous
this.previous = this.current
this.current = temporary
}

/**
* Retrieves or creates a client. Ensures it is in this.current
* @param key IP or other key
* @returns Client
*/
private getClient(key: string): Client {
if (this.current.has(key)) {
return this.current.get(key)!
}

let client
if (this.previous.has(key)) {
client = this.previous.get(key)!
} else if (this.pool.length > 0) {
client = this.pool.pop()!
this.resetClient(client)
this.newClients++
} else {
client = { totalHits: 0, resetTime: new Date() }
this.resetClient(client)
this.newClients++
}

this.current.set(key, client)
return client
}
}
16 changes: 9 additions & 7 deletions test/library/memory-store-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { jest } from '@jest/globals'
import MemoryStore from '../../source/memory-store.js'
import type { Options } from '../../source/index.js'

describe('memory store test', () => {
const minute = 60 * 1000

describe.only('memory store test', () => {
beforeEach(() => {
jest.useFakeTimers()
jest.spyOn(global, 'clearInterval')
Expand All @@ -17,7 +19,7 @@ describe('memory store test', () => {

it('sets the value to 1 on first call to `increment`', async () => {
const store = new MemoryStore()
store.init({ windowMs: -1 } as Options)
store.init({ windowMs: minute } as Options)
const key = 'test-store'

const { totalHits } = await store.increment(key)
Expand All @@ -26,7 +28,7 @@ describe('memory store test', () => {

it('increments the key for the store when `increment` is called', async () => {
const store = new MemoryStore()
store.init({ windowMs: -1 } as Options)
store.init({ windowMs: minute } as Options)
const key = 'test-store'

await store.increment(key)
Expand All @@ -37,7 +39,7 @@ describe('memory store test', () => {

it('decrements the key for the store when `decrement` is called', async () => {
const store = new MemoryStore()
store.init({ windowMs: -1 } as Options)
store.init({ windowMs: minute } as Options)
const key = 'test-store'

await store.increment(key)
Expand All @@ -50,7 +52,7 @@ describe('memory store test', () => {

it('resets the count for a key in the store when `resetKey` is called', async () => {
const store = new MemoryStore()
store.init({ windowMs: -1 } as Options)
store.init({ windowMs: minute } as Options)
const key = 'test-store'

await store.increment(key)
Expand All @@ -62,7 +64,7 @@ describe('memory store test', () => {

it('resets the count for all keys in the store when `resetAll` is called', async () => {
const store = new MemoryStore()
store.init({ windowMs: -1 } as Options)
store.init({ windowMs: minute } as Options)
const keyOne = 'test-store-one'
const keyTwo = 'test-store-two'

Expand All @@ -78,7 +80,7 @@ describe('memory store test', () => {

it('clears the timer when `shutdown` is called', async () => {
const store = new MemoryStore()
store.init({ windowMs: -1 } as Options)
store.init({ windowMs: minute } as Options)
expect(store.interval).toBeDefined()
store.shutdown()
expect(clearInterval).toHaveBeenCalledWith(store.interval)
Expand Down
5 changes: 4 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"include": ["source/"],
"exclude": ["node_modules/"],
"extends": "@express-rate-limit/tsconfig"
"extends": "@express-rate-limit/tsconfig",
"compilerOptions": {
"target": "ES2020"
}
}