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

perf: rewrite using lua scripting when is possible #16

Merged
merged 4 commits into from
Apr 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions examples/server/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
'use strict'

const { styleText } = require('node:util')
const { json } = require('http-body')
const send = require('send-http')
const Redis = require('ioredis')
const http = require('http')

const redis = new Redis()
const openkey = require('../..')({ redis })

const server = http.createServer(async (req, res) => {
try {
console.log(`~> ${req.method} ${req.url} `)

if (req.url === '/keys/create') {
const options = await json(req)
const result = await openkey.keys.create(options)
return send(res, 201, result)
}

if (req.url.startsWith('/keys/key_')) {
const key = req.url.split('/keys/')[1]
const result = await openkey.keys.retrieve(key)
return send(res, 200, result)
}

if (req.url === '/plans/create') {
const options = await json(req)
const result = await openkey.plans.create(options)
return send(res, 201, result)
}

if (req.url.startsWith('/usage/')) {
const keyValue = req.url.split('/usage/')[1]
const { pending, ...usage } = await openkey.usage.increment(keyValue)
const statusCode = usage.remaining > 0 ? 200 : 429
res.setHeader('X-Rate-Limit-Limit', usage.limit)
res.setHeader('X-Rate-Limit-Remaining', usage.remaining)
res.setHeader('X-Rate-Limit-Reset', usage.reset)
return send(res, statusCode, usage)
}

if (req.url.startsWith('/stats/')) {
const keyValue = req.url.split('/stats/')[1]
const stats = await openkey.stats(keyValue)
return send(res, 200, stats)
}

return send(res, 400)
} catch (error) {
console.log(' ' + styleText('red', `ERROR: ${error.message}`))
res.statusCode = 500
res.end()
}
})

const PORT = 1337

server.listen(PORT, () => {
console.log(`Server is listening on port http://localhost:${PORT}`)
})
5 changes: 5 additions & 0 deletions examples/server/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"scripts": {
"dev": "watchexec --on-busy-update=restart --exts js,yml --clear=clear 'node index.js'"
}
}
49 changes: 49 additions & 0 deletions src/error.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
'use strict'

const { isPlainObject } = require('./util')

class OpenKeyError extends Error {
constructor (props) {
super()
this.name = 'OpenKeyError'
Object.assign(this, props)
}
}

const errors = [
['KEY_NOT_EXIST', key => `The key \`${key}\` does not exist.`],
['KEY_IS_ASSOCIATED', (id, value) => `The plan \`${id}\` is associated with the key \`${value}\`.`],
['PLAN_NOT_EXIST', plan => `The plan \`${plan}\` does not exist.`],
['PLAN_ID_REQUIRED', () => 'The argument `id` must be a string.'],
['PLAN_INVALID_ID', () => 'The argument `id` cannot contain whitespace.'],
['PLAN_INVALID_LIMIT', () => 'The argument `limit` must be a positive number.'],
['PLAN_INVALID_PERIOD', () => 'The argument `period` must be a string.'],
['PLAN_ALREADY_EXIST', plan => `The plan \`${plan}\` already exists.`],
['METADATA_NOT_FLAT_OBJECT', () => 'The metadata must be a flat object.'],
['METADATA_INVALID', key => `The metadata field '${key}' can't be an object.`]
].reduce((acc, [code, message]) => {
acc[code] = args => new OpenKeyError({ code, message: message.apply(null, args()) })
return acc
}, {})

const assert = (condition, code, args = () => []) => {
return (
condition ||
(() => {
throw errors[code](args)
})()
)
}

const assertMetadata = metadata => {
if (metadata) {
assert(isPlainObject(metadata), 'METADATA_NOT_FLAT_OBJECT')
Object.keys(metadata).forEach(key => {
assert(!isPlainObject(metadata[key]), 'METADATA_INVALID', () => [key])
if (metadata[key] === undefined) delete metadata[key]
})
return Object.keys(metadata).length ? metadata : undefined
}
}

module.exports = { errors, assert, assertMetadata }
7 changes: 4 additions & 3 deletions src/keys.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict'

const { pick, uid, assert, assertMetadata } = require('./util')
const { pick, uid } = require('./util')
const { assert, assertMetadata } = require('./error')

