Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ jobs:
- name: Pin client version
run: pnpm add -D ${{ matrix.client }}@${{ matrix.version }}

# Connections are generic over the consumer's client and constrain it
# with structural types (src/cluster/redis-types.ts): every supported
# client major must still type-check against them.
- name: Type check against the pinned client
run: pnpm exec tsc

- name: Run cluster tests
run: pnpm exec vitest run --project ${{ matrix.project }}

Expand Down
234 changes: 145 additions & 89 deletions README.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions scripts/test-ci.mts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ if (selectedLegs.has("clients")) {
cmd: ["pnpm", "add", "-D", `${pin.client}@${pin.version}`],
pin,
},
{ title: "tsc", cmd: ["pnpm", "exec", "tsc"] },
{
title: `vitest run --project ${pin.project}`,
cmd: ["pnpm", "exec", "vitest", "run", "--project", pin.project],
Expand Down
25 changes: 9 additions & 16 deletions src/Bottleneck.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type {
BottleneckEvents,
BottleneckOptions,
ClientsList,
Counts,
EventInfo,
JobDefaults,
Expand All @@ -18,6 +17,7 @@ import BottleneckError from "./BottleneckError";
import IORedisConnection from "./cluster/IORedisConnection";
import RedisConnection from "./cluster/RedisConnection";
import RedisDatastore from "./cluster/RedisDatastore";
import { validateDatastoreOptions } from "./datastore-options";
import Events from "./Events";
import Group from "./Group";
import Job from "./Job";
Expand Down Expand Up @@ -102,15 +102,11 @@ class Bottleneck {
timeout: null as number | null,
heartbeatInterval: 5000,
clientTimeout: 10000,
Redis: null,
clientOptions: {} as object,
clusterNodes: null as unknown,
clearDatastore: false,
connection: null,
};
/** @internal */
instanceDefaults = {
datastore: "local",
connection: null,
id: "<no-id>",
rejectOnDrop: true,
Expand All @@ -126,8 +122,9 @@ class Bottleneck {
dropErrorMessage: "This limiter has been stopped.",
};

// Populated from instanceDefaults via parser.load in the constructor.
/** `"local"`, or the connection's `"redis"` / `"ioredis"`. */
datastore: string = "local";
// Populated from instanceDefaults via parser.load in the constructor.
connection: RedisConnection | IORedisConnection | null = null;
id: string = "<no-id>";
/** @internal */
Expand Down Expand Up @@ -186,14 +183,12 @@ class Bottleneck {
this._registerLock = new Sync("register");
const storeOptions = load(options, this.storeDefaults, {});

if (this.datastore === "redis" || this.datastore === "ioredis" || this.connection != null) {
if (this.connection != null) {
const opts = load(options, this.redisStoreDefaults, {});
this._store = new RedisDatastore(this, storeOptions, opts);
} else if (this.datastore === "local") {
} else {
const opts = load(options, this.localStoreDefaults, {});
this._store = new LocalDatastore(this, storeOptions, opts);
} else {
throw new BottleneckError(`Invalid datastore type: ${this.datastore}`, "INVALID_DATASTORE");
}

this._queues.on("leftzero", () => this._store.heartbeat?.ref?.());
Expand All @@ -208,14 +203,12 @@ class Bottleneck {
"INVALID_ARGUMENTS",
);
}
validateDatastoreOptions(options);
}

ready(): Promise<unknown> {
return this._store.ready;
}

clients(): ClientsList {
return this._store.clients as ClientsList;
/** Resolves once the limiter is connected to Redis (immediately for a local limiter). */
async ready(): Promise<void> {
await this._store.ready;
}

channel(): string {
Expand Down
4 changes: 3 additions & 1 deletion src/BottleneckError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ export type BottleneckErrorCode =
| "OVERWEIGHT"
| "INVALID_DATASTORE"
| "INVALID_ARGUMENTS"
| "MISSING_CLIENT";
| "MISSING_CLIENT"
| "CLIENT_NOT_OPEN"
| "LEGACY_REDIS_OPTIONS";

class BottleneckError extends Error {
/** Stable machine-readable reason; absent on internal invariant failures. */
Expand Down
53 changes: 13 additions & 40 deletions src/Group.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
import type {
BottleneckOptions,
GroupEvents,
GroupLimiterPair,
IORedisConnectionOptions,
RedisConnectionOptions,
} from "./types";
import { runDetached, setDetachedInterval } from "./async-context";
import type IORedisConnection from "./cluster/IORedisConnection";
import type RedisConnection from "./cluster/RedisConnection";
import type { BottleneckOptions, GroupEvents, GroupLimiterPair } from "./types";
import { setDetachedInterval } from "./async-context";
import Bottleneck from "./Bottleneck";
import IORedisConnection from "./cluster/IORedisConnection";
import RedisConnection from "./cluster/RedisConnection";
import * as Scripts from "./cluster/Scripts";
import { validateDatastoreOptions } from "./datastore-options";
import Events from "./Events";
import { load, overwrite } from "./parser";

Expand Down Expand Up @@ -47,43 +42,17 @@ class Group {
/** @internal */
interval: ReturnType<typeof setInterval> | undefined;
/** @internal */
sharedConnection: boolean;
/** @internal */
Bottleneck: typeof Bottleneck;

constructor(limiterOptions: BottleneckOptions = {}) {
validateDatastoreOptions(limiterOptions);
this.deleteKey = this.deleteKey.bind(this);
this.limiterOptions = { ...limiterOptions } as Record<string, unknown>;
load(this.limiterOptions, this.defaults, this);
this.Events = new Events(this);
this.instances = {};
this._startAutoCleanup();
this.sharedConnection = this.connection != null;
this.Bottleneck = Bottleneck;

if (this.connection == null) {
if (this.limiterOptions.datastore === "redis") {
// Same detachment rationale as RedisDatastore: socket setup must not
// capture the constructing caller's async context.
this.connection = runDetached(
() =>
new RedisConnection(
Object.assign({}, this.limiterOptions, {
Events: this.Events,
}) as unknown as RedisConnectionOptions,
),
);
} else if (this.limiterOptions.datastore === "ioredis") {
this.connection = runDetached(
() =>
new IORedisConnection(
Object.assign({}, this.limiterOptions, {
Events: this.Events,
}) as unknown as IORedisConnectionOptions,
),
);
}
}
}

key(key = ""): Bottleneck {
Expand Down Expand Up @@ -182,10 +151,14 @@ class Group {
}
}

disconnect(flush = true): Promise<void> | undefined {
/**
* Stop the Group's auto-cleanup and disconnect its Redis-backed limiters.
* The connection stays open: close it (and its client) yourself.
*/
async disconnect(flush = true): Promise<void> {
clearInterval(this.interval);
if (!this.sharedConnection) {
return this.connection?.disconnect(flush);
if (this.connection != null) {
await Promise.all(Object.values(this.instances).map((limiter) => limiter.disconnect(flush)));
}
}
}
Expand Down
2 changes: 0 additions & 2 deletions src/LocalDatastore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ class LocalDatastore {
/** @internal */
_unblockTime = 0;
ready: Promise<unknown> = Promise.resolve();
clients: Record<string, unknown> = {};

constructor(
instance: Bottleneck,
Expand All @@ -50,7 +49,6 @@ class LocalDatastore {
this._done = 0;
this._unblockTime = 0;
this.ready = Promise.resolve();
this.clients = {};
this._startHeartbeat();
}

Expand Down
Loading
Loading