Files
Malte Ubl c674284885 [RFC] add concurrency strategies for overlapping messages (queue, debounce, concurrent) (#277)
* feat: add concurrency strategies for overlapping messages (queue, debounce, concurrent)

## Problem

When multiple messages arrive on the same thread while a handler is still
processing, the SDK has only one behavior: **lock-and-drop**. The incoming
message is silently discarded (or force-released, which creates uncontrolled
concurrency). This is insufficient for most real-world use cases:

- **AI chatbots** lose user follow-up messages sent while the model is streaming
- **Customer support bots** miss messages entirely, breaking conversation flow
- **Collaborative editing bots** need to coalesce rapid corrections into one action

## Solution

Introduce a new `concurrency` option on `ChatConfig` with four strategies:

### `'drop'` (default, backward-compatible)

Existing behavior. Lock acquired or `LockError` thrown. No changes.

### `'queue'`

Messages that arrive while a handler is running are enqueued in the state
adapter. When the current handler finishes, the queue is drained: **only the
latest message is dispatched**, with all intermediate messages provided as
`context.skipped`. This gives the handler full visibility into what happened
while it was busy, without forcing it to re-process every message sequentially.

```typescript
const chat = new Chat({
  concurrency: 'queue',
  // ...
});

chat.onNewMention(async (thread, message, context) => {
  if (context && context.skipped.length > 0) {
    // "You sent 4 messages while I was thinking. Responding to your latest."
    const allMessages = [...context.skipped, message];
    // Pass all messages to the LLM for full context
  }
});
```

Flow:
```
A arrives  → acquire lock → process A
B arrives  → lock busy → enqueue B
C arrives  → lock busy → enqueue C
D arrives  → lock busy → enqueue D
A done     → drain: [B, C, D] → handler(D, { skipped: [B, C] })
D done     → queue empty → release lock
```

### `'debounce'`

Every message (including the first) starts or resets a debounce timer. Only the
**final message in a burst** is processed. The lock-holding function stays alive
through `waitUntil` during the debounce window.

```typescript
const chat = new Chat({
  concurrency: { strategy: 'debounce', debounceMs: 1500 },
  // ...
});
```

Flow:
```
A arrives  → acquire lock → store A as pending → sleep(debounceMs)
B arrives  → lock busy → overwrite pending with B (A dropped)
C arrives  → lock busy → overwrite pending with C (B dropped)
             ... debounceMs elapses with no new message ...
           → process C → release lock
```

### `'concurrent'`

No locking at all. Every message is processed immediately in its own handler
invocation. Suitable for stateless handlers (lookups, translations) where
thread ordering doesn't matter.

```typescript
const chat = new Chat({
  concurrency: 'concurrent',
  // ...
});
```

## API Surface

### ChatConfig

```typescript
interface ChatConfig {
  concurrency?: ConcurrencyStrategy | ConcurrencyConfig;
  /** @deprecated Use `concurrency` instead */
  onLockConflict?: 'force' | 'drop' | ((threadId, message) => ...);
}

type ConcurrencyStrategy = 'drop' | 'queue' | 'debounce' | 'concurrent';

interface ConcurrencyConfig {
  strategy: ConcurrencyStrategy;
  maxQueueSize?: number;           // Default: 10
  onQueueFull?: 'drop-oldest' | 'drop-newest';  // Default: 'drop-oldest'
  queueEntryTtlMs?: number;        // Default: 90_000 (90s)
  debounceMs?: number;              // Default: 1500
  maxConcurrent?: number;           // Default: Infinity
}
```

### MessageContext (new, passed to handlers)

```typescript
interface MessageContext {
  skipped: Message[];               // Intermediate messages, chronological
  totalSinceLastHandler: number;    // skipped.length + 1
}
```

All handler types (`MentionHandler`, `MessageHandler`, `SubscribedMessageHandler`,
`DirectMessageHandler`) now accept an optional `MessageContext` as their last
parameter. Existing handlers that don't use it are unaffected.

### StateAdapter (new methods)

```typescript
interface StateAdapter {
  enqueue(threadId: string, entry: QueueEntry, maxSize: number): Promise<number>;
  dequeue(threadId: string): Promise<QueueEntry | null>;
  queueDepth(threadId: string): Promise<number>;
}
```

Implemented across all four state adapters:
- **MemoryStateAdapter**: in-process array
- **RedisStateAdapter**: Lua script (RPUSH + LTRIM + PEXPIRE)
- **IoRedisStateAdapter**: same Lua approach
- **PostgresStateAdapter**: new `chat_state_queues` table with atomic dequeue

## Architecture

`handleIncomingMessage` was refactored into composable pieces:

- `dispatchToHandlers()` — shared handler dispatch logic (mention detection,
  subscription routing, pattern matching). Extracted from the old monolithic
  method so all strategies reuse it.
- `handleDrop()` — original lock-or-fail path (preserves `onLockConflict` compat)
- `handleQueueOrDebounce()` — enqueue if busy, drain or debounce after
- `handleConcurrent()` — skip locking entirely
- `drainQueue()` — collect all pending, dispatch latest with skipped context
- `debounceLoop()` — sleep/check/repeat until no new messages arrive

## Queue Entry TTL

Queued messages have a configurable TTL (`queueEntryTtlMs`, default 90s). Stale
entries are discarded on dequeue with a `message-expired` log event. This
prevents unbounded accumulation and ensures handlers don't process messages
that are no longer relevant.

## Observability

All strategies emit structured log events at `info` level:

| Event                 | Strategy         | Data                                  |
|-----------------------|------------------|---------------------------------------|
| `message-queued`      | queue            | threadId, messageId, queueDepth       |
| `message-dequeued`    | queue, debounce  | threadId, messageId, skippedCount     |
| `message-dropped`     | drop, queue      | threadId, messageId, reason           |
| `message-expired`     | queue, debounce  | threadId, messageId                   |
| `message-superseded`  | debounce         | threadId, droppedId                   |
| `message-debouncing`  | debounce         | threadId, messageId, debounceMs       |
| `message-debounce-reset` | debounce      | threadId, messageId                   |

## Backward Compatibility

- Default remains `'drop'` — zero breaking changes for existing users
- `onLockConflict` continues to work but is marked `@deprecated`
- Handler signatures are backward-compatible (new `context` param is optional)
- Deduplication always runs regardless of strategy

## Files Changed

- `packages/chat/src/types.ts` — new types, updated handler signatures
- `packages/chat/src/chat.ts` — strategy routing, drain/debounce loops
- `packages/chat/src/index.ts` — export new types
- `packages/chat/src/mock-adapter.ts` — queue methods for test mock
- `packages/state-memory/src/index.ts` — in-memory queue
- `packages/state-redis/src/index.ts` — Redis queue (Lua)
- `packages/state-ioredis/src/index.ts` — ioredis queue (Lua)
- `packages/state-pg/src/index.ts` — Postgres queue table
- `packages/chat/src/chat.test.ts` — tests for queue, debounce, concurrent

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: comprehensive test coverage for concurrency strategies and queue methods

Add tests across all state adapters and the Chat class:

**MemoryStateAdapter** (8 new tests):
- enqueue/dequeue single entry
- dequeue from empty queue returns null
- dequeue from nonexistent thread returns null
- queueDepth returns 0 for empty queue
- FIFO ordering across multiple entries
- maxSize trimming (keeps newest)
- maxSize=1 debounce behavior (last-write-wins)
- queue isolation by thread
- queue cleared on disconnect

**PostgresStateAdapter** (8 new tests):
- INSERT query for enqueue
- overflow trimming query
- depth return value
- parsed entry from dequeue
- null from empty dequeue
- atomic DELETE-RETURNING for dequeue
- queueDepth return value
- zero depth for empty queue

**RedisStateAdapter / IoRedisStateAdapter** (3+3 existence checks):
- enqueue, dequeue, queueDepth method existence

**Chat concurrency** (5 new tests):
- drop-newest policy when queue is full
- drop-oldest policy evicts oldest entries
- expired entries skipped during drain
- onNewMessage pattern handlers receive context
- onSubscribedMessage handlers receive skipped context

Total new tests: 27 (780 chat + 33 memory + 59 pg)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Address feedback

* Support a channel locking strategy, make it default for WhatsApp and Telegram

* docs: fix typo "Committment" → "Commitment" (#274)

Co-authored-by: Arif Kobel <arif.kobel@phorax.com>

* Add webhook verification to GChat (#287)

- Issues a warning if required env vars are not present (also for telegram)
- Makes telegram use a time-safe verifier

* Make adapters depend on `chat` as a real dep (#289)

Without this, changeset will make any dep change a major change

* Version Packages (#290)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* Fix serialization

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Arif Kobel <102538661+ArifKobel@users.noreply.github.com>
Co-authored-by: Arif Kobel <arif.kobel@phorax.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-24 13:46:38 -07:00
..