Skip to content
Open
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
78 changes: 78 additions & 0 deletions docs/codedocs/api-reference/debounce.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
title: "Debounce"
description: "API reference for the Debounce class implemented in src/debounce.ts."
---

The `Debounce` class provides a distributed debounce across instances using a shared Redis counter. It is implemented in `src/debounce.ts`. In this repository, it is not re-exported from `src/index.ts`, so verify your package exports before importing from the root.

## Constructor
```typescript
new Debounce(config: DebounceConfig)
```

| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` | — | Unique Redis key for the debounce window. |
| `redis` | `Redis` | — | Upstash Redis client instance. |
| `wait` | `number` | `1000` | Window size in milliseconds. |
| `callback` | `(...args: any[]) => any` | — | Function to run after the debounce window. |

**Example**
```typescript filename="debounce-ctor.ts"
import { Debounce } from "@upstash/lock";
import { Redis } from "@upstash/redis";

const debounced = new Debounce({
id: "events:search",
redis: Redis.fromEnv(),
wait: 1000,
callback: (query: string) => {
console.log("search", query);
},
});
```

## Methods

### `call`
Triggers the debounce flow. The callback runs only if this call is still the most recent after `wait` milliseconds.

```typescript
call(...args: any[]): Promise<void>
```

**Example**
```typescript filename="debounce-call.ts"
await debounced.call("upstash");
await debounced.call("lock");
```

## Behavior Notes
`call()` always waits the full `wait` duration before deciding whether to execute the callback. If you need immediate execution on the first call and suppression of subsequent calls, you should wrap the callback with your own “leading edge” logic. The implementation is intentionally minimal and uses Redis `INCR` and `GET` only, which keeps the number of round-trips low but provides no visibility into how many calls were suppressed. Also note that the last invocation wins: the arguments used to execute the callback are the ones provided by the most recent call that survives the window.

**Example: passing multiple arguments**
```typescript filename="debounce-multi-args.ts"
await debounced.call({ id: "evt_1" }, "webhook");
```

## Usage Pattern
```typescript filename="debounce-pattern.ts"
import { Debounce } from "@upstash/lock";
import { Redis } from "@upstash/redis";

const redis = Redis.fromEnv();
const debounced = new Debounce({
id: "webhooks:ingest",
redis,
wait: 2000,
callback: async (payload: { id: string }) => {
await ingestWebhook(payload);
},
});

export async function handleWebhook(payload: { id: string }) {
await debounced.call(payload);
}
```

Related pages: [Distributed Debounce](../distributed-debounce), [Types](../types).
131 changes: 131 additions & 0 deletions docs/codedocs/api-reference/lock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
---
title: "Lock"
description: "API reference for the Lock class in @upstash/lock."
---

The `Lock` class provides a best-effort distributed lock backed by Upstash Redis. It is defined in `src/lock.ts` and re-exported from `src/index.ts`.

## Constructor
```typescript
new Lock(config: LockCreateConfig)
```

| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` | — | Unique Redis key for the lock. Use a stable, namespaced identifier. |
| `redis` | `Redis` | — | Upstash Redis client instance. |
| `lease` | `number` | `10000` | Lease duration in milliseconds. |
| `retry.attempts` | `number` | `3` | Number of acquire attempts before giving up. |
| `retry.delay` | `number` | `100` | Delay between attempts in milliseconds. |

**Example**
```typescript filename="lock-ctor.ts"
import { Lock } from "@upstash/lock";
import { Redis } from "@upstash/redis";

const lock = new Lock({
id: "jobs:cleanup",
redis: Redis.fromEnv(),
lease: 5_000,
retry: { attempts: 2, delay: 250 },
});
```

## Methods

### `acquire`
Attempts to acquire the lock. Returns `true` on success, `false` otherwise.

```typescript
acquire(config?: LockAcquireConfig): Promise<boolean>
```

| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `config.lease` | `number \| undefined` | — | Overrides the instance lease for this acquisition. |
| `config.retry.attempts` | `number \| undefined` | — | Overrides retry attempts for this acquisition. |
| `config.retry.delay` | `number \| undefined` | — | Overrides retry delay for this acquisition. |
| `config.uuid` | `string \| undefined` | — | UUID to use instead of `crypto.randomUUID()`. |

**Example**
```typescript filename="lock-acquire.ts"
const acquired = await lock.acquire({
lease: 15_000,
retry: { attempts: 5, delay: 200 },
});
```

### `release`
Safely releases the lock if the UUID matches the stored Redis value. Returns `true` if the key was deleted.

```typescript
release(): Promise<boolean>
```

**Example**
```typescript filename="lock-release.ts"
try {
await doWork();
} finally {
await lock.release();
}
```

### `extend`
Extends the current lease by the given amount in milliseconds. Returns `true` if the TTL was updated.

