Skip to content

Commit 6e32338

Browse files
egeominotticlaude
andcommitted
feat(sdk-ts): enterprise upgrade — observability, backpressure, ACKB batching, pool, typed responses (0.1.6)
All additive and backward-compatible; defaults unchanged (silent observability, unbounded backpressure, ackBatch off, poolSize 1). - Observability: injectable Logger + onTelemetry sink (zero hard deps); Connection extends EventEmitter emitting connect/disconnect/ reconnect_scheduled; per-command latency, timeout, auth and backpressure telemetry. Consumer sink/logger errors are isolated (Telemetry.safely) so they can never break the transport hot path. - Backpressure: optional maxInFlight bounds in-flight commands with a waiter queue (released on settle, cleared on teardown). - ACK batching: opt-in Worker ackBatch coalesces ACKs into ACKB round-trips; a job stays active (lock renewed) until its batch is confirmed; slots are freed BEFORE event emits and the batcher settles each item exactly once even when a callback throws (adversarially verified error-path tests included). - ConnectionPool: round-robin producer-side pool (Queue poolSize); workers intentionally stay single-connection. - Typed responses: generic call<R> over exported response shapes; removed internal `as Record<string, unknown>` casts. - CI: .github/workflows/sdk.yml runs both SDK suites (TS on bun+node, Python 3.10/3.12) against a real server on every sdk/src change; sdk-release.yml publishes with npm provenance, gated on the e2e suite. e2e 100/100 on bun AND node; biome clean; build clean. Skeptic PASS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 968fc1e commit 6e32338

25 files changed

Lines changed: 1180 additions & 81 deletions

.github/workflows/sdk-release.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
name: SDK Release (npm)
2+
3+
# Publishes the TypeScript SDK to npm with build provenance
4+
# (https://docs.npmjs.com/generating-provenance-statements). Trigger by pushing
5+
# a tag like `sdk-ts-v1.0.0`. Requires an NPM_TOKEN secret (automation token).
6+
7+
on:
8+
push:
9+
tags: ['sdk-ts-v*']
10+
workflow_dispatch:
11+
12+
permissions:
13+
contents: read
14+
id-token: write # required for npm provenance
15+
16+
jobs:
17+
publish:
18+
runs-on: ubuntu-latest
19+
steps:
20+
- uses: actions/checkout@v4
21+
- uses: oven-sh/setup-bun@v2
22+
with:
23+
bun-version: latest
24+
- uses: actions/setup-node@v4
25+
with:
26+
node-version: 22
27+
registry-url: https://registry.npmjs.org
28+
- name: Install server deps (for bun src/main.ts)
29+
run: bun install --frozen-lockfile || bun install
30+
- name: Build + verify SDK
31+
working-directory: sdk/typescript
32+
run: |
33+
bun install --frozen-lockfile || bun install
34+
bun run build
35+
bun run check
36+
- name: Test gate (never publish an untested tag)
37+
working-directory: sdk/typescript
38+
run: |
39+
bun tests/integration.ts
40+
bun tests/e2e.ts
41+
- name: Publish with provenance
42+
working-directory: sdk/typescript
43+
run: npm publish --provenance --access public
44+
env:
45+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

