Skip to content

Commit

Permalink
Initial version
Browse files Browse the repository at this point in the history
  • Loading branch information
dimikot committed Aug 24, 2024
0 parents commit 9dc042c
Show file tree
Hide file tree
Showing 41 changed files with 2,240 additions and 0 deletions.
443 changes: 443 additions & 0 deletions .eslintrc.base.js

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"use strict";
module.exports = require("./.eslintrc.base.js")(__dirname, {
"import/no-extraneous-dependencies": "error",
"@typescript-eslint/explicit-function-return-type": [
"error",
{ allowExpressions: true },
],
"lodash/import-scope": ["error", "method"],
});
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: "CI Full Run"
on:
pull_request:
branches:
- main
- grok/*/*
push:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: ["20.x"]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- run: npm install -g pnpm --force
- run: pnpm install
- run: pnpm run build
- run: pnpm run lint
- run: pnpm run test
36 changes: 36 additions & 0 deletions .github/workflows/semgrep.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Name of this GitHub Actions workflow.
name: Semgrep

on:
# Scan changed files in PRs (diff-aware scanning):
pull_request:
branches: ['main']

# Schedule the CI job (this method uses cron syntax):
schedule:
- cron: '0 0 * * MON-FRI'

jobs:
semgrep:
# User definable name of this GitHub Actions job.
name: Scan
# If you are self-hosting, change the following `runs-on` value:
runs-on: ubuntu-latest

container:
# A Docker image with Semgrep installed. Do not change this.
image: returntocorp/semgrep@sha256:6c7ab81e4d1fd25a09f89f1bd52c984ce107c6ff33affef6ca3bc626a4cc479b

# Skip any PR created by dependabot to avoid permission issues:
if: (github.actor != 'dependabot[bot]')

steps:
# Fetch project source with GitHub Actions Checkout.
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
# Run the "semgrep ci" command on the command line of the docker image.
- run: semgrep ci
env:
# Connect to Semgrep Cloud Platform through your SEMGREP_APP_TOKEN.
# Generate a token from Semgrep Cloud Platform > Settings
# and add it to your GitHub secrets.
SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
dist

# Common in both .gitignore and .npmignore
node_modules
package-lock.json
yarn.lock
pnpm-lock.yaml
.DS_Store
*.log
*.tmp
*.swp
14 changes: 14 additions & 0 deletions .npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
dist/__tests__
dist/**/__tests__
dist/tsconfig.tsbuildinfo
.npmrc

# Common in both .gitignore and .npmignore
node_modules
package-lock.json
yarn.lock
pnpm-lock.yaml
.DS_Store
*.log
*.tmp
*.swp
8 changes: 8 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"overrides": [
{
"files": ["*.ts", "*.tsx"],
"options": { "parser": "typescript" }
}
]
}
8 changes: 8 additions & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"mhutchie.git-graph",
"trentrand.git-rebase-shortcuts"
]
}
20 changes: 20 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "git grok: push local commits as individual PRs",
"detail": "Install git-grok first: https://github.com/dimikot/git-grok",
"type": "shell",
"command": "git grok",
"problemMatcher": [],
"hide": false
},
{
"label": "git rebase --interactive",
"detail": "Opens a UI for interactive rebase (install \"Git rebase shortcuts\" extension).",
"type": "shell",
"command": "GIT_EDITOR=\"code --wait\" git rebase -i",
"problemMatcher": []
}
]
}
22 changes: 22 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
MIT License

Copyright (c) 2023 Mango Technologies, Inc. DBA ClickUp

Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:

The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
164 changes: 164 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# distributed-pacer: A concurrency aware Redis-backed rate limiter with pacing delay prediction and Token Bucket bursts handling