module.exports = ({ serialize, deserialize, plans, redis, prefix } = {}) => {
/**
Expand Down Expand Up @@ -40,7 +41,7 @@ module.exports = ({ serialize, deserialize, plans, redis, prefix } = {}) => {
*/
const retrieve = async (value, { throwError = false } = {}) => {
const key = await redis.get(prefixKey(value))
if (throwError) assert(key !== null, () => `The key \`${value}\` does not exist.`)
if (throwError) assert(key !== null, 'KEY_NOT_EXIST', () => [value])
else if (key === null) return null
return Object.assign({ value }, deserialize(key))
}
Expand All @@ -54,7 +55,7 @@ module.exports = ({ serialize, deserialize, plans, redis, prefix } = {}) => {
*/
const del = async value => {
const isDeleted = (await redis.del(prefixKey(value))) === 1
assert(isDeleted, () => `The key \`${value}\` does not exist.`)
assert(isDeleted, 'KEY_NOT_EXIST', () => [value])
return isDeleted
}

Expand Down
32 changes: 12 additions & 20 deletions src/plans.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict'

const { pick, assert, assertMetadata } = require('./util')
const { pick } = require('./util')
const { assert, assertMetadata } = require('./error')

module.exports = ({ serialize, deserialize, redis, keys, prefix } = {}) => {
/**
Expand All @@ -17,26 +18,20 @@ module.exports = ({ serialize, deserialize, redis, keys, prefix } = {}) => {
* @returns {Object} The plan object.
*/
const create = async (opts = {}) => {
assert(typeof opts.id === 'string' && opts.id.length > 0, () => 'The argument `id` must be a string.')
assert(!/\s/.test(opts.id), () => 'The argument `id` cannot contain whitespace.')
assert(typeof opts.id === 'string' && opts.id.length > 0, 'PLAN_ID_REQUIRED')
assert(!/\s/.test(opts.id), 'PLAN_INVALID_ID')
const plan = Object.assign(
{
limit: assert(
typeof opts.limit === 'number' && opts.limit > 0 && opts.limit,
() => 'The argument `limit` must be a positive number.'
),
period: assert(
typeof opts.period === 'string' && opts.period.length > 0 && opts.period,
() => 'The argument `period` must be a string.'
)
limit: assert(typeof opts.limit === 'number' && opts.limit > 0 && opts.limit, 'PLAN_INVALID_LIMIT'),
period: assert(typeof opts.period === 'string' && opts.period.length > 0 && opts.period, 'PLAN_INVALID_PERIOD')
},
pick(opts, ['rate', 'burst'])
)
const metadata = assertMetadata(opts.metadata)
if (metadata) plan.metadata = metadata
plan.createdAt = plan.updatedAt = Date.now()
const isCreated = (await redis.set(prefixKey(opts.id), serialize(plan), 'NX')) === 'OK'
if (!isCreated) throw new TypeError(`The plan \`${opts.id}\` already exists.`)
assert(isCreated, 'PLAN_ALREADY_EXIST', () => [opts.id])
return Object.assign({ id: opts.id }, plan)
}

Expand All @@ -52,7 +47,7 @@ module.exports = ({ serialize, deserialize, redis, keys, prefix } = {}) => {
*/
const retrieve = async (id, { throwError = false } = {}) => {
const plan = await redis.get(prefixKey(id))
if (throwError) assert(plan !== null, () => `The plan \`${id}\` does not exist.`)
if (throwError) assert(plan !== null, 'PLAN_NOT_EXIST', () => [id])
else if (plan === null) return null
return Object.assign({ id }, deserialize(plan))
}
Expand All @@ -68,9 +63,9 @@ module.exports = ({ serialize, deserialize, redis, keys, prefix } = {}) => {
const del = async id => {
const allKeys = await keys().list()
const key = allKeys.find(key => key.plan === id)
assert(key === undefined, () => `The plan \`${id}\` is associated with the key \`${key.value}\`.`)
assert(key === undefined, 'KEY_IS_ASSOCIATED', () => [id, key.value])
const isDeleted = (await redis.del(prefixKey(id))) === 1
assert(isDeleted, () => `The plan \`${id}\` does not exist.`)
assert(isDeleted, 'PLAN_NOT_EXIST', () => [id])
return isDeleted
}

Expand Down Expand Up @@ -100,16 +95,13 @@ module.exports = ({ serialize, deserialize, redis, keys, prefix } = {}) => {
)

if (opts.limit) {
plan.limit = assert(
typeof opts.limit === 'number' && opts.limit > 0 && opts.limit,
() => 'The argument `limit` must be a positive number.'
)
plan.limit = assert(typeof opts.limit === 'number' && opts.limit > 0 && opts.limit, 'PLAN_INVALID_LIMIT')
}

if (opts.period) {
plan.period = assert(
typeof opts.period === 'string' && opts.period.length > 0 && opts.period,
() => 'The argument `period` must be a string.'
'PLAN_INVALID_PERIOD'
)
}

Expand Down
10 changes: 2 additions & 8 deletions src/stats.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,11 @@
const { promisify } = require('util')
const stream = require('stream')

const { Transform } = stream
const { formatYYYMMDDDate } = require('./util')

const { Transform } = stream
const pipeline = promisify(stream.pipeline)

const formatYYYMMDDDate = (now = new Date()) => {
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}

/**
* 90 days in milliseconds
*/
Expand Down
12 changes: 7 additions & 5 deletions src/usage.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ module.exports = ({ plans, keys, redis, stats, prefix, serialize, deserialize })
* Increment the usage for a given key.
*
* @param {string} keyValue
* @param {number} [quantity=1]
* @param {Object} options - The options for creating a plan.
* @param {number} [options.quantity=1] - The quantity to increment.
* @param {boolean} [options.throwError=true] - Throw an error if the key does not exist.
*
* @returns {Promise<{limit: number, remaining: number, reset: number, pending: Promise<void>}>}
*
Expand All @@ -23,9 +25,9 @@ module.exports = ({ plans, keys, redis, stats, prefix, serialize, deserialize })
* // }
*
*/
const increment = async (keyValue, quantity = 1) => {
const key = await keys.retrieve(keyValue)
const plan = await plans.retrieve(key.plan)
const increment = async (keyValue, { quantity = 1, throwError = true } = {}) => {
const key = await keys.retrieve(keyValue, { throwError })
const plan = await plans.retrieve(key.plan, { throwError })

let usage = deserialize(await redis.get(prefixKey(keyValue)))

Expand Down Expand Up @@ -73,7 +75,7 @@ module.exports = ({ plans, keys, redis, stats, prefix, serialize, deserialize })
* // }
*
*/
const get = async keyValue => increment(keyValue, 0)
const get = async keyValue => increment(keyValue, { quantity: 0 })
get.increment = increment
get.prefixKey = prefixKey

Expand Down
29 changes: 7 additions & 22 deletions src/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,26 +20,11 @@ const pick = (obj, keys) => {
return result
}

/**
* Assert a condition, or throw an error if the condition is falsy.
* @param {*} value - The value to assert.
* @param {string} message - The error message.
*/
const assert = (value, message) =>
value ||
(() => {
throw new TypeError(message())
})()

const assertMetadata = metadata => {
if (metadata) {
assert(isPlainObject(metadata), () => 'The metadata must be a flat object.')
Object.keys(metadata).forEach(key => {
assert(!isPlainObject(metadata[key]), () => `The metadata field '${key}' can't be an object.`)
if (metadata[key] === undefined) delete metadata[key]
})
return Object.keys(metadata).length ? metadata : undefined
}
const formatYYYMMDDDate = (now = new Date()) => {
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}

const isPlainObject = value => {
Expand All @@ -52,8 +37,8 @@ const isPlainObject = value => {
}

module.exports = {
assert,
assertMetadata,
formatYYYMMDDDate,
isPlainObject,
pick,
uid
}
22 changes: 14 additions & 8 deletions test/keys.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ testCleanup({
keys: () => Promise.all([redis.keys(openkey.keys.prefixKey('*'))])
})

test('.create # `metadata` must be a flat object', async t => {
test('.create # error if `metadata` is not a flat object', async t => {
const error = await t.throwsAsync(openkey.keys.create({ metadata: { tier: { type: 'new' } } }))
t.is(error.message, "The metadata field 'tier' can't be an object.")
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'METADATA_INVALID')
})

test('.create # `metadata` as undefined is omitted', async t => {
Expand All @@ -31,7 +32,8 @@ test('.create # `metadata` as undefined is omitted', async t => {
test('.create # error if plan does not exist', async t => {
const error = await t.throwsAsync(openkey.keys.create({ plan: '123' }))
t.is(error.message, 'The plan `123` does not exist.')
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'PLAN_NOT_EXIST')
})

test('.create', async t => {
Expand Down Expand Up @@ -94,14 +96,16 @@ test('.update', async t => {
test('.update # error if key does not exist', async t => {
const error = await t.throwsAsync(openkey.keys.update('value'))
t.is(error.message, 'The key `value` does not exist.')
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'KEY_NOT_EXIST')
})

test('.update # error if plan does not exist', async t => {
const { value } = await openkey.keys.create()
const error = await t.throwsAsync(openkey.keys.update(value, { plan: 'id' }))
t.is(error.message, 'The plan `id` does not exist.')
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'PLAN_NOT_EXIST')
})

test('.update # add a plan', async t => {
Expand Down Expand Up @@ -131,11 +135,12 @@ test('.update # add metadata', async t => {
}
})

test('.update # metadata must be a flat object', async t => {
test('.update # error is metadata is not a flat object', async t => {
const { value } = await openkey.keys.create()
const error = await t.throwsAsync(openkey.keys.update(value, { metadata: { email: { cc: '[email protected]' } } }))
t.is(error.message, "The metadata field 'email' can't be an object.")
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'METADATA_INVALID')
})

test('.update # metadata as undefined is omitted', async t => {
Expand Down Expand Up @@ -192,5 +197,6 @@ test('.del # error if key does not exist', async t => {
const error = await t.throwsAsync(openkey.keys.del('key_id'))

t.is(error.message, 'The key `key_id` does not exist.')
t.is(error.name, 'TypeError')
t.is(error.name, 'OpenKeyError')
t.is(error.code, 'KEY_NOT_EXIST')
})
Loading
Loading