.github/workflows/sdk.yml

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
name: SDK
2+
3+
# CI for the polyglot client SDKs (sdk/typescript, sdk/python). Both spawn a
4+
# real server (bun src/main.ts) and run their e2e suites against it.
5+
6+
on:
7+
push:
8+
branches: [main, master]
9+
paths: ['sdk/**', 'src/**', '.github/workflows/sdk.yml']
10+
pull_request:
11+
paths: ['sdk/**', 'src/**', '.github/workflows/sdk.yml']
12+
13+
concurrency:
14+
group: sdk-${{ github.ref }}
15+
cancel-in-progress: true
16+
17+
jobs:
18+
typescript:
19+
name: TypeScript (${{ matrix.runtime }})
20+
runs-on: ubuntu-latest
21+
strategy:
22+
fail-fast: false
23+
matrix:
24+
runtime: [bun, node]
25+
steps:
26+
- uses: actions/checkout@v4
27+
- uses: oven-sh/setup-bun@v2
28+
with:
29+
bun-version: latest
30+
- uses: actions/setup-node@v4
31+
with:
32+
node-version: 22
33+
- name: Install server deps (for bun src/main.ts)
34+
run: bun install --frozen-lockfile || bun install
35+
- name: Install + build SDK
36+
working-directory: sdk/typescript
37+
run: |
38+
bun install --frozen-lockfile || bun install
39+
bun run build
40+
- name: Lint (biome)
41+
working-directory: sdk/typescript
42+
run: bun run check
43+
- name: Integration + e2e (bun)
44+
if: matrix.runtime == 'bun'
45+
working-directory: sdk/typescript
46+
run: |
47+
bun tests/integration.ts
48+
bun tests/e2e.ts
49+
- name: Integration + e2e (node)
50+
if: matrix.runtime == 'node'
51+
working-directory: sdk/typescript
52+
run: |
53+
node --experimental-strip-types tests/integration.ts
54+
node --experimental-strip-types tests/e2e.ts
55+
56+
python:
57+
name: Python ${{ matrix.python }}
58+
runs-on: ubuntu-latest
59+
strategy:
60+
fail-fast: false
61+
matrix:
62+
python: ['3.10', '3.12']
63+
steps:
64+
- uses: actions/checkout@v4
65+
- uses: oven-sh/setup-bun@v2
66+
with:
67+
bun-version: latest
68+
- uses: actions/setup-python@v5
69+
with:
70+
python-version: ${{ matrix.python }}
71+
- name: Install server deps (for bun src/main.ts)
72+
run: bun install --frozen-lockfile || bun install
73+
- name: Set up venv + msgpack
74+
working-directory: sdk/python
75+
run: |
76+
python -m venv .venv
77+
.venv/bin/pip install --quiet msgpack
78+
- name: Integration + e2e
79+
working-directory: sdk/python
80+
run: |
81+
.venv/bin/python tests/test_integration.py
82+
.venv/bin/python tests/run_e2e.py

sdk/typescript/CHANGELOG.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,37 @@ All notable changes to `bunqueue-client` (TypeScript SDK) are documented here.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.1.6] - 2026-07-09
9+
10+
Enterprise-grade hardening. All additive and backward-compatible; defaults are
11+
unchanged (observability is silent, backpressure unbounded, ACK batching off).
12+
13+
### Added
14+
15+
- **Observability.** Every `Connection`/`Queue`/`Worker`/`FlowProducer` accepts
16+
an injectable `logger` and an `onTelemetry` sink (zero hard deps — bridge it to
17+
OpenTelemetry/Prometheus yourself). `TelemetryEvent` is a typed union covering
18+
per-command latency, connect/disconnect/reconnect, auth and backpressure.
19+
`Connection` is now an `EventEmitter` emitting `connect` / `disconnect` /
20+
`reconnect_scheduled`. Ships `noopLogger` (default) and `consoleLogger`.
21+
- **Backpressure.** `maxInFlight` bounds concurrent in-flight commands; callers
22+
park until a slot frees instead of growing memory unbounded under load.
23+
- **ACK batching.** Opt-in `Worker({ ackBatch: { enabled: true } })` coalesces
24+
completed-job ACKs into `ACKB` round-trips for higher throughput; a job stays
25+
active (lock renewed) until its batch is confirmed.
26+
- **Connection pool.** `Queue({ poolSize: N })` fans producer commands across N
27+
round-robin connections (`ConnectionPool`, producer-side; workers stay single-
28+
connection by design).
29+
- **Typed responses.** `call<R>()` is generic over the exported response shapes
30+
(`JobResponse`, `PulledJobsResponse`, `JobCountsResponse`, …); internal
31+
`as Record<string, unknown>` casts removed across the query/control/flow paths.
32+
33+
### CI
34+
35+
- GitHub Actions runs both SDK suites on every `sdk/`/`src/` change (TypeScript
36+
on Bun + Node, Python 3.10/3.12); an npm release workflow publishes with
37+
build provenance.
38+
839
## [0.1.5] - 2026-07-08
940

