@absolutejs/router 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -19
- package/dist/index.d.ts +111 -10
- package/dist/index.js +167 -35
- package/dist/index.js.map +3 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,26 +37,31 @@ ws.data = { ...ws.data, release: handle.release, upstream: decision.shard!.url }
|
|
|
37
37
|
// ...proxy WS frames to decision.shard.url; call handle.release() on close.
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
##
|
|
40
|
+
## Surface (0.1.0)
|
|
41
41
|
|
|
42
42
|
| API | Purpose |
|
|
43
43
|
|---|---|
|
|
44
44
|
| `createRouter(options)` | Factory. Returns a `Router`. |
|
|
45
|
-
| `router.route({ tenantId, channelId? })` | Returns `{ shard, decision }`. Decision is `allow` / `rate-limited` / `capped` / `no-shards`. |
|
|
46
|
-
| `router.acquire(tenantId)` | Increment
|
|
47
|
-
| `router.markHealthy(id)` / `router.markUnhealthy(id)` | Caller-driven health state. Unhealthy shards are skipped
|
|
45
|
+
| `router.route({ tenantId, channelId?, route? })` | Returns `{ shard, decision, emptiedBucket? }`. Decision is `allow` / `rate-limited` / `capped` / `no-shards` / `denied`. |
|
|
46
|
+
| `router.acquire(tenantId)` | Increment active-connection counter; returns `{ active, release }`. `release` is idempotent. |
|
|
47
|
+
| `router.markHealthy(id)` / `router.markUnhealthy(id)` | Caller-driven health state. Unhealthy shards are skipped. |
|
|
48
|
+
| `router.drainShard(id)` | Refuse new routes; existing acquires unaffected. Operator-intentional state distinct from unhealthy. `markHealthy` cancels it. |
|
|
49
|
+
| `router.isHealthy(id)` / `router.isDraining(id)` | Inspect state. |
|
|
48
50
|
| `router.addShard(shard)` / `router.removeShard(id)` | Runtime shard membership changes. |
|
|
49
|
-
| `router.shards()`
|
|
51
|
+
| `router.shards()` | Inspect shard list. |
|
|
52
|
+
| `router.snapshot()` / `router.restore(snap)` | Serializable point-in-time state. Survive edge restarts without dropping rate-limit tokens. |
|
|
50
53
|
| `router.dispose()` | Stop accepting routes; all subsequent `route()` returns `no-shards`. |
|
|
51
54
|
|
|
52
|
-
### Hash strategies
|
|
55
|
+
### Hash strategies + load bias
|
|
53
56
|
|
|
54
|
-
- **`jump`** (default) — Lamping & Veach 2014. O(log n) with no memory, exactly
|
|
55
|
-
|
|
56
|
-
- **`rendezvous`** — HRW hash. Supports per-shard `weight` for heterogeneous
|
|
57
|
-
engine sizes. O(N) per lookup.
|
|
57
|
+
- **`jump`** (default) — Lamping & Veach 2014. O(log n) with no memory, exactly 1/N keys move when shards are added at the tail. Ignores `weight` AND `load` (its design property is unconditional stickiness).
|
|
58
|
+
- **`rendezvous`** — HRW hash. Supports per-shard `weight` for heterogeneous engine sizes; ALSO supports the `load: (shardId) => number` hook for runtime hot-spot avoidance — `effectiveWeight = weight / load`. O(N) per lookup.
|
|
58
59
|
- **Custom**: pass `(key, shards) => index`.
|
|
59
60
|
|
|
61
|
+
### Drain mode
|
|
62
|
+
|
|
63
|
+
`drainShard(id)` excludes a shard from new routing without marking it broken. Use this before a planned shard shutdown — tenants on the draining shard rehash to healthy non-draining shards on their NEXT route, but in-flight requests aren't torn down. The caller waits for the shard to be quiet (e.g. via the runtime's stats), then `removeShard()`. `markHealthy()` cancels a drain in case ops changes their mind.
|
|
64
|
+
|
|
60
65
|
### Connection cap
|
|
61
66
|
|
|
62
67
|
`perTenantConnectionCap` is the max concurrent connections one tenant can hold,
|
|
@@ -64,19 +69,41 @@ counted via `acquire()` / the returned `release()`. When reached, `route()`
|
|
|
64
69
|
returns `capped` — your gateway should refuse the upgrade with `429` /
|
|
65
70
|
`503`. Default `Infinity` (no cap).
|
|
66
71
|
|
|
67
|
-
### Rate
|
|
72
|
+
### Rate limits — tenant + per-route
|
|
73
|
+
|
|
74
|
+
`perTenantRateLimit` is a token bucket per tenant: `tokens` is bucket capacity AND starting balance; `refillPerSecond` continuously refills up to capacity. Each successful `route()` costs one token. Bucket is computed lazily at lookup time — no timer churn for idle tenants. Default `{ tokens: Infinity, refillPerSecond: 0 }` (no limit).
|
|
75
|
+
|
|
76
|
+
`perRouteRateLimits: Record<string, RateLimit>` layers a SECOND per-route bucket on top of the tenant-wide one. `route({ route: 'expensive' })` checks both; if either is empty, the call returns `rate-limited` with `emptiedBucket` reporting which one. Useful for "100 cheap calls / minute, 5 expensive calls / minute" shapes where one tenant-wide cap won't express the policy. **A failed route bucket does NOT consume the tenant bucket** — neither token is deducted unless both pass.
|
|
68
77
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
78
|
+
### Allow hook (meter integration)
|
|
79
|
+
|
|
80
|
+
`allow: (tenantId) => boolean` is a caller-supplied gate. Returning `false` makes `route()` return `{ decision: 'denied' }` immediately, before any bucket is touched. The intended pairing is `@absolutejs/metering`'s `meter.allow` — pass it directly:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
const meter = createMeter({ ... });
|
|
84
|
+
const router = createRouter({
|
|
85
|
+
shards,
|
|
86
|
+
allow: meter.allow, // refuse routes for over-quota tenants
|
|
87
|
+
load: (id) => runtimeRoster.load(id), // and bias toward less-loaded shards
|
|
88
|
+
});
|
|
89
|
+
```
|
|
74
90
|
|
|
75
91
|
### Health
|
|
76
92
|
|
|
77
|
-
The router does not probe backends itself — keeping it bun/elysia-free means
|
|
78
|
-
|
|
79
|
-
|
|
93
|
+
The router does not probe backends itself — keeping it bun/elysia-free means no I/O. Wire your own health-check loop and call `markHealthy` / `markUnhealthy`. A live health-checking adapter is a candidate for a later 0.0.x subpath.
|
|
94
|
+
|
|
95
|
+
### Snapshot + restore
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
const json = JSON.stringify(router.snapshot());
|
|
99
|
+
await persistToDisk('/var/lib/router/state.json', json);
|
|
100
|
+
|
|
101
|
+
// On edge restart:
|
|
102
|
+
const restored = createRouter({ ... same config ... });
|
|
103
|
+
restored.restore(JSON.parse(await readFromDisk('/var/lib/router/state.json')));
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Captures rate-limit token counts, per-route bucket state, shard health + drain state, per-tenant active connection counts. Without this, an edge restart hands every tenant a fresh full bucket — instant rate-limit-bypass for anyone watching the deploy times.
|
|
80
107
|
|
|
81
108
|
## Architectural role
|
|
82
109
|
|
package/dist/index.d.ts
CHANGED
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
* Sits between an incoming request / WS upgrade and N backend processes (each
|
|
6
6
|
* one a `@absolutejs/runtime` instance hosting a `@absolutejs/sync` engine
|
|
7
7
|
* for a subset of tenants). Decides which shard owns the tenant, whether the
|
|
8
|
-
* tenant is over its rate limit, whether the tenant
|
|
9
|
-
* cap,
|
|
8
|
+
* tenant is over its rate limit (tenant-wide + per-route), whether the tenant
|
|
9
|
+
* is over its connection cap, whether the caller-supplied allow hook approves,
|
|
10
|
+
* and whether the chosen shard is healthy and not draining.
|
|
10
11
|
*
|
|
11
|
-
* v0.0
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* v0.1.0 is bun/elysia-agnostic — pure logic, no `Bun.serve` or proxy code.
|
|
13
|
+
* The caller wires the routing decision into whichever HTTP/WS layer they
|
|
14
|
+
* have. An Elysia adapter ships in a later 0.0.x as a subpath.
|
|
14
15
|
*/
|
|
15
16
|
export type Shard = {
|
|
16
17
|
id: string;
|
|
@@ -28,8 +29,8 @@ export type Shard = {
|
|
|
28
29
|
};
|
|
29
30
|
/**
|
|
30
31
|
* Pure hash-strategy function. Given the routing key + the list of currently
|
|
31
|
-
*
|
|
32
|
-
* chosen shard. The router never calls a strategy with an empty `shards`
|
|
32
|
+
* eligible shards (healthy AND not draining), return the index in `shards` of
|
|
33
|
+
* the chosen shard. The router never calls a strategy with an empty `shards`
|
|
33
34
|
* array — that case is handled upstream as `no-shards`.
|
|
34
35
|
*/
|
|
35
36
|
export type HashStrategyFn = (key: string, shards: ReadonlyArray<Shard>) => number;
|
|
@@ -43,6 +44,23 @@ export type RateLimit = {
|
|
|
43
44
|
tokens: number;
|
|
44
45
|
refillPerSecond: number;
|
|
45
46
|
};
|
|
47
|
+
/**
|
|
48
|
+
* Optional caller-supplied gate. Called per-route with the tenant id; returning
|
|
49
|
+
* `false` causes the route to return `decision: 'denied'`. The intended
|
|
50
|
+
* use is `meter.allow` from `@absolutejs/metering` — pass it directly to refuse
|
|
51
|
+
* routes for over-quota tenants without wiring the integration manually.
|
|
52
|
+
*/
|
|
53
|
+
export type AllowHook = (tenantId: string) => boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Optional caller-supplied load metric. Called per `route()` with a shard id;
|
|
56
|
+
* returning a value > 1 makes the rendezvous strategy bias AWAY from this shard
|
|
57
|
+
* (effective weight = `shard.weight / load`). Used to avoid hot-spotting when
|
|
58
|
+
* a stickiness-locked shard is overloaded — the router can't move existing
|
|
59
|
+
* tenants, but it can avoid sending NEW tenants there.
|
|
60
|
+
*
|
|
61
|
+
* Jump-hash ignores this — it has no per-call weight bias by design.
|
|
62
|
+
*/
|
|
63
|
+
export type ShardLoadFn = (shardId: string) => number;
|
|
46
64
|
export type RouterOptions = {
|
|
47
65
|
/** Initial shard set. Can be empty (every `route()` returns `no-shards`). */
|
|
48
66
|
shards: Shard[];
|
|
@@ -60,10 +78,48 @@ export type RouterOptions = {
|
|
|
60
78
|
* `'rate-limited'`. One `route()` call costs one token.
|
|
61
79
|
*/
|
|
62
80
|
perTenantRateLimit?: RateLimit;
|
|
81
|
+
/**
|
|
82
|
+
* Per-route token buckets layered on top of the tenant-wide bucket.
|
|
83
|
+
* Keyed by route name; supplied by the caller via
|
|
84
|
+
* `route({ route: 'someRoute' })`. The tenant bucket AND the route bucket
|
|
85
|
+
* must both have a token available. When the route bucket is empty,
|
|
86
|
+
* `route()` returns `'rate-limited'` with `routeId` set.
|
|
87
|
+
*/
|
|
88
|
+
perRouteRateLimits?: Record<string, RateLimit>;
|
|
89
|
+
/** Optional load hook biasing the rendezvous strategy. */
|
|
90
|
+
load?: ShardLoadFn;
|
|
91
|
+
/** Optional caller-supplied allow gate. */
|
|
92
|
+
allow?: AllowHook;
|
|
63
93
|
/** Override `Date.now` for tests. */
|
|
64
94
|
clock?: () => number;
|
|
65
95
|
};
|
|
66
|
-
export type RouteDecision = 'allow' | 'rate-limited' | 'capped' | 'no-shards';
|
|
96
|
+
export type RouteDecision = 'allow' | 'rate-limited' | 'capped' | 'no-shards' | 'denied';
|
|
97
|
+
/**
|
|
98
|
+
* Returned by {@link Router.metrics}. Combines point-in-time state with
|
|
99
|
+
* cumulative counters since `createRouter()`. Survives `dispose()` so
|
|
100
|
+
* post-shutdown introspection still reads totals. Added in 0.2.0.
|
|
101
|
+
*
|
|
102
|
+
* - `routes` — total `route()` calls (any decision).
|
|
103
|
+
* - `acquires` — total `acquire()` calls.
|
|
104
|
+
* - `rejectsByDecision` — counts per non-allow `RouteDecision`. The
|
|
105
|
+
* operator's "where am I shedding load?" answer.
|
|
106
|
+
* - `shardLoadDistribution` — cumulative routes assigned per shard
|
|
107
|
+
* since `createRouter()`. With `markHealthy`/`drainShard` this is
|
|
108
|
+
* the "is rebalancing actually rebalancing?" signal. (Per-shard
|
|
109
|
+
* *active* connection count would require route-to-acquire
|
|
110
|
+
* correlation that the current `acquire(tenantId)` API doesn't
|
|
111
|
+
* capture; cumulative routes are the directly-observable proxy.)
|
|
112
|
+
* - `lastRouteMs` — wall-clock of the most recent `route()` call (a
|
|
113
|
+
* climb means the hot path is getting slower — usually a sign that
|
|
114
|
+
* `load:` is doing too much work, or the shard count exploded).
|
|
115
|
+
*/
|
|
116
|
+
export type RouterMetrics = {
|
|
117
|
+
routes: number;
|
|
118
|
+
acquires: number;
|
|
119
|
+
rejectsByDecision: Record<Exclude<RouteDecision, 'allow'>, number>;
|
|
120
|
+
shardLoadDistribution: Record<string, number>;
|
|
121
|
+
lastRouteMs: number;
|
|
122
|
+
};
|
|
67
123
|
export type RouteRequest = {
|
|
68
124
|
tenantId: string;
|
|
69
125
|
/**
|
|
@@ -74,11 +130,22 @@ export type RouteRequest = {
|
|
|
74
130
|
* tenant may land on different shards.
|
|
75
131
|
*/
|
|
76
132
|
channelId?: string;
|
|
133
|
+
/**
|
|
134
|
+
* Optional per-route rate-limit key. Looked up in `perRouteRateLimits`.
|
|
135
|
+
* Unknown routes pass without per-route gating (only the tenant-wide
|
|
136
|
+
* bucket applies).
|
|
137
|
+
*/
|
|
138
|
+
route?: string;
|
|
77
139
|
};
|
|
78
140
|
export type RouteResult = {
|
|
79
141
|
decision: RouteDecision;
|
|
80
|
-
/** The chosen shard. `null`
|
|
142
|
+
/** The chosen shard. `null` when `decision !== 'allow'`. */
|
|
81
143
|
shard: Shard | null;
|
|
144
|
+
/**
|
|
145
|
+
* Set on a `'rate-limited'` result to tell the caller which bucket emptied:
|
|
146
|
+
* `'tenant'` for the tenant-wide bucket, the route id for a per-route bucket.
|
|
147
|
+
*/
|
|
148
|
+
emptiedBucket?: 'tenant' | string;
|
|
82
149
|
};
|
|
83
150
|
export type AcquireHandle = {
|
|
84
151
|
/** Number of active connections for this tenant after acquire (>=1). */
|
|
@@ -87,21 +154,55 @@ export type AcquireHandle = {
|
|
|
87
154
|
release: () => void;
|
|
88
155
|
};
|
|
89
156
|
export type RouterSnapshot = {
|
|
157
|
+
version: 1;
|
|
158
|
+
at: number;
|
|
90
159
|
shards: Array<Shard & {
|
|
91
160
|
healthy: boolean;
|
|
161
|
+
draining: boolean;
|
|
162
|
+
active: number;
|
|
163
|
+
}>;
|
|
164
|
+
tenants: Array<{
|
|
165
|
+
tenant: string;
|
|
92
166
|
active: number;
|
|
167
|
+
tokens: number;
|
|
168
|
+
lastRefillAt: number;
|
|
169
|
+
}>;
|
|
170
|
+
routeBuckets: Array<{
|
|
171
|
+
tenant: string;
|
|
172
|
+
route: string;
|
|
173
|
+
tokens: number;
|
|
174
|
+
lastRefillAt: number;
|
|
93
175
|
}>;
|
|
94
|
-
tenants: number;
|
|
95
176
|
};
|
|
96
177
|
export type Router = {
|
|
97
178
|
route: (request: RouteRequest) => RouteResult;
|
|
98
179
|
acquire: (tenantId: string) => AcquireHandle;
|
|
99
180
|
markHealthy: (shardId: string) => void;
|
|
100
181
|
markUnhealthy: (shardId: string) => void;
|
|
182
|
+
/**
|
|
183
|
+
* Begin draining a shard — exclude it from new routing decisions while
|
|
184
|
+
* leaving existing acquires alone. Semantically distinct from
|
|
185
|
+
* `markUnhealthy` (an operator-intentional state, not a failure).
|
|
186
|
+
* `markHealthy` cancels both states. Use this before a planned shard
|
|
187
|
+
* shutdown — tenants on the shard rehash to healthy non-draining shards
|
|
188
|
+
* on their next route, but in-flight requests are NOT torn down.
|
|
189
|
+
*/
|
|
190
|
+
drainShard: (shardId: string) => void;
|
|
191
|
+
isHealthy: (shardId: string) => boolean;
|
|
192
|
+
isDraining: (shardId: string) => boolean;
|
|
101
193
|
addShard: (shard: Shard) => void;
|
|
102
194
|
removeShard: (shardId: string) => void;
|
|
103
195
|
shards: () => Shard[];
|
|
104
196
|
snapshot: () => RouterSnapshot;
|
|
197
|
+
restore: (snapshot: RouterSnapshot) => void;
|
|
198
|
+
/**
|
|
199
|
+
* Operator-shaped point-in-time + cumulative metrics since
|
|
200
|
+
* `createRouter()`. Use for tier dashboards and "where am I
|
|
201
|
+
* shedding load" alerts. Distinct from `snapshot()`, which is the
|
|
202
|
+
* persistence shape (preserves tenant + bucket state across a
|
|
203
|
+
* shard reboot). Added in 0.2.0.
|
|
204
|
+
*/
|
|
205
|
+
metrics: () => RouterMetrics;
|
|
105
206
|
dispose: () => void;
|
|
106
207
|
};
|
|
107
208
|
export declare const createRouter: (options: RouterOptions) => Router;
|
package/dist/index.js
CHANGED
|
@@ -23,17 +23,19 @@ var jumpHash = (key, numBuckets) => {
|
|
|
23
23
|
return Number(b);
|
|
24
24
|
};
|
|
25
25
|
var jumpStrategy = (key, shards) => jumpHash(key, shards.length);
|
|
26
|
-
var
|
|
26
|
+
var makeRendezvousStrategy = (load) => (key, shards) => {
|
|
27
27
|
let bestIndex = 0;
|
|
28
28
|
let bestScore = -Infinity;
|
|
29
29
|
for (let i = 0;i < shards.length; i++) {
|
|
30
30
|
const shard = shards[i];
|
|
31
|
-
const
|
|
32
|
-
if (
|
|
31
|
+
const baseWeight = shard.weight ?? 1;
|
|
32
|
+
if (baseWeight <= 0)
|
|
33
33
|
continue;
|
|
34
|
+
const loadValue = load ? Math.max(load(shard.id), 0.0001) : 1;
|
|
35
|
+
const effectiveWeight = baseWeight / loadValue;
|
|
34
36
|
const seed = fnv1a32(`${key}|${shard.id}`);
|
|
35
37
|
const u = (seed + 1) / 4294967296;
|
|
36
|
-
const score =
|
|
38
|
+
const score = effectiveWeight * -Math.log(u);
|
|
37
39
|
if (score > bestScore) {
|
|
38
40
|
bestScore = score;
|
|
39
41
|
bestIndex = i;
|
|
@@ -41,28 +43,41 @@ var rendezvousStrategy = (key, shards) => {
|
|
|
41
43
|
}
|
|
42
44
|
return bestIndex;
|
|
43
45
|
};
|
|
44
|
-
var resolveStrategy = (strategy) => {
|
|
46
|
+
var resolveStrategy = (strategy, load) => {
|
|
45
47
|
if (strategy === "jump")
|
|
46
48
|
return jumpStrategy;
|
|
47
49
|
if (strategy === "rendezvous")
|
|
48
|
-
return
|
|
50
|
+
return makeRendezvousStrategy(load);
|
|
49
51
|
return strategy;
|
|
50
52
|
};
|
|
51
53
|
var createRouter = (options) => {
|
|
52
54
|
const clock = options.clock ?? Date.now;
|
|
53
|
-
const strategy = resolveStrategy(options.hashStrategy ?? "jump");
|
|
55
|
+
const strategy = resolveStrategy(options.hashStrategy ?? "jump", options.load);
|
|
54
56
|
const cap = options.perTenantConnectionCap ?? Infinity;
|
|
55
57
|
const rate = options.perTenantRateLimit ?? { refillPerSecond: 0, tokens: Infinity };
|
|
58
|
+
const perRouteRateLimits = options.perRouteRateLimits ?? {};
|
|
59
|
+
const allowHook = options.allow;
|
|
56
60
|
const shardList = [...options.shards];
|
|
57
61
|
const healthy = new Map;
|
|
62
|
+
const draining = new Set;
|
|
58
63
|
for (const shard of shardList)
|
|
59
64
|
healthy.set(shard.id, true);
|
|
60
65
|
const tenants = new Map;
|
|
66
|
+
const routeBuckets = new Map;
|
|
61
67
|
let disposed = false;
|
|
68
|
+
let totalRoutes = 0;
|
|
69
|
+
let totalAcquires = 0;
|
|
70
|
+
let lastRouteMs = 0;
|
|
71
|
+
const rejectsByDecision = {
|
|
72
|
+
"rate-limited": 0,
|
|
73
|
+
capped: 0,
|
|
74
|
+
"no-shards": 0,
|
|
75
|
+
denied: 0
|
|
76
|
+
};
|
|
77
|
+
const shardLoadByShard = new Map;
|
|
62
78
|
const freshTenant = (now) => ({
|
|
63
79
|
active: 0,
|
|
64
|
-
lastRefillAt: now,
|
|
65
|
-
tokens: rate.tokens
|
|
80
|
+
bucket: { lastRefillAt: now, tokens: rate.tokens }
|
|
66
81
|
});
|
|
67
82
|
const ensureTenant = (id, now) => {
|
|
68
83
|
const found = tenants.get(id);
|
|
@@ -72,42 +87,88 @@ var createRouter = (options) => {
|
|
|
72
87
|
tenants.set(id, fresh);
|
|
73
88
|
return fresh;
|
|
74
89
|
};
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
90
|
+
const refillBucket = (bucket, rule, now) => {
|
|
91
|
+
if (rule.refillPerSecond <= 0)
|
|
77
92
|
return;
|
|
78
|
-
const elapsedMs = now -
|
|
93
|
+
const elapsedMs = now - bucket.lastRefillAt;
|
|
79
94
|
if (elapsedMs <= 0)
|
|
80
95
|
return;
|
|
81
|
-
const added = elapsedMs / 1000 *
|
|
82
|
-
|
|
83
|
-
|
|
96
|
+
const added = elapsedMs / 1000 * rule.refillPerSecond;
|
|
97
|
+
bucket.tokens = Math.min(rule.tokens, bucket.tokens + added);
|
|
98
|
+
bucket.lastRefillAt = now;
|
|
99
|
+
};
|
|
100
|
+
const ensureRouteBucket = (tenant, route2, rule, now) => {
|
|
101
|
+
const key = `${tenant}|${route2}`;
|
|
102
|
+
const found = routeBuckets.get(key);
|
|
103
|
+
if (found)
|
|
104
|
+
return found;
|
|
105
|
+
const fresh = { lastRefillAt: now, tokens: rule.tokens };
|
|
106
|
+
routeBuckets.set(key, fresh);
|
|
107
|
+
return fresh;
|
|
84
108
|
};
|
|
85
|
-
const
|
|
109
|
+
const eligibleShards = () => shardList.filter((shard) => healthy.get(shard.id) === true && !draining.has(shard.id));
|
|
86
110
|
const route = (request) => {
|
|
111
|
+
const routeStart = clock();
|
|
112
|
+
totalRoutes += 1;
|
|
113
|
+
const recordRejection = (decision) => {
|
|
114
|
+
rejectsByDecision[decision] += 1;
|
|
115
|
+
lastRouteMs = clock() - routeStart;
|
|
116
|
+
return { decision, shard: null };
|
|
117
|
+
};
|
|
87
118
|
if (disposed)
|
|
88
|
-
return
|
|
89
|
-
const live =
|
|
119
|
+
return recordRejection("no-shards");
|
|
120
|
+
const live = eligibleShards();
|
|
90
121
|
if (live.length === 0)
|
|
91
|
-
return
|
|
92
|
-
|
|
122
|
+
return recordRejection("no-shards");
|
|
123
|
+
if (allowHook && !allowHook(request.tenantId)) {
|
|
124
|
+
return recordRejection("denied");
|
|
125
|
+
}
|
|
126
|
+
const now = routeStart;
|
|
93
127
|
const state = ensureTenant(request.tenantId, now);
|
|
94
128
|
if (state.active >= cap) {
|
|
95
|
-
return
|
|
129
|
+
return recordRejection("capped");
|
|
96
130
|
}
|
|
97
|
-
|
|
98
|
-
if (state.tokens < 1) {
|
|
99
|
-
|
|
131
|
+
refillBucket(state.bucket, rate, now);
|
|
132
|
+
if (state.bucket.tokens < 1) {
|
|
133
|
+
rejectsByDecision["rate-limited"] += 1;
|
|
134
|
+
lastRouteMs = clock() - routeStart;
|
|
135
|
+
return {
|
|
136
|
+
decision: "rate-limited",
|
|
137
|
+
emptiedBucket: "tenant",
|
|
138
|
+
shard: null
|
|
139
|
+
};
|
|
100
140
|
}
|
|
101
|
-
|
|
141
|
+
let routeBucket = null;
|
|
142
|
+
let routeRule = null;
|
|
143
|
+
if (request.route !== undefined && perRouteRateLimits[request.route] !== undefined) {
|
|
144
|
+
routeRule = perRouteRateLimits[request.route];
|
|
145
|
+
routeBucket = ensureRouteBucket(request.tenantId, request.route, routeRule, now);
|
|
146
|
+
refillBucket(routeBucket, routeRule, now);
|
|
147
|
+
if (routeBucket.tokens < 1) {
|
|
148
|
+
rejectsByDecision["rate-limited"] += 1;
|
|
149
|
+
lastRouteMs = clock() - routeStart;
|
|
150
|
+
return {
|
|
151
|
+
decision: "rate-limited",
|
|
152
|
+
emptiedBucket: request.route,
|
|
153
|
+
shard: null
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
state.bucket.tokens -= 1;
|
|
158
|
+
if (routeBucket)
|
|
159
|
+
routeBucket.tokens -= 1;
|
|
102
160
|
const key = request.channelId === undefined ? request.tenantId : `${request.tenantId}:${request.channelId}`;
|
|
103
161
|
const index = strategy(key, live);
|
|
104
162
|
const chosen = live[Math.max(0, Math.min(index, live.length - 1))];
|
|
163
|
+
shardLoadByShard.set(chosen.id, (shardLoadByShard.get(chosen.id) ?? 0) + 1);
|
|
164
|
+
lastRouteMs = clock() - routeStart;
|
|
105
165
|
return { decision: "allow", shard: chosen };
|
|
106
166
|
};
|
|
107
167
|
const acquire = (tenantId) => {
|
|
108
168
|
const now = clock();
|
|
109
169
|
const state = ensureTenant(tenantId, now);
|
|
110
170
|
state.active += 1;
|
|
171
|
+
totalAcquires += 1;
|
|
111
172
|
let released = false;
|
|
112
173
|
return {
|
|
113
174
|
active: state.active,
|
|
@@ -133,11 +194,28 @@ var createRouter = (options) => {
|
|
|
133
194
|
disposed = true;
|
|
134
195
|
shardList.length = 0;
|
|
135
196
|
healthy.clear();
|
|
197
|
+
draining.clear();
|
|
136
198
|
tenants.clear();
|
|
199
|
+
routeBuckets.clear();
|
|
137
200
|
},
|
|
138
|
-
|
|
201
|
+
drainShard: (id) => {
|
|
139
202
|
if (healthy.has(id))
|
|
203
|
+
draining.add(id);
|
|
204
|
+
},
|
|
205
|
+
isDraining: (id) => draining.has(id),
|
|
206
|
+
isHealthy: (id) => healthy.get(id) === true,
|
|
207
|
+
metrics: () => ({
|
|
208
|
+
acquires: totalAcquires,
|
|
209
|
+
lastRouteMs,
|
|
210
|
+
rejectsByDecision: { ...rejectsByDecision },
|
|
211
|
+
routes: totalRoutes,
|
|
212
|
+
shardLoadDistribution: Object.fromEntries(shardLoadByShard)
|
|
213
|
+
}),
|
|
214
|
+
markHealthy: (id) => {
|
|
215
|
+
if (healthy.has(id)) {
|
|
140
216
|
healthy.set(id, true);
|
|
217
|
+
draining.delete(id);
|
|
218
|
+
}
|
|
141
219
|
},
|
|
142
220
|
markUnhealthy: (id) => {
|
|
143
221
|
if (healthy.has(id))
|
|
@@ -148,22 +226,76 @@ var createRouter = (options) => {
|
|
|
148
226
|
if (at >= 0)
|
|
149
227
|
shardList.splice(at, 1);
|
|
150
228
|
healthy.delete(id);
|
|
229
|
+
draining.delete(id);
|
|
151
230
|
},
|
|
152
231
|
route,
|
|
153
232
|
shards: () => shardList.map((shard) => ({ ...shard })),
|
|
154
|
-
snapshot: () =>
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
233
|
+
snapshot: () => {
|
|
234
|
+
const now = clock();
|
|
235
|
+
const tenantsOut = [];
|
|
236
|
+
for (const [tenant, state] of tenants) {
|
|
237
|
+
tenantsOut.push({
|
|
238
|
+
active: state.active,
|
|
239
|
+
lastRefillAt: state.bucket.lastRefillAt,
|
|
240
|
+
tenant,
|
|
241
|
+
tokens: state.bucket.tokens
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
const routesOut = [];
|
|
245
|
+
for (const [key, bucket] of routeBuckets) {
|
|
246
|
+
const pipe = key.indexOf("|");
|
|
247
|
+
if (pipe < 0)
|
|
248
|
+
continue;
|
|
249
|
+
routesOut.push({
|
|
250
|
+
lastRefillAt: bucket.lastRefillAt,
|
|
251
|
+
route: key.slice(pipe + 1),
|
|
252
|
+
tenant: key.slice(0, pipe),
|
|
253
|
+
tokens: bucket.tokens
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
at: now,
|
|
258
|
+
routeBuckets: routesOut,
|
|
259
|
+
shards: shardList.map((shard) => {
|
|
260
|
+
const active = Array.from(tenants.values()).reduce((acc, state) => acc + state.active, 0);
|
|
261
|
+
return {
|
|
262
|
+
...shard,
|
|
263
|
+
active,
|
|
264
|
+
draining: draining.has(shard.id),
|
|
265
|
+
healthy: healthy.get(shard.id) === true
|
|
266
|
+
};
|
|
267
|
+
}),
|
|
268
|
+
tenants: tenantsOut,
|
|
269
|
+
version: 1
|
|
270
|
+
};
|
|
271
|
+
},
|
|
272
|
+
restore: (snap) => {
|
|
273
|
+
tenants.clear();
|
|
274
|
+
routeBuckets.clear();
|
|
275
|
+
draining.clear();
|
|
276
|
+
for (const t of snap.tenants) {
|
|
277
|
+
tenants.set(t.tenant, {
|
|
278
|
+
active: t.active,
|
|
279
|
+
bucket: { lastRefillAt: t.lastRefillAt, tokens: t.tokens }
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
for (const r of snap.routeBuckets) {
|
|
283
|
+
routeBuckets.set(`${r.tenant}|${r.route}`, {
|
|
284
|
+
lastRefillAt: r.lastRefillAt,
|
|
285
|
+
tokens: r.tokens
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
for (const shard of snap.shards) {
|
|
289
|
+
healthy.set(shard.id, shard.healthy);
|
|
290
|
+
if (shard.draining)
|
|
291
|
+
draining.add(shard.id);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
162
294
|
};
|
|
163
295
|
};
|
|
164
296
|
export {
|
|
165
297
|
createRouter
|
|
166
298
|
};
|
|
167
299
|
|
|
168
|
-
//# debugId=
|
|
300
|
+
//# debugId=E49309CC1211AA2864756E2164756E21
|
|
169
301
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/index.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"/**\n * @absolutejs/router — multi-tenant connection routing primitive for Bun PaaS\n * gateways.\n *\n * Sits between an incoming request / WS upgrade and N backend processes (each\n * one a `@absolutejs/runtime` instance hosting a `@absolutejs/sync` engine\n * for a subset of tenants). Decides which shard owns the tenant, whether the\n * tenant is over its rate limit, whether the tenant is over its connection\n * cap, and whether the chosen shard is healthy.\n *\n * v0.0.1 is intentionally bun/elysia-agnostic — pure logic, no `Bun.serve`\n * or proxy code. The caller wires the routing decision into whichever HTTP/WS\n * layer they have. An Elysia adapter ships in a later 0.0.x as a subpath.\n */\n\nexport type Shard = {\n\tid: string;\n\t/**\n\t * Connection target for the chosen backend. Format is opaque to the router\n\t * — `ws://host:port`, `https://host`, a unix socket path, whatever the\n\t * caller's proxy layer understands.\n\t */\n\turl: string;\n\t/**\n\t * Relative weight for hash strategies that support it (rendezvous). Jump\n\t * hash ignores weights — every shard is treated as weight 1. Default 1.\n\t */\n\tweight?: number;\n};\n\n/**\n * Pure hash-strategy function. Given the routing key + the list of currently\n * healthy shards (with their weights), return the index in `shards` of the\n * chosen shard. The router never calls a strategy with an empty `shards`\n * array — that case is handled upstream as `no-shards`.\n */\nexport type HashStrategyFn = (key: string, shards: ReadonlyArray<Shard>) => number;\n\nexport type HashStrategy = 'jump' | 'rendezvous' | HashStrategyFn;\n\n/**\n * Per-tenant token-bucket rate limit. `tokens` is the bucket capacity AND the\n * starting balance; `refillPerSecond` is added continuously up to capacity.\n * Defaults: `Infinity` tokens / `0` refill = no limit.\n */\nexport type RateLimit = {\n\ttokens: number;\n\trefillPerSecond: number;\n};\n\nexport type RouterOptions = {\n\t/** Initial shard set. Can be empty (every `route()` returns `no-shards`). */\n\tshards: Shard[];\n\t/** Hash strategy. Default `'jump'`. */\n\thashStrategy?: HashStrategy;\n\t/**\n\t * Max concurrent connections per tenant (counted via `acquire()` /\n\t * `release()`). Default `Infinity` (no cap). When reached, `route()`\n\t * returns `'capped'`.\n\t */\n\tperTenantConnectionCap?: number;\n\t/**\n\t * Per-tenant token bucket. Default `{ tokens: Infinity, refillPerSecond: 0 }`\n\t * (no limit). When the tenant's bucket is empty, `route()` returns\n\t * `'rate-limited'`. One `route()` call costs one token.\n\t */\n\tperTenantRateLimit?: RateLimit;\n\t/** Override `Date.now` for tests. */\n\tclock?: () => number;\n};\n\nexport type RouteDecision = 'allow' | 'rate-limited' | 'capped' | 'no-shards';\n\nexport type RouteRequest = {\n\ttenantId: string;\n\t/**\n\t * Optional sub-key within a tenant for shardable channels. When set, the\n\t * hash key is `${tenantId}:${channelId}`. Use this when a single tenant is\n\t * too hot for one engine and its work can be partitioned (e.g. per-doc,\n\t * per-room) across multiple engines. Different channels of the same\n\t * tenant may land on different shards.\n\t */\n\tchannelId?: string;\n};\n\nexport type RouteResult = {\n\tdecision: RouteDecision;\n\t/** The chosen shard. `null` only when `decision === 'no-shards'`. */\n\tshard: Shard | null;\n};\n\nexport type AcquireHandle = {\n\t/** Number of active connections for this tenant after acquire (>=1). */\n\tactive: number;\n\t/** Releases one connection for this tenant. Idempotent — safe to call once. */\n\trelease: () => void;\n};\n\nexport type RouterSnapshot = {\n\tshards: Array<Shard & { healthy: boolean; active: number }>;\n\ttenants: number;\n};\n\nexport type Router = {\n\troute: (request: RouteRequest) => RouteResult;\n\tacquire: (tenantId: string) => AcquireHandle;\n\tmarkHealthy: (shardId: string) => void;\n\tmarkUnhealthy: (shardId: string) => void;\n\taddShard: (shard: Shard) => void;\n\tremoveShard: (shardId: string) => void;\n\tshards: () => Shard[];\n\tsnapshot: () => RouterSnapshot;\n\tdispose: () => void;\n};\n\n// -----------------------------------------------------------------------------\n// Hash strategies\n// -----------------------------------------------------------------------------\n\n/**\n * FNV-1a 32-bit. Cheap, no deps, good enough as a seed for the consistent\n * hash strategies below. Not cryptographic — do NOT use as a security hash.\n */\nconst fnv1a32 = (input: string): number => {\n\tlet hash = 0x811c9dc5;\n\tfor (let i = 0; i < input.length; i++) {\n\t\thash ^= input.charCodeAt(i);\n\t\thash = Math.imul(hash, 0x01000193);\n\t}\n\treturn hash >>> 0;\n};\n\n/**\n * Jump-consistent-hash, Lamping & Veach 2014. Maps a 64-bit key to a bucket\n * in `[0, numBuckets)`. Properties: O(log n), no memory, exactly 1/N keys\n * move when buckets are added at the tail. We mix a 32-bit FNV-1a into a\n * 64-bit-ish state via bigint to satisfy the 64-bit arithmetic the algorithm\n * expects.\n */\nconst jumpHash = (key: string, numBuckets: number): number => {\n\tif (numBuckets <= 0) return -1;\n\tlet k = BigInt(fnv1a32(key)) | (BigInt(fnv1a32(key + '#hi')) << 32n);\n\tlet b = -1n;\n\tlet j = 0n;\n\twhile (j < BigInt(numBuckets)) {\n\t\tb = j;\n\t\tk = (k * 2862933555777941757n + 1n) & 0xffffffffffffffffn;\n\t\t// (b + 1) * (1 << 31) / ((k >> 33) + 1)\n\t\tconst shifted = (k >> 33n) + 1n;\n\t\tj = ((b + 1n) * (1n << 31n)) / shifted;\n\t}\n\treturn Number(b);\n};\n\nconst jumpStrategy: HashStrategyFn = (key, shards) => jumpHash(key, shards.length);\n\n/**\n * Rendezvous (highest-random-weight) hash. For each shard, compute\n * `score = weight * -ln(rand(hash(key + shard.id)))`; pick the shard with\n * the highest score. Properties: supports per-shard weighting, single-shard\n * change moves only that shard's \"winning\" keys, O(N) per lookup.\n */\nconst rendezvousStrategy: HashStrategyFn = (key, shards) => {\n\tlet bestIndex = 0;\n\tlet bestScore = -Infinity;\n\tfor (let i = 0; i < shards.length; i++) {\n\t\tconst shard = shards[i]!;\n\t\tconst weight = shard.weight ?? 1;\n\t\tif (weight <= 0) continue;\n\t\tconst seed = fnv1a32(`${key}|${shard.id}`);\n\t\t// Map [0, 2^32) to (0, 1) and take -ln. Heavier weight = higher expected score.\n\t\tconst u = (seed + 1) / 0x1_0000_0000;\n\t\tconst score = weight * -Math.log(u);\n\t\tif (score > bestScore) {\n\t\t\tbestScore = score;\n\t\t\tbestIndex = i;\n\t\t}\n\t}\n\treturn bestIndex;\n};\n\nconst resolveStrategy = (strategy: HashStrategy): HashStrategyFn => {\n\tif (strategy === 'jump') return jumpStrategy;\n\tif (strategy === 'rendezvous') return rendezvousStrategy;\n\treturn strategy;\n};\n\n// -----------------------------------------------------------------------------\n// Router\n// -----------------------------------------------------------------------------\n\ntype TenantState = {\n\tactive: number;\n\ttokens: number;\n\tlastRefillAt: number;\n};\n\nexport const createRouter = (options: RouterOptions): Router => {\n\tconst clock = options.clock ?? Date.now;\n\tconst strategy = resolveStrategy(options.hashStrategy ?? 'jump');\n\tconst cap = options.perTenantConnectionCap ?? Infinity;\n\tconst rate = options.perTenantRateLimit ?? { refillPerSecond: 0, tokens: Infinity };\n\n\tconst shardList: Shard[] = [...options.shards];\n\tconst healthy = new Map<string, boolean>();\n\tfor (const shard of shardList) healthy.set(shard.id, true);\n\n\tconst tenants = new Map<string, TenantState>();\n\tlet disposed = false;\n\n\tconst freshTenant = (now: number): TenantState => ({\n\t\tactive: 0,\n\t\tlastRefillAt: now,\n\t\ttokens: rate.tokens,\n\t});\n\n\tconst ensureTenant = (id: string, now: number): TenantState => {\n\t\tconst found = tenants.get(id);\n\t\tif (found) return found;\n\t\tconst fresh = freshTenant(now);\n\t\ttenants.set(id, fresh);\n\t\treturn fresh;\n\t};\n\n\tconst refill = (state: TenantState, now: number) => {\n\t\tif (rate.refillPerSecond <= 0) return;\n\t\tconst elapsedMs = now - state.lastRefillAt;\n\t\tif (elapsedMs <= 0) return;\n\t\tconst added = (elapsedMs / 1000) * rate.refillPerSecond;\n\t\tstate.tokens = Math.min(rate.tokens, state.tokens + added);\n\t\tstate.lastRefillAt = now;\n\t};\n\n\tconst healthyShards = (): Shard[] => shardList.filter((shard) => healthy.get(shard.id) === true);\n\n\tconst route: Router['route'] = (request) => {\n\t\tif (disposed) return { decision: 'no-shards', shard: null };\n\n\t\tconst live = healthyShards();\n\t\tif (live.length === 0) return { decision: 'no-shards', shard: null };\n\n\t\tconst now = clock();\n\t\tconst state = ensureTenant(request.tenantId, now);\n\n\t\tif (state.active >= cap) {\n\t\t\treturn { decision: 'capped', shard: null };\n\t\t}\n\n\t\trefill(state, now);\n\t\tif (state.tokens < 1) {\n\t\t\treturn { decision: 'rate-limited', shard: null };\n\t\t}\n\t\tstate.tokens -= 1;\n\n\t\tconst key = request.channelId === undefined\n\t\t\t? request.tenantId\n\t\t\t: `${request.tenantId}:${request.channelId}`;\n\t\tconst index = strategy(key, live);\n\t\tconst chosen = live[Math.max(0, Math.min(index, live.length - 1))]!;\n\t\treturn { decision: 'allow', shard: chosen };\n\t};\n\n\tconst acquire: Router['acquire'] = (tenantId) => {\n\t\tconst now = clock();\n\t\tconst state = ensureTenant(tenantId, now);\n\t\tstate.active += 1;\n\t\tlet released = false;\n\t\treturn {\n\t\t\tactive: state.active,\n\t\t\trelease: () => {\n\t\t\t\tif (released) return;\n\t\t\t\treleased = true;\n\t\t\t\tconst current = tenants.get(tenantId);\n\t\t\t\tif (current && current.active > 0) current.active -= 1;\n\t\t\t},\n\t\t};\n\t};\n\n\treturn {\n\t\tacquire,\n\t\taddShard: (shard) => {\n\t\t\tif (shardList.some((existing) => existing.id === shard.id)) return;\n\t\t\tshardList.push(shard);\n\t\t\thealthy.set(shard.id, true);\n\t\t},\n\t\tdispose: () => {\n\t\t\tdisposed = true;\n\t\t\tshardList.length = 0;\n\t\t\thealthy.clear();\n\t\t\ttenants.clear();\n\t\t},\n\t\tmarkHealthy: (id) => {\n\t\t\tif (healthy.has(id)) healthy.set(id, true);\n\t\t},\n\t\tmarkUnhealthy: (id) => {\n\t\t\tif (healthy.has(id)) healthy.set(id, false);\n\t\t},\n\t\tremoveShard: (id) => {\n\t\t\tconst at = shardList.findIndex((shard) => shard.id === id);\n\t\t\tif (at >= 0) shardList.splice(at, 1);\n\t\t\thealthy.delete(id);\n\t\t},\n\t\troute,\n\t\tshards: () => shardList.map((shard) => ({ ...shard })),\n\t\tsnapshot: () => ({\n\t\t\tshards: shardList.map((shard) => ({\n\t\t\t\t...shard,\n\t\t\t\tactive: 0,\n\t\t\t\thealthy: healthy.get(shard.id) === true,\n\t\t\t})),\n\t\t\ttenants: tenants.size,\n\t\t}),\n\t};\n};\n"
|
|
5
|
+
"/**\n * @absolutejs/router — multi-tenant connection routing primitive for Bun PaaS\n * gateways.\n *\n * Sits between an incoming request / WS upgrade and N backend processes (each\n * one a `@absolutejs/runtime` instance hosting a `@absolutejs/sync` engine\n * for a subset of tenants). Decides which shard owns the tenant, whether the\n * tenant is over its rate limit (tenant-wide + per-route), whether the tenant\n * is over its connection cap, whether the caller-supplied allow hook approves,\n * and whether the chosen shard is healthy and not draining.\n *\n * v0.1.0 is bun/elysia-agnostic — pure logic, no `Bun.serve` or proxy code.\n * The caller wires the routing decision into whichever HTTP/WS layer they\n * have. An Elysia adapter ships in a later 0.0.x as a subpath.\n */\n\nexport type Shard = {\n\tid: string;\n\t/**\n\t * Connection target for the chosen backend. Format is opaque to the router\n\t * — `ws://host:port`, `https://host`, a unix socket path, whatever the\n\t * caller's proxy layer understands.\n\t */\n\turl: string;\n\t/**\n\t * Relative weight for hash strategies that support it (rendezvous). Jump\n\t * hash ignores weights — every shard is treated as weight 1. Default 1.\n\t */\n\tweight?: number;\n};\n\n/**\n * Pure hash-strategy function. Given the routing key + the list of currently\n * eligible shards (healthy AND not draining), return the index in `shards` of\n * the chosen shard. The router never calls a strategy with an empty `shards`\n * array — that case is handled upstream as `no-shards`.\n */\nexport type HashStrategyFn = (key: string, shards: ReadonlyArray<Shard>) => number;\n\nexport type HashStrategy = 'jump' | 'rendezvous' | HashStrategyFn;\n\n/**\n * Per-tenant token-bucket rate limit. `tokens` is the bucket capacity AND the\n * starting balance; `refillPerSecond` is added continuously up to capacity.\n * Defaults: `Infinity` tokens / `0` refill = no limit.\n */\nexport type RateLimit = {\n\ttokens: number;\n\trefillPerSecond: number;\n};\n\n/**\n * Optional caller-supplied gate. Called per-route with the tenant id; returning\n * `false` causes the route to return `decision: 'denied'`. The intended\n * use is `meter.allow` from `@absolutejs/metering` — pass it directly to refuse\n * routes for over-quota tenants without wiring the integration manually.\n */\nexport type AllowHook = (tenantId: string) => boolean;\n\n/**\n * Optional caller-supplied load metric. Called per `route()` with a shard id;\n * returning a value > 1 makes the rendezvous strategy bias AWAY from this shard\n * (effective weight = `shard.weight / load`). Used to avoid hot-spotting when\n * a stickiness-locked shard is overloaded — the router can't move existing\n * tenants, but it can avoid sending NEW tenants there.\n *\n * Jump-hash ignores this — it has no per-call weight bias by design.\n */\nexport type ShardLoadFn = (shardId: string) => number;\n\nexport type RouterOptions = {\n\t/** Initial shard set. Can be empty (every `route()` returns `no-shards`). */\n\tshards: Shard[];\n\t/** Hash strategy. Default `'jump'`. */\n\thashStrategy?: HashStrategy;\n\t/**\n\t * Max concurrent connections per tenant (counted via `acquire()` /\n\t * `release()`). Default `Infinity` (no cap). When reached, `route()`\n\t * returns `'capped'`.\n\t */\n\tperTenantConnectionCap?: number;\n\t/**\n\t * Per-tenant token bucket. Default `{ tokens: Infinity, refillPerSecond: 0 }`\n\t * (no limit). When the tenant's bucket is empty, `route()` returns\n\t * `'rate-limited'`. One `route()` call costs one token.\n\t */\n\tperTenantRateLimit?: RateLimit;\n\t/**\n\t * Per-route token buckets layered on top of the tenant-wide bucket.\n\t * Keyed by route name; supplied by the caller via\n\t * `route({ route: 'someRoute' })`. The tenant bucket AND the route bucket\n\t * must both have a token available. When the route bucket is empty,\n\t * `route()` returns `'rate-limited'` with `routeId` set.\n\t */\n\tperRouteRateLimits?: Record<string, RateLimit>;\n\t/** Optional load hook biasing the rendezvous strategy. */\n\tload?: ShardLoadFn;\n\t/** Optional caller-supplied allow gate. */\n\tallow?: AllowHook;\n\t/** Override `Date.now` for tests. */\n\tclock?: () => number;\n};\n\nexport type RouteDecision =\n\t| 'allow'\n\t| 'rate-limited'\n\t| 'capped'\n\t| 'no-shards'\n\t| 'denied';\n\n/**\n * Returned by {@link Router.metrics}. Combines point-in-time state with\n * cumulative counters since `createRouter()`. Survives `dispose()` so\n * post-shutdown introspection still reads totals. Added in 0.2.0.\n *\n * - `routes` — total `route()` calls (any decision).\n * - `acquires` — total `acquire()` calls.\n * - `rejectsByDecision` — counts per non-allow `RouteDecision`. The\n * operator's \"where am I shedding load?\" answer.\n * - `shardLoadDistribution` — cumulative routes assigned per shard\n * since `createRouter()`. With `markHealthy`/`drainShard` this is\n * the \"is rebalancing actually rebalancing?\" signal. (Per-shard\n * *active* connection count would require route-to-acquire\n * correlation that the current `acquire(tenantId)` API doesn't\n * capture; cumulative routes are the directly-observable proxy.)\n * - `lastRouteMs` — wall-clock of the most recent `route()` call (a\n * climb means the hot path is getting slower — usually a sign that\n * `load:` is doing too much work, or the shard count exploded).\n */\nexport type RouterMetrics = {\n\troutes: number;\n\tacquires: number;\n\trejectsByDecision: Record<Exclude<RouteDecision, 'allow'>, number>;\n\tshardLoadDistribution: Record<string, number>;\n\tlastRouteMs: number;\n};\n\nexport type RouteRequest = {\n\ttenantId: string;\n\t/**\n\t * Optional sub-key within a tenant for shardable channels. When set, the\n\t * hash key is `${tenantId}:${channelId}`. Use this when a single tenant is\n\t * too hot for one engine and its work can be partitioned (e.g. per-doc,\n\t * per-room) across multiple engines. Different channels of the same\n\t * tenant may land on different shards.\n\t */\n\tchannelId?: string;\n\t/**\n\t * Optional per-route rate-limit key. Looked up in `perRouteRateLimits`.\n\t * Unknown routes pass without per-route gating (only the tenant-wide\n\t * bucket applies).\n\t */\n\troute?: string;\n};\n\nexport type RouteResult = {\n\tdecision: RouteDecision;\n\t/** The chosen shard. `null` when `decision !== 'allow'`. */\n\tshard: Shard | null;\n\t/**\n\t * Set on a `'rate-limited'` result to tell the caller which bucket emptied:\n\t * `'tenant'` for the tenant-wide bucket, the route id for a per-route bucket.\n\t */\n\temptiedBucket?: 'tenant' | string;\n};\n\nexport type AcquireHandle = {\n\t/** Number of active connections for this tenant after acquire (>=1). */\n\tactive: number;\n\t/** Releases one connection for this tenant. Idempotent — safe to call once. */\n\trelease: () => void;\n};\n\nexport type RouterSnapshot = {\n\tversion: 1;\n\tat: number;\n\tshards: Array<Shard & { healthy: boolean; draining: boolean; active: number }>;\n\ttenants: Array<{\n\t\ttenant: string;\n\t\tactive: number;\n\t\ttokens: number;\n\t\tlastRefillAt: number;\n\t}>;\n\trouteBuckets: Array<{\n\t\ttenant: string;\n\t\troute: string;\n\t\ttokens: number;\n\t\tlastRefillAt: number;\n\t}>;\n};\n\nexport type Router = {\n\troute: (request: RouteRequest) => RouteResult;\n\tacquire: (tenantId: string) => AcquireHandle;\n\tmarkHealthy: (shardId: string) => void;\n\tmarkUnhealthy: (shardId: string) => void;\n\t/**\n\t * Begin draining a shard — exclude it from new routing decisions while\n\t * leaving existing acquires alone. Semantically distinct from\n\t * `markUnhealthy` (an operator-intentional state, not a failure).\n\t * `markHealthy` cancels both states. Use this before a planned shard\n\t * shutdown — tenants on the shard rehash to healthy non-draining shards\n\t * on their next route, but in-flight requests are NOT torn down.\n\t */\n\tdrainShard: (shardId: string) => void;\n\tisHealthy: (shardId: string) => boolean;\n\tisDraining: (shardId: string) => boolean;\n\taddShard: (shard: Shard) => void;\n\tremoveShard: (shardId: string) => void;\n\tshards: () => Shard[];\n\tsnapshot: () => RouterSnapshot;\n\trestore: (snapshot: RouterSnapshot) => void;\n\t/**\n\t * Operator-shaped point-in-time + cumulative metrics since\n\t * `createRouter()`. Use for tier dashboards and \"where am I\n\t * shedding load\" alerts. Distinct from `snapshot()`, which is the\n\t * persistence shape (preserves tenant + bucket state across a\n\t * shard reboot). Added in 0.2.0.\n\t */\n\tmetrics: () => RouterMetrics;\n\tdispose: () => void;\n};\n\n// -----------------------------------------------------------------------------\n// Hash strategies\n// -----------------------------------------------------------------------------\n\n/**\n * FNV-1a 32-bit. Cheap, no deps, good enough as a seed for the consistent\n * hash strategies below. Not cryptographic — do NOT use as a security hash.\n */\nconst fnv1a32 = (input: string): number => {\n\tlet hash = 0x811c9dc5;\n\tfor (let i = 0; i < input.length; i++) {\n\t\thash ^= input.charCodeAt(i);\n\t\thash = Math.imul(hash, 0x01000193);\n\t}\n\treturn hash >>> 0;\n};\n\n/**\n * Jump-consistent-hash, Lamping & Veach 2014. Maps a 64-bit key to a bucket\n * in `[0, numBuckets)`. Properties: O(log n), no memory, exactly 1/N keys\n * move when buckets are added at the tail.\n */\nconst jumpHash = (key: string, numBuckets: number): number => {\n\tif (numBuckets <= 0) return -1;\n\tlet k = BigInt(fnv1a32(key)) | (BigInt(fnv1a32(key + '#hi')) << 32n);\n\tlet b = -1n;\n\tlet j = 0n;\n\twhile (j < BigInt(numBuckets)) {\n\t\tb = j;\n\t\tk = (k * 2862933555777941757n + 1n) & 0xffffffffffffffffn;\n\t\tconst shifted = (k >> 33n) + 1n;\n\t\tj = ((b + 1n) * (1n << 31n)) / shifted;\n\t}\n\treturn Number(b);\n};\n\nconst jumpStrategy: HashStrategyFn = (key, shards) => jumpHash(key, shards.length);\n\nconst makeRendezvousStrategy = (load?: ShardLoadFn): HashStrategyFn => (key, shards) => {\n\tlet bestIndex = 0;\n\tlet bestScore = -Infinity;\n\tfor (let i = 0; i < shards.length; i++) {\n\t\tconst shard = shards[i]!;\n\t\tconst baseWeight = shard.weight ?? 1;\n\t\tif (baseWeight <= 0) continue;\n\t\tconst loadValue = load ? Math.max(load(shard.id), 0.0001) : 1;\n\t\tconst effectiveWeight = baseWeight / loadValue;\n\t\tconst seed = fnv1a32(`${key}|${shard.id}`);\n\t\tconst u = (seed + 1) / 0x1_0000_0000;\n\t\tconst score = effectiveWeight * -Math.log(u);\n\t\tif (score > bestScore) {\n\t\t\tbestScore = score;\n\t\t\tbestIndex = i;\n\t\t}\n\t}\n\treturn bestIndex;\n};\n\nconst resolveStrategy = (strategy: HashStrategy, load?: ShardLoadFn): HashStrategyFn => {\n\tif (strategy === 'jump') return jumpStrategy;\n\tif (strategy === 'rendezvous') return makeRendezvousStrategy(load);\n\treturn strategy;\n};\n\n// -----------------------------------------------------------------------------\n// Router\n// -----------------------------------------------------------------------------\n\ntype Bucket = {\n\ttokens: number;\n\tlastRefillAt: number;\n};\n\ntype TenantState = {\n\tactive: number;\n\tbucket: Bucket;\n};\n\nexport const createRouter = (options: RouterOptions): Router => {\n\tconst clock = options.clock ?? Date.now;\n\tconst strategy = resolveStrategy(options.hashStrategy ?? 'jump', options.load);\n\tconst cap = options.perTenantConnectionCap ?? Infinity;\n\tconst rate = options.perTenantRateLimit ?? { refillPerSecond: 0, tokens: Infinity };\n\tconst perRouteRateLimits = options.perRouteRateLimits ?? {};\n\tconst allowHook = options.allow;\n\n\tconst shardList: Shard[] = [...options.shards];\n\tconst healthy = new Map<string, boolean>();\n\tconst draining = new Set<string>();\n\tfor (const shard of shardList) healthy.set(shard.id, true);\n\n\tconst tenants = new Map<string, TenantState>();\n\t/** Per-tenant per-route bucket. Key = `${tenant}|${route}`. */\n\tconst routeBuckets = new Map<string, Bucket>();\n\tlet disposed = false;\n\t// 0.2.0: cumulative operator counters. Per-shard active count is\n\t// derived from `acquires` minus per-shard releases — but tracking\n\t// per-shard requires routing → acquire correlation that the\n\t// existing API doesn't enforce. Use point-in-time aggregation from\n\t// `tenants` Map: each tenant's active count contributes to its\n\t// most-recently-routed shard. (Acquire isn't shard-tagged today;\n\t// adding a `shardId` to the AcquireHandle is a separate change.)\n\tlet totalRoutes = 0;\n\tlet totalAcquires = 0;\n\tlet lastRouteMs = 0;\n\tconst rejectsByDecision: Record<\n\t\tExclude<RouteDecision, 'allow'>,\n\t\tnumber\n\t> = {\n\t\t'rate-limited': 0,\n\t\t'capped': 0,\n\t\t'no-shards': 0,\n\t\t'denied': 0\n\t};\n\tconst shardLoadByShard = new Map<string, number>();\n\n\tconst freshTenant = (now: number): TenantState => ({\n\t\tactive: 0,\n\t\tbucket: { lastRefillAt: now, tokens: rate.tokens },\n\t});\n\n\tconst ensureTenant = (id: string, now: number): TenantState => {\n\t\tconst found = tenants.get(id);\n\t\tif (found) return found;\n\t\tconst fresh = freshTenant(now);\n\t\ttenants.set(id, fresh);\n\t\treturn fresh;\n\t};\n\n\tconst refillBucket = (bucket: Bucket, rule: RateLimit, now: number) => {\n\t\tif (rule.refillPerSecond <= 0) return;\n\t\tconst elapsedMs = now - bucket.lastRefillAt;\n\t\tif (elapsedMs <= 0) return;\n\t\tconst added = (elapsedMs / 1000) * rule.refillPerSecond;\n\t\tbucket.tokens = Math.min(rule.tokens, bucket.tokens + added);\n\t\tbucket.lastRefillAt = now;\n\t};\n\n\tconst ensureRouteBucket = (tenant: string, route: string, rule: RateLimit, now: number): Bucket => {\n\t\tconst key = `${tenant}|${route}`;\n\t\tconst found = routeBuckets.get(key);\n\t\tif (found) return found;\n\t\tconst fresh: Bucket = { lastRefillAt: now, tokens: rule.tokens };\n\t\trouteBuckets.set(key, fresh);\n\t\treturn fresh;\n\t};\n\n\tconst eligibleShards = (): Shard[] =>\n\t\tshardList.filter((shard) => healthy.get(shard.id) === true && !draining.has(shard.id));\n\n\tconst route: Router['route'] = (request) => {\n\t\tconst routeStart = clock();\n\t\ttotalRoutes += 1;\n\t\tconst recordRejection = (\n\t\t\tdecision: Exclude<RouteDecision, 'allow'>\n\t\t): RouteResult => {\n\t\t\trejectsByDecision[decision] += 1;\n\t\t\tlastRouteMs = clock() - routeStart;\n\t\t\treturn { decision, shard: null };\n\t\t};\n\t\tif (disposed) return recordRejection('no-shards');\n\n\t\tconst live = eligibleShards();\n\t\tif (live.length === 0) return recordRejection('no-shards');\n\n\t\tif (allowHook && !allowHook(request.tenantId)) {\n\t\t\treturn recordRejection('denied');\n\t\t}\n\n\t\tconst now = routeStart;\n\t\tconst state = ensureTenant(request.tenantId, now);\n\n\t\tif (state.active >= cap) {\n\t\t\treturn recordRejection('capped');\n\t\t}\n\n\t\trefillBucket(state.bucket, rate, now);\n\t\tif (state.bucket.tokens < 1) {\n\t\t\trejectsByDecision['rate-limited'] += 1;\n\t\t\tlastRouteMs = clock() - routeStart;\n\t\t\treturn {\n\t\t\t\tdecision: 'rate-limited',\n\t\t\t\temptiedBucket: 'tenant',\n\t\t\t\tshard: null\n\t\t\t};\n\t\t}\n\n\t\tlet routeBucket: Bucket | null = null;\n\t\tlet routeRule: RateLimit | null = null;\n\t\tif (request.route !== undefined && perRouteRateLimits[request.route] !== undefined) {\n\t\t\trouteRule = perRouteRateLimits[request.route]!;\n\t\t\trouteBucket = ensureRouteBucket(request.tenantId, request.route, routeRule, now);\n\t\t\trefillBucket(routeBucket, routeRule, now);\n\t\t\tif (routeBucket.tokens < 1) {\n\t\t\t\trejectsByDecision['rate-limited'] += 1;\n\t\t\t\tlastRouteMs = clock() - routeStart;\n\t\t\t\treturn {\n\t\t\t\t\tdecision: 'rate-limited',\n\t\t\t\t\temptiedBucket: request.route,\n\t\t\t\t\tshard: null\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Commit both buckets only after both passed.\n\t\tstate.bucket.tokens -= 1;\n\t\tif (routeBucket) routeBucket.tokens -= 1;\n\n\t\tconst key = request.channelId === undefined\n\t\t\t? request.tenantId\n\t\t\t: `${request.tenantId}:${request.channelId}`;\n\t\tconst index = strategy(key, live);\n\t\tconst chosen = live[Math.max(0, Math.min(index, live.length - 1))]!;\n\t\tshardLoadByShard.set(\n\t\t\tchosen.id,\n\t\t\t(shardLoadByShard.get(chosen.id) ?? 0) + 1\n\t\t);\n\t\tlastRouteMs = clock() - routeStart;\n\t\treturn { decision: 'allow', shard: chosen };\n\t};\n\n\tconst acquire: Router['acquire'] = (tenantId) => {\n\t\tconst now = clock();\n\t\tconst state = ensureTenant(tenantId, now);\n\t\tstate.active += 1;\n\t\ttotalAcquires += 1;\n\t\tlet released = false;\n\t\treturn {\n\t\t\tactive: state.active,\n\t\t\trelease: () => {\n\t\t\t\tif (released) return;\n\t\t\t\treleased = true;\n\t\t\t\tconst current = tenants.get(tenantId);\n\t\t\t\tif (current && current.active > 0) current.active -= 1;\n\t\t\t},\n\t\t};\n\t};\n\n\treturn {\n\t\tacquire,\n\t\taddShard: (shard) => {\n\t\t\tif (shardList.some((existing) => existing.id === shard.id)) return;\n\t\t\tshardList.push(shard);\n\t\t\thealthy.set(shard.id, true);\n\t\t},\n\t\tdispose: () => {\n\t\t\tdisposed = true;\n\t\t\tshardList.length = 0;\n\t\t\thealthy.clear();\n\t\t\tdraining.clear();\n\t\t\ttenants.clear();\n\t\t\trouteBuckets.clear();\n\t\t},\n\t\tdrainShard: (id) => {\n\t\t\tif (healthy.has(id)) draining.add(id);\n\t\t},\n\t\tisDraining: (id) => draining.has(id),\n\t\tisHealthy: (id) => healthy.get(id) === true,\n\t\tmetrics: () => ({\n\t\t\tacquires: totalAcquires,\n\t\t\tlastRouteMs,\n\t\t\trejectsByDecision: { ...rejectsByDecision },\n\t\t\troutes: totalRoutes,\n\t\t\tshardLoadDistribution: Object.fromEntries(shardLoadByShard)\n\t\t}),\n\t\tmarkHealthy: (id) => {\n\t\t\tif (healthy.has(id)) {\n\t\t\t\thealthy.set(id, true);\n\t\t\t\tdraining.delete(id);\n\t\t\t}\n\t\t},\n\t\tmarkUnhealthy: (id) => {\n\t\t\tif (healthy.has(id)) healthy.set(id, false);\n\t\t},\n\t\tremoveShard: (id) => {\n\t\t\tconst at = shardList.findIndex((shard) => shard.id === id);\n\t\t\tif (at >= 0) shardList.splice(at, 1);\n\t\t\thealthy.delete(id);\n\t\t\tdraining.delete(id);\n\t\t},\n\t\troute,\n\t\tshards: () => shardList.map((shard) => ({ ...shard })),\n\t\tsnapshot: () => {\n\t\t\tconst now = clock();\n\t\t\tconst tenantsOut: RouterSnapshot['tenants'] = [];\n\t\t\tfor (const [tenant, state] of tenants) {\n\t\t\t\ttenantsOut.push({\n\t\t\t\t\tactive: state.active,\n\t\t\t\t\tlastRefillAt: state.bucket.lastRefillAt,\n\t\t\t\t\ttenant,\n\t\t\t\t\ttokens: state.bucket.tokens,\n\t\t\t\t});\n\t\t\t}\n\t\t\tconst routesOut: RouterSnapshot['routeBuckets'] = [];\n\t\t\tfor (const [key, bucket] of routeBuckets) {\n\t\t\t\tconst pipe = key.indexOf('|');\n\t\t\t\tif (pipe < 0) continue;\n\t\t\t\troutesOut.push({\n\t\t\t\t\tlastRefillAt: bucket.lastRefillAt,\n\t\t\t\t\troute: key.slice(pipe + 1),\n\t\t\t\t\ttenant: key.slice(0, pipe),\n\t\t\t\t\ttokens: bucket.tokens,\n\t\t\t\t});\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tat: now,\n\t\t\t\trouteBuckets: routesOut,\n\t\t\t\tshards: shardList.map((shard) => {\n\t\t\t\t\tconst active = Array.from(tenants.values()).reduce((acc, state) => acc + state.active, 0);\n\t\t\t\t\treturn {\n\t\t\t\t\t\t...shard,\n\t\t\t\t\t\tactive,\n\t\t\t\t\t\tdraining: draining.has(shard.id),\n\t\t\t\t\t\thealthy: healthy.get(shard.id) === true,\n\t\t\t\t\t};\n\t\t\t\t}),\n\t\t\t\ttenants: tenantsOut,\n\t\t\t\tversion: 1,\n\t\t\t};\n\t\t},\n\t\trestore: (snap) => {\n\t\t\ttenants.clear();\n\t\t\trouteBuckets.clear();\n\t\t\tdraining.clear();\n\t\t\tfor (const t of snap.tenants) {\n\t\t\t\ttenants.set(t.tenant, {\n\t\t\t\t\tactive: t.active,\n\t\t\t\t\tbucket: { lastRefillAt: t.lastRefillAt, tokens: t.tokens },\n\t\t\t\t});\n\t\t\t}\n\t\t\tfor (const r of snap.routeBuckets) {\n\t\t\t\trouteBuckets.set(`${r.tenant}|${r.route}`, {\n\t\t\t\t\tlastRefillAt: r.lastRefillAt,\n\t\t\t\t\ttokens: r.tokens,\n\t\t\t\t});\n\t\t\t}\n\t\t\tfor (const shard of snap.shards) {\n\t\t\t\thealthy.set(shard.id, shard.healthy);\n\t\t\t\tif (shard.draining) draining.add(shard.id);\n\t\t\t}\n\t\t},\n\t};\n};\n"
|
|
6
6
|
],
|
|
7
|
-
"mappings": ";;
|
|
8
|
-
"debugId": "
|
|
7
|
+
"mappings": "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",
|
|
8
|
+
"debugId": "E49309CC1211AA2864756E2164756E21",
|
|
9
9
|
"names": []
|
|
10
10
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@absolutejs/router",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Multi-tenant connection routing primitive for Bun PaaS gateways. Consistent-hash tenant→shard, per-tenant connection cap, per-tenant rate limit, healthy-shard skip. The library that goes in front of N @absolutejs/runtime instances.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|