```typescript
extend(amt: number): Promise<boolean>
```

**Example**
```typescript filename="lock-extend.ts"
const ok = await lock.extend(10_000);
if (!ok) {
throw new Error("lost lock");
}
```

### `getStatus`
Returns `"ACQUIRED"` if the lock’s UUID matches Redis, otherwise `"FREE"`.

```typescript
getStatus(): Promise<LockStatus>
```

**Example**
```typescript filename="lock-status.ts"
const status = await lock.getStatus();
if (status === "FREE") {
console.log("not held");
}
```

### `id`
Read-only property returning the Redis key for this lock.

```typescript
const key: string = lock.id;
```

## Usage Pattern
```typescript filename="lock-pattern.ts"
import { Lock } from "@upstash/lock";
import { Redis } from "@upstash/redis";

const redis = Redis.fromEnv();

export async function runOnce() {
const lock = new Lock({ id: "jobs:daily", redis, lease: 10_000 });

if (!(await lock.acquire())) return;

try {
await doWork();
} finally {
await lock.release();
}
}
```

Related pages: [Lock Lifecycle](../lock-lifecycle), [Lease and Retry](../lease-and-retry), [Types](../types).
53 changes: 53 additions & 0 deletions docs/codedocs/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
title: "Architecture"
description: "Internal structure of @upstash/lock and how locking and debouncing operations flow through Redis."
---

This library is intentionally small. The implementation lives in two core classes and a shared types module, with a single entry point that re-exports the public surface. The design prioritizes predictable Redis operations and minimal moving parts so the behavior is easy to reason about.

```mermaid
graph TD
A[index.ts] --> B[Lock]
A --> C[Types]
B --> D[Redis SET NX PX]
B --> E[Redis EVAL: release]
B --> F[Redis EVAL: extend]
C --> B
G[Debounce] --> H[Redis INCR]
G --> I[Redis GET]
```

**Key Design Decisions**
- **Single-key lock per critical section.** In `src/lock.ts`, each lock instance operates on exactly one Redis key (`config.id`). This keeps state minimal and allows `SET NX PX` to be the authoritative acquisition check. The key name becomes the coordination point for all instances.
- **UUID ownership tracking.** The lock stores a UUID in Redis when acquired and keeps the same UUID in memory (`config.UUID`). `release()` and `extend()` use that UUID to guard against releasing or extending another instance’s lock. You can see this in the Lua scripts inside `src/lock.ts`.
- **Best-effort safety, not consensus.** The README explicitly discourages using this for correctness guarantees like leader election. The code mirrors that: no fencing tokens, no quorum, and no clock synchronization. This is a pragmatic trade-off for simplicity and serverless suitability.
- **Lua for atomic operations.** Redis does not provide a built-in “compare-and-delete” or “compare-and-extend” command. The library uses `eval` scripts to ensure those operations are atomic (`release()` and `extend()` in `src/lock.ts`).
- **Distributed debounce via a counter.** `src/debounce.ts` uses `INCR` and `GET` with a wait delay to determine the “last” invocation in a window. It is intentionally simple and does not track per-caller state.

**How the Pieces Fit Together**
The public entry point is `src/index.ts`, which re-exports the `Lock` class and all exported types from `src/types.ts`. The `Lock` class is the primary API. `Debounce` is defined in `src/debounce.ts` and uses the same Redis client type, but it is not re-exported from `src/index.ts` in this repository.

**Lock lifecycle data flow**
1. The caller constructs a `Lock` with a Redis client and key name. The constructor normalizes defaults (`lease`, `retry.attempts`, `retry.delay`) in `src/lock.ts`.
2. `acquire()` attempts a Redis `SET` with `NX` (only set if the key does not exist) and `PX` (set TTL in milliseconds). If it returns `OK`, the lock is acquired and the UUID is stored in memory.
3. If acquisition fails, the method waits `retry.delay` milliseconds and tries again up to `retry.attempts` times.
4. `release()` executes a Lua script that deletes the key only if the stored UUID matches the instance UUID, preventing accidental release by a different instance.
5. `extend()` executes a Lua script that reads the current TTL and extends it by a requested amount only if ownership still matches.

**Debounce data flow**
1. Each call to `Debounce.call()` increments a counter at the debounce key using `INCR`.
2. The method sleeps for the configured `wait` duration.
3. It reads the current value from Redis and compares it to the value returned by `INCR` earlier. If the value changed, another call happened, so the callback is skipped. If it is unchanged, the callback runs.

**Why this architecture works for serverless**
- No in-memory coordination; all coordination happens in Redis and survives cold starts.
- One key per lock/debounce, so the Redis footprint is predictable.
- A single `Redis` client instance (from `@upstash/redis`) is sufficient across all operations.