1041
Protocol-coherence audit against the bunqueue server. Every fix ships with a

sdk/typescript/README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,49 @@ const queue = new Queue('emails', {
272272

273273
Authentication uses server side tokens (`AUTH_TOKENS`). Transport security uses native TLS, with support for system certificate authorities, a custom CA bundle, or disabled verification for development environments.
274274

275+
## Observability
276+
277+
Inject a logger and a telemetry sink to bridge the client into your stack. There are no hard dependencies — you wire OpenTelemetry, Prometheus or your own logger. Defaults are silent.
278+
279+
```typescript
280+
import { Queue, consoleLogger, type TelemetryEvent } from 'bunqueue-client';
281+
282+
const queue = new Queue('emails', {
283+
logger: consoleLogger('info'), // or your own { debug, info, warn, error }
284+
onTelemetry: (e: TelemetryEvent) => {
285+
// per-command latency, connect/disconnect/reconnect, auth, backpressure
286+
if (e.type === 'command') metrics.observe(e.cmd, e.durationMs, e.ok);
287+
},
288+
});
289+
290+
// Connection is an EventEmitter for lifecycle hooks:
291+
queue.connection.on('reconnect_scheduled', (i) => log.warn('reconnecting', i));
292+
queue.connection.on('disconnect', () => log.warn('link down'));
293+
```
294+
295+
## Throughput and resilience
296+
297+
```typescript
298+
// Bound in-flight commands (backpressure) — parks callers instead of growing memory:
299+
const queue = new Queue('emails', { maxInFlight: 10_000 });
300+
301+
// Fan producer commands across N connections (round-robin, producer-side):
302+
const pooled = new Queue('emails', { poolSize: 4 });
303+
304+
// Batch worker ACKs into ACKB round-trips (opt-in) for high-volume consumers:
305+
const worker = new Worker('emails', process, {
306+
ackBatch: { enabled: true, maxSize: 50, maxDelayMs: 5 },
307+
});
308+
```
309+
310+
Always attach a `worker.on('error', …)` listener: per Node `EventEmitter` semantics an unhandled `error` event throws. The worker frees each job's concurrency slot before emitting, so even a throwing listener cannot degrade throughput — but the error itself is yours to observe.
311+
312+
Half-open links are detected via TCP keepalive and a consecutive-timeout teardown, so a silently dropped connection (cloud LB/NAT idle drop) recovers in seconds rather than minutes.
313+
314+
## Typed responses
315+
316+
`connection.call<R>()` and `queue.call<R>()` are generic over the exported response shapes (`JobResponse`, `PulledJobsResponse`, `JobCountsResponse`, `WaitJobResponse`, …), so raw command access is fully typed without casting.
317+
275318
## API surface
276319

277320
| Area | Capabilities |

sdk/typescript/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "bunqueue-client",
3-
"version": "0.1.5",
3+
"version": "0.1.6",
44
"description": "Cross-runtime TypeScript client for the bunqueue job queue server \u2014 Node.js, Bun, Deno and Cloudflare Workers",
55
"license": "MIT",
66
"type": "module",

sdk/typescript/src/ack-batcher.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* Batches completed-job ACKs into ACKB round-trips for higher throughput.
3+
*
4+
* Opt-in (see WorkerOptions.ackBatch). Items flush when the buffer reaches
5+
* `maxSize` or after `maxDelayMs`, whichever comes first; `flush()` drains the
6+
* rest on shutdown. Each item's `onSettled` fires after the batch is acked
7+
* (or errored), so the worker keeps a job "active" — and its lock renewed —
8+
* until the server has confirmed the ack.
9+
*/
10+
11+
import type { Connection } from './connection.js';
12+
import { compact } from './frame.js';
13+
14+
export interface AckItem {
15+
id: string;
16+
token: string;
17+
result: unknown;
18+
/** Called once the batch settles: `err` set on failure, undefined on ack. */
19+
onSettled: (err?: unknown) => void;
20+
}
21+
22+
export class AckBatcher {
23+
private buffer: AckItem[] = [];
24+
private timer: ReturnType<typeof setTimeout> | null = null;
25+
26+
constructor(
27+
private readonly connection: Connection,
28+
private readonly maxSize: number,
29+
private readonly maxDelayMs: number
30+
) {}
31+
32+
add(item: AckItem): void {
33+
this.buffer.push(item);
34+
if (this.buffer.length >= this.maxSize) {
35+
void this.flush();
36+
} else if (!this.timer) {
37+
this.timer = setTimeout(() => void this.flush(), this.maxDelayMs);
38+
this.timer.unref?.(); // don't keep the process alive for a pending flush
39+
}
40+
}
41+
42+
/** Send the buffered ACKs as one ACKB (no-op when empty). */
43+
async flush(): Promise<void> {
44+
if (this.timer) {
45+
clearTimeout(this.timer);
46+
this.timer = null;
47+
}
48+
if (this.buffer.length === 0) return;
49+
const batch = this.buffer;
50+
this.buffer = [];
51+
// Capture the wire outcome first; settle callbacks run OUTSIDE the try so
52+
// a throwing callback can neither be re-invoked with an error (double
53+
// settle) nor starve the remaining items of their callback.
54+
let error: unknown;
55+
try {
56+
await this.connection.call(
57+
compact({
58+
cmd: 'ACKB',
59+
ids: batch.map((b) => b.id),
60+
tokens: batch.map((b) => b.token),
61+
results: batch.map((b) => b.result),
62+
}) as { cmd: string }
63+
);
64+
} catch (err) {
65+
error = err ?? new Error('ACKB failed');
66+
}
67+
for (const item of batch) {
68+
try {
69+
item.onSettled(error);
70+
} catch {
71+
// A callback error (e.g. an unhandled 'error' emit in the worker)
72+
// must never prevent the other items from settling.
73+
}
74+
}
75+
}
76+
}

sdk/typescript/src/backpressure.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* Backpressure gate: bounds the number of in-flight commands on a connection.
3+
*
4+
* When the in-flight count reaches `max`, `acquire()` parks the caller until a
5+
* slot frees (a pending command settles). This turns unbounded memory growth
6+
* under a fast producer / slow server into cooperative flow control. `max <= 0`
7+
* disables the gate (unbounded, the default).
8+
*/
9+
export class Backpressure {
10+
private readonly waiters: Array<() => void> = [];
11+
12+
constructor(
13+
private readonly max: number,
14+
private readonly onWait?: () => void
15+
) {}
16+
17+
/** Resolve immediately if under the limit, else park until a slot frees. */
18+
acquire(inFlight: number): Promise<void> | void {
19+
if (this.max <= 0 || inFlight < this.max) return;
20+
this.onWait?.();
21+
return new Promise<void>((resolve) => {
22+
this.waiters.push(resolve);
23+
});
24+
}
25+
26+
/** Release one parked caller (call when a pending command settles). */
27+
release(): void {
28+
this.waiters.shift()?.();
29+
}
30+
31+
/** Release every parked caller (call on teardown so none hang forever). */
32+
clear(): void {
33+
while (this.waiters.length > 0) this.waiters.shift()?.();
34+
}
35+
36+
/** Number of callers currently parked. */
37+
get waiting(): number {
38+
return this.waiters.length;
39+
}
40+
}

0 commit comments

Comments
 (0)