See also [Full API documentation](https://github.com/clickup/distributed-pacer/blob/master/docs/modules.md).

![CI run](https://github.com/clickup/distributed-pacer/actions/workflows/ci.yml/badge.svg?branch=main)

## Pacing

Pacing controls the rate at which concurrent clients perform some operations. It
introduces deliberate delays between client requests. The primary goal of pacing
is to ensure that the rate of operations (such as outgoing requests) does not
exceed a certain threshold (e.g. QPS - "queries per second").

Use Cases:

- **Outgoing Requests.** Pacing is typically used by clients to manage the rate
at which they send requests to an external service that imposes rate limits.
- **Load Management.** In scenarios where the external service might be
sensitive to sudden spikes in traffic, pacing helps in distributing the load
more evenly over time.

Notice that the term "pacing" is typically used for outgoing requests from
clients (to slow down the requests flow without dropping them), whilst "rate
limiting" is for incoming requests on servers (to reject non-conforming
requests).

### Pacing Usage Example

```ts
import { DistributedPacer } from "@clickup/distributed-pacer";
import { Redis } from "ioredis";
import { setTimeout } from "timers/promises";

const myIoRedis = new Redis();

async function mySendApiRequest() {
// sends an API request somewhere
}

async function myWorkerRunningOnMultipleMachines() {
while (true) {
const lightweightPacer = new DistributedPacer(myIoRedis, {
key: "myKey",
qps: 10,
maxBurst: 1, // optional
burstAllowanceFactor: 0.5, // optional
});
const outcome = await lightweightPacer.pace(1 /* weight */);

console.log(outcome.reason);
await setTimeout(outcome.delayMs);

await mySendApiRequest();
}
}
```

### How it Works

`DistributedPacer` spreads the requests issued by some concurrent workers or
processes uniformly into the future to satisfy the desired downstream QPS
(queries per second) exactly. The implementation is inspired by Leaky Bucket for
Queues algorithm.

The general use case is to introduce some artificial back-pressure when sending
requests to external services, to avoid overloading them, e.g.:

- Pacing outgoing requests to some external API to meet its rate limits.
- Protecting the local database from overloading with concurrent writes done by
multiple workers.

Imagine we have a time machine, and we can send requests (events) into the exact
provided moment of time in the future. To send a request into the future, the
Lua script in Redis returns that moment's timestamp, and then the worker needs
to call delay() to wake up at that moment. We also store the last moment of the
future to where we sent a previous request, so next requests coming (if they
come too quickly) will be sent further and further away.

Another analogy is booking a meeting in the calendar. When a new request
arrives, it's not executed immediately, but instead scheduled in the calendar
according to the QPS allowance.

Thus, after the returned `delayMs` is awaited, the request will happen in at
least 1/QPS seconds after the previous request; thus, it will satisfy the target
QPS. Also, if there were no requests in the past within 1/QPS seconds from the
present time, then `delayMs` returned will be 0.

### Bursts Allowance

Imagine that each call to `pace(weight)` adds `weight` of water to the bucket of
`maxBurst` volume, and every second, `1/qps*burstAllowanceFactor` of water leaks
out of the bucket at a constant rate (but only when the pacer is idle, i.e.
there are no requests scheduled to the future on top of the bucket). If the
bucket is not yet full (its watermark is below `maxBurst` level), then the
returned `delayMs` will be 0, so the worker can proceed with the request
immediately. Otherwise, pacing will start to happen. I.e. we pace only the
requests which cause the bucket to overflow (Leaky Bucket algorithm).

The default value of `burstAllowanceFactor` is less than 1, which forces the
burst allowance to be earned slightly slower than the target QPS.

## Rate Limiting

Although pacing is the primary use case for this module, it also supports "rate
limiting" mode, where it's expected that requests out of quota will be rejected
(instead of being delayed). This is useful for handling *incoming* requests on
servers (as opposed to pacing, where the requests *originate* from workers).

To use `DistributedPacer` in rate limiting mode, call `rateLimit()` method on
it. Logically, it works exactly the same way as `pace()`, but when it returns a
non-zero `delayMs`, it doesn't alter the state in Redis, assuming that the
request will be rejected and won't contribute to `maxBurst` allowance.

To utilize the power of Leaky Bucket algorithm (or its equivalent here, Token
Bucket), pass a nonzero value to `maxBurst`. With the default value (which is
0), no bursts will be allowed, so the requests will need to come in not less
than `1/qps` seconds in between.

### Rate Limiting Usage Example

Disclaimer: here, we use Express just as an illustration: there is obviously a
ready middleware module for Express rate limiting use case. Use
`DistributedPacer` in other applications, like GraphQL processing, WebSockets,
internal IO services etc.

```ts
import { DistributedPacer } from "@clickup/distributed-pacer";
import { Redis } from "ioredis";
import express from "express";

const myIoRedis = new Redis();

express()
.get('/', (req, res) => {
const lightweightPacer = new DistributedPacer(myIoRedis, {
key: "myKey",
qps: 10,
maxBurst: 20,
});
const outcome = await lightweightPacer.rateLimit(1 /* weight */);

if (outcome.delay > 0) {
console.log(outcome.reason);
res.status(429).send(`Rate limited, try again in ${outcome.delay} ms.`);
} else {
res.send("Hello World!")
}
})
.listen(port);
```

## Performance

This module is cheap and can be put on a critical path in your application.

`DistributedPacer` objects are lightweight, so you can create them as often as
you want (even on every request).

Each call to `pace()` or `rateLimit()` causes one round-trip to Redis (it runs a
custom Lua function), and the timing of that call is O(1).

If you need to use multiple keys, you can use Redis in cluster mode to spread
those keys across multiple Redis nodes (pass an instance of `Redis.Cluster` to
`DistributedPacer` constructor).
39 changes: 39 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Security

Keeping our clients' data secure is an absolute top priority at ClickUp. Our goal is to provide a secure environment, while also being mindful of application performance and the overall user experience.

ClickUp believes effective disclosure of security vulnerabilities requires mutual trust, respect, transparency and common good between ClickUp and Security Researchers. Together, our vigilant expertise promotes the continued security and privacy of ClickUp customers, products, and services.

If you think you've found a security vulnerability in any ClickUp-owned repository, please let us know as outlined below.

ClickUp defines a security vulnerability as an unintended weakness or exposure that could be used to compromise the integrity, availability or confidentiality of our products and services.

## Our Commitment to Reporters

- **Trust**. We maintain trust and confidentiality in our professional exchanges with security researchers.
- **Respect**. We treat all researchers with respect and recognize your contribution for keeping our customers safe and secure.
- **Transparency**. We will work with you to validate and remediate reported vulnerabilities in accordance with our commitment to security and privacy.
- **Common Good**. We investigate and remediate issues in a manner consistent with protecting the safety and security of those potentially affected by a reported vulnerability.

## What We Ask of Reporters

- **Trust**. We request that you communicate about potential vulnerabilities in a responsible manner, providing sufficient time and information for our team to validate and address potential issues.
- **Respect**. We request that researchers make every effort to avoid privacy violations, degradation of user experience, disruption to production systems, and destruction of data during security testing.
- **Transparency**. We request that researchers provide the technical details and background necessary for our team to identify and validate reported issues, using the form below.
- **Common Good**. We request that researchers act for the common good, protecting user privacy and security by refraining from publicly disclosing unverified vulnerabilities until our team has had time to validate and address reported issues.

## Vulnerability Reporting

ClickUp recommends that you share the details of any suspected vulnerabilities across any asset owned, controlled, or operated by ClickUp (or that would reasonably impact the security of ClickUp and our users) using our vulnerability disclosure form at <http://clickup.com/bug-bounty>. The ClickUp Security team will acknowledge receipt of each valid vulnerability report, conduct a thorough investigation, and then take appropriate action for resolution.

## Safe Harbor

When conducting vulnerability research according to this policy, we consider this research to be:

- Authorized in accordance with the Computer Fraud and Abuse Act (CFAA) (and/or similar state laws), and we will not initiate or support legal action against you for accidental, good faith violations of this policy;
- Exempt from the Digital Millennium Copyright Act (DMCA), and we will not bring a claim against you for circumvention of technology controls;
- Exempt from restrictions in our Terms & Conditions that would interfere with conducting security research, and we waive those restrictions on a limited basis for work done under this policy; and
- Lawful, helpful to the overall security of the Internet, and conducted in good faith.
- You are expected, as always, to comply with all applicable laws.

If at any time you have concerns or are uncertain whether your security research is consistent with this policy, please inquire via <[email protected]> before going any further.
23 changes: 23 additions & 0 deletions docker-compose.redis.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
services:
redis:
image: bitnami/redis:6.2.11
ports:
- ${REDISCLI_PORT:?err}:6379
environment:
- "ALLOW_EMPTY_PASSWORD=yes"
- "REDIS_PASSWORD=bitnami"
command:
- bash
- "-c"
- |
/opt/bitnami/scripts/redis/run.sh \
--appendonly yes \
--auto-aof-rewrite-percentage 100 \
--auto-aof-rewrite-min-size 104857600 \
--maxclients 40000 \
--loglevel warning
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 1s
timeout: 20s
retries: 10
1 change: 1 addition & 0 deletions docs/.nojekyll
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false.
Loading

0 comments on commit 9dc042c

Please sign in to comment.