If you want to see how these primitives map to user workflows, the next pages cover the lock lifecycle and debounce mechanics in detail.

<Cards>
<Card title="Lock Lifecycle" href="/docs/lock-lifecycle">How acquisition, release, and status checking work internally.</Card>
<Card title="Lease and Retry" href="/docs/lease-and-retry">Configuration trade-offs that impact reliability.</Card>
<Card title="Distributed Debounce" href="/docs/distributed-debounce">How the debounce counter algorithm works.</Card>
</Cards>
94 changes: 94 additions & 0 deletions docs/codedocs/distributed-debounce.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
---
title: "Distributed Debounce"
description: "How the shared counter algorithm collapses bursts of calls across instances."
---

A distributed debounce ensures that a callback runs only once after a burst of calls, even when those calls happen on different machines or serverless invocations. The implementation in `src/debounce.ts` uses a single Redis counter key to decide which call gets to run the callback.

```mermaid
sequenceDiagram
participant A as Instance A
participant B as Instance B
participant Redis as Upstash Redis
A->>Redis: INCR debounce:key (returns 41)
B->>Redis: INCR debounce:key (returns 42)
A->>A: wait 1000ms
B->>B: wait 1000ms
A->>Redis: GET debounce:key (returns 42)
A-->>A: skip callback
B->>Redis: GET debounce:key (returns 42)
B-->>B: run callback
```

**What the concept is**
A distributed debounce is a time-windowed gating mechanism. It does not enforce mutual exclusion; instead, it ensures only the last call in a window executes a callback. This is useful for collapsing bursts of events like webhooks, user typing, or repetitive cron triggers.

**Why it exists**
In serverless and multi-instance environments, local debounce utilities do not coordinate across instances. This implementation uses Redis as a shared counter so that all callers can observe the same “latest” invocation.

**How it works internally**
- `call()` increments a counter with `INCR` and stores the returned integer in a local variable (`thisTaskIncr`).
- It sleeps for the configured `wait` duration using `setTimeout`.
- After the wait, it reads the current counter with `GET`.
- If the counter value is still equal to `thisTaskIncr`, this call was the most recent, and it executes the callback. If the counter changed, a newer call happened and this call exits without running the callback.
- The code is intentionally minimal and relies on Redis atomicity for `INCR` and consistent `GET` reads.

**How it relates to other concepts**
- The **Lock Lifecycle** concept guarantees mutual exclusion per key. Debounce does not and does not track ownership.
- The **Lease and Retry** concept is about acquiring a lock under contention. Debounce is about deferring execution until a quiet period.

**Basic usage**
```typescript filename="debounce-basic.ts"
import { Debounce } from "@upstash/lock";
import { Redis } from "@upstash/redis";

const redis = Redis.fromEnv();

const debounced = new Debounce({
id: "events:search",
redis,
wait: 1000,
callback: (query: string) => {
console.log("search", query);
},
});

await debounced.call("upstash");
```

**Advanced usage: async callback and multiple arguments**
```typescript filename="debounce-advanced.ts"
import { Debounce } from "@upstash/lock";
import { Redis } from "@upstash/redis";

const redis = Redis.fromEnv();

const debounced = new Debounce({
id: "events:ingest",
redis,
wait: 1500,
callback: async (payload: { id: string }, source: string) => {
await writeToWarehouse(payload, source);
},
});

await debounced.call({ id: "evt_123" }, "webhook");
await debounced.call({ id: "evt_456" }, "webhook");
```

<Callout type="warn">
In this repository, `Debounce` is defined in `src/debounce.ts` but not re-exported from `src/index.ts`. If your published package does not expose `Debounce`, you will need to import it from the build output or upgrade to a version that re-exports it. Verify your version’s exports before relying on this API.
</Callout>

<Callout type="warn">
The callback always runs after a full `wait` delay, even for the last call. If you need immediate execution followed by suppression, this is not the right algorithm.
</Callout>

<Accordions>
<Accordion title="Counter-based debounce vs timestamp-based debounce">
A counter-based debounce is very cheap: one `INCR` and one `GET` per call. It does not require clock synchronization or server time, which makes it a good fit for serverless runtimes. The trade-off is that it cannot tell you how much time has passed since the last call, only whether a newer call happened in the window. A timestamp-based approach can provide richer metrics but is more complex and needs additional logic to handle clock skew.
</Accordion>
<Accordion title="One shared key vs per-tenant keys">
Using one shared key for all debounced actions is simple but will merge unrelated events into a single debounce window. For real applications, you should use a key that includes the logical scope, such as a user ID, job type, or tenant. This increases Redis key count but avoids accidental suppression across unrelated work. Keep keys short and predictable to make monitoring easier.
</Accordion>
</Accordions>
Loading