@stimulcross/rate-limiter 0.0.1
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/.editorconfig +21 -0
- package/.github/workflows/node.yml +87 -0
- package/.husky/commit-msg +1 -0
- package/.husky/pre-commit +1 -0
- package/.megaignore +8 -0
- package/.prettierignore +3 -0
- package/LICENSE +21 -0
- package/README.md +7 -0
- package/commitlint.config.js +8 -0
- package/eslint.config.js +65 -0
- package/lint-staged.config.js +4 -0
- package/package.json +89 -0
- package/prettier.config.cjs +1 -0
- package/src/core/cancellable.ts +4 -0
- package/src/core/clock.ts +9 -0
- package/src/core/decision.ts +27 -0
- package/src/core/rate-limit-policy.ts +15 -0
- package/src/core/rate-limiter-status.ts +14 -0
- package/src/core/rate-limiter.ts +37 -0
- package/src/core/state-storage.ts +51 -0
- package/src/enums/rate-limit-error-code.ts +29 -0
- package/src/errors/custom.error.ts +14 -0
- package/src/errors/invalid-cost.error.ts +33 -0
- package/src/errors/rate-limit.error.ts +91 -0
- package/src/errors/rate-limiter-destroyed.error.ts +8 -0
- package/src/index.ts +11 -0
- package/src/interfaces/rate-limiter-options.ts +84 -0
- package/src/interfaces/rate-limiter-queue-options.ts +45 -0
- package/src/interfaces/rate-limiter-run-options.ts +58 -0
- package/src/limiters/abstract-rate-limiter.ts +206 -0
- package/src/limiters/composite.policy.ts +102 -0
- package/src/limiters/fixed-window/fixed-window.limiter.ts +121 -0
- package/src/limiters/fixed-window/fixed-window.options.ts +29 -0
- package/src/limiters/fixed-window/fixed-window.policy.ts +159 -0
- package/src/limiters/fixed-window/fixed-window.state.ts +10 -0
- package/src/limiters/fixed-window/fixed-window.status.ts +46 -0
- package/src/limiters/fixed-window/index.ts +4 -0
- package/src/limiters/generic-cell/generic-cell.limiter.ts +108 -0
- package/src/limiters/generic-cell/generic-cell.options.ts +23 -0
- package/src/limiters/generic-cell/generic-cell.policy.ts +115 -0
- package/src/limiters/generic-cell/generic-cell.state.ts +8 -0
- package/src/limiters/generic-cell/generic-cell.status.ts +54 -0
- package/src/limiters/generic-cell/index.ts +4 -0
- package/src/limiters/http-response-based/http-limit-info.extractor.ts +20 -0
- package/src/limiters/http-response-based/http-limit.info.ts +41 -0
- package/src/limiters/http-response-based/http-response-based-limiter.options.ts +18 -0
- package/src/limiters/http-response-based/http-response-based-limiter.state.ts +13 -0
- package/src/limiters/http-response-based/http-response-based-limiter.status.ts +74 -0
- package/src/limiters/http-response-based/http-response-based.limiter.ts +512 -0
- package/src/limiters/http-response-based/index.ts +6 -0
- package/src/limiters/leaky-bucket/index.ts +4 -0
- package/src/limiters/leaky-bucket/leaky-bucket.limiter.ts +105 -0
- package/src/limiters/leaky-bucket/leaky-bucket.options.ts +23 -0
- package/src/limiters/leaky-bucket/leaky-bucket.policy.ts +134 -0
- package/src/limiters/leaky-bucket/leaky-bucket.state.ts +9 -0
- package/src/limiters/leaky-bucket/leaky-bucket.status.ts +36 -0
- package/src/limiters/sliding-window-counter/index.ts +7 -0
- package/src/limiters/sliding-window-counter/sliding-window-counter.limiter.ts +76 -0
- package/src/limiters/sliding-window-counter/sliding-window-counter.options.ts +20 -0
- package/src/limiters/sliding-window-counter/sliding-window-counter.policy.ts +167 -0
- package/src/limiters/sliding-window-counter/sliding-window-counter.state.ts +10 -0
- package/src/limiters/sliding-window-counter/sliding-window-counter.status.ts +53 -0
- package/src/limiters/sliding-window-log/index.ts +4 -0
- package/src/limiters/sliding-window-log/sliding-window-log.limiter.ts +65 -0
- package/src/limiters/sliding-window-log/sliding-window-log.options.ts +20 -0
- package/src/limiters/sliding-window-log/sliding-window-log.policy.ts +166 -0
- package/src/limiters/sliding-window-log/sliding-window-log.state.ts +19 -0
- package/src/limiters/sliding-window-log/sliding-window-log.status.ts +44 -0
- package/src/limiters/token-bucket/index.ts +4 -0
- package/src/limiters/token-bucket/token-bucket.limiter.ts +110 -0
- package/src/limiters/token-bucket/token-bucket.options.ts +17 -0
- package/src/limiters/token-bucket/token-bucket.policy.ts +155 -0
- package/src/limiters/token-bucket/token-bucket.state.ts +10 -0
- package/src/limiters/token-bucket/token-bucket.status.ts +36 -0
- package/src/runtime/default-clock.ts +8 -0
- package/src/runtime/execution-tickets.ts +34 -0
- package/src/runtime/in-memory-state-store.ts +135 -0
- package/src/runtime/rate-limiter.executor.ts +286 -0
- package/src/runtime/semaphore.ts +31 -0
- package/src/runtime/task.ts +141 -0
- package/src/types/limit-behavior.ts +8 -0
- package/src/utils/generate-random-string.ts +16 -0
- package/src/utils/promise-with-resolvers.ts +23 -0
- package/src/utils/sanitize-error.ts +4 -0
- package/src/utils/sanitize-priority.ts +22 -0
- package/src/utils/validate-cost.ts +16 -0
- package/tests/integration/limiters/fixed-window.limiter.spec.ts +371 -0
- package/tests/integration/limiters/generic-cell.limiter.spec.ts +361 -0
- package/tests/integration/limiters/http-response-based.limiter.spec.ts +833 -0
- package/tests/integration/limiters/leaky-bucket.spec.ts +357 -0
- package/tests/integration/limiters/sliding-window-counter.limiter.spec.ts +175 -0
- package/tests/integration/limiters/sliding-window-log.spec.ts +185 -0
- package/tests/integration/limiters/token-bucket.limiter.spec.ts +363 -0
- package/tests/tsconfig.json +4 -0
- package/tests/unit/policies/composite.policy.spec.ts +244 -0
- package/tests/unit/policies/fixed-window.policy.spec.ts +260 -0
- package/tests/unit/policies/generic-cell.policy.spec.ts +178 -0
- package/tests/unit/policies/leaky-bucket.policy.spec.ts +215 -0
- package/tests/unit/policies/sliding-window-counter.policy.spec.ts +209 -0
- package/tests/unit/policies/sliding-window-log.policy.spec.ts +285 -0
- package/tests/unit/policies/token-bucket.policy.spec.ts +371 -0
- package/tests/unit/runtime/execution-tickets.spec.ts +121 -0
- package/tests/unit/runtime/in-memory-state-store.spec.ts +238 -0
- package/tests/unit/runtime/rate-limiter.executor.spec.ts +353 -0
- package/tests/unit/runtime/semaphore.spec.ts +98 -0
- package/tests/unit/runtime/task.spec.ts +182 -0
- package/tests/unit/utils/generate-random-string.spec.ts +51 -0
- package/tests/unit/utils/promise-with-resolvers.spec.ts +57 -0
- package/tests/unit/utils/sanitize-priority.spec.ts +46 -0
- package/tests/unit/utils/validate-cost.spec.ts +48 -0
- package/tsconfig.json +14 -0
- package/vitest.config.js +22 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { InvalidCostError } from '../../../src/index.js';
|
|
3
|
+
import { TokenBucketPolicy } from '../../../src/limiters/token-bucket/token-bucket.policy.js';
|
|
4
|
+
|
|
5
|
+
describe('TokenBucketPolicy', () => {
|
|
6
|
+
describe('Constructor', () => {
|
|
7
|
+
it('should initialize with valid parameters', () => {
|
|
8
|
+
const policy = new TokenBucketPolicy(10, 5, 20);
|
|
9
|
+
expect(policy.capacity).toBe(10);
|
|
10
|
+
expect(policy.refillRate).toBe(5);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should throw an error for invalid capacity', () => {
|
|
14
|
+
expect(() => new TokenBucketPolicy(0, 5)).toThrow(/Invalid capacity/u);
|
|
15
|
+
expect(() => new TokenBucketPolicy(-10, 5)).toThrow(/Invalid capacity/u);
|
|
16
|
+
expect(() => new TokenBucketPolicy(5.5, 5)).toThrow(/Invalid capacity/u);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should throw an error for invalid refillRate', () => {
|
|
20
|
+
expect(() => new TokenBucketPolicy(10, 0)).toThrow(/Invalid refillRate/u);
|
|
21
|
+
expect(() => new TokenBucketPolicy(10, -5)).toThrow(/Invalid refillRate/u);
|
|
22
|
+
expect(() => new TokenBucketPolicy(10, Number.NaN)).toThrow(/Invalid refillRate/u);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should throw an error for invalid maxDebt', () => {
|
|
26
|
+
expect(() => new TokenBucketPolicy(10, 5, -5)).toThrow(/Invalid maxDebt/u);
|
|
27
|
+
expect(() => new TokenBucketPolicy(10, 5, 5.5)).toThrow(/Invalid maxDebt/u);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should return correct initial state', () => {
|
|
31
|
+
const policy = new TokenBucketPolicy(10, 5);
|
|
32
|
+
expect(policy.getInitialState()).toEqual({ tokens: 10, debt: 0, lastRefill: 0 });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should allow Infinity as maxDebt', () => {
|
|
36
|
+
expect(() => new TokenBucketPolicy(10, 5, Number.POSITIVE_INFINITY)).not.toThrow();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('Get Status', () => {
|
|
41
|
+
const policy = new TokenBucketPolicy(10, 10);
|
|
42
|
+
|
|
43
|
+
it('should return correct info for completely full bucket', () => {
|
|
44
|
+
const state = policy.getInitialState();
|
|
45
|
+
const status = policy.getStatus(state, 1000);
|
|
46
|
+
|
|
47
|
+
expect(status.capacity).toBe(10);
|
|
48
|
+
expect(status.refillRate).toBe(10);
|
|
49
|
+
expect(status.tokens).toBe(10);
|
|
50
|
+
expect(status.debt).toBe(0);
|
|
51
|
+
expect(status.nextAvailableAt).toBe(1000);
|
|
52
|
+
expect(status.resetAt).toBe(1000);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should return correct info for partially depleted bucket', () => {
|
|
56
|
+
const state = { tokens: 5, debt: 0, lastRefill: 1000 };
|
|
57
|
+
const status = policy.getStatus(state, 1000);
|
|
58
|
+
|
|
59
|
+
expect(status.tokens).toBe(5);
|
|
60
|
+
expect(status.debt).toBe(0);
|
|
61
|
+
expect(status.nextAvailableAt).toBe(1000);
|
|
62
|
+
expect(status.resetAt).toBe(1500);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should return correct info for bucket with active debt', () => {
|
|
66
|
+
const state = { tokens: 0, debt: 5, lastRefill: 1000 };
|
|
67
|
+
const status = policy.getStatus(state, 1000);
|
|
68
|
+
|
|
69
|
+
expect(status.tokens).toBe(0);
|
|
70
|
+
expect(status.debt).toBe(5);
|
|
71
|
+
expect(status.nextAvailableAt).toBe(1600);
|
|
72
|
+
expect(status.resetAt).toBe(2500);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should calculate nextAvailableAt and resetAt correctly with fractional refillRate', () => {
|
|
76
|
+
const fractionalPolicy = new TokenBucketPolicy(10, 2.5);
|
|
77
|
+
const state = { tokens: 0, debt: 0, lastRefill: 1000 };
|
|
78
|
+
const status = fractionalPolicy.getStatus(state, 1000);
|
|
79
|
+
|
|
80
|
+
expect(status.nextAvailableAt).toBe(1400);
|
|
81
|
+
expect(status.resetAt).toBe(5000);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('Evaluate', () => {
|
|
86
|
+
describe('Validation and Edge Cases', () => {
|
|
87
|
+
const policy = new TokenBucketPolicy(10, 5);
|
|
88
|
+
|
|
89
|
+
it('should throw if cost exceeds the bucket capacity', () => {
|
|
90
|
+
const state = policy.getInitialState();
|
|
91
|
+
expect(() => policy.evaluate(state, 1000, 15)).toThrow(InvalidCostError);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should allow zero cost without consuming tokens', () => {
|
|
95
|
+
const state = { tokens: 5, debt: 0, lastRefill: 1000 };
|
|
96
|
+
const result = policy.evaluate(state, 1000, 0);
|
|
97
|
+
|
|
98
|
+
expect(result.decision.kind).toBe('allow');
|
|
99
|
+
expect(result.nextState.tokens).toBe(5);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should handle time jumping backwards gracefully', () => {
|
|
103
|
+
const state = { tokens: 5, debt: 0, lastRefill: 2000 };
|
|
104
|
+
const result = policy.evaluate(state, 1000, 1);
|
|
105
|
+
|
|
106
|
+
expect(result.decision.kind).toBe('allow');
|
|
107
|
+
expect(result.nextState.tokens).toBe(4);
|
|
108
|
+
expect(result.nextState.lastRefill).toBe(2000);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('Immediate execution', () => {
|
|
113
|
+
const policy = new TokenBucketPolicy(10, 10);
|
|
114
|
+
|
|
115
|
+
it('should allow request when tokens are sufficient', () => {
|
|
116
|
+
const state = policy.getInitialState();
|
|
117
|
+
const result = policy.evaluate(state, 1000, 3);
|
|
118
|
+
|
|
119
|
+
expect(result.decision).toEqual({ kind: 'allow' });
|
|
120
|
+
expect(result.nextState).toEqual({ tokens: 7, debt: 0, lastRefill: 1000 });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should deny request and return correct retryAt when tokens are insufficient', () => {
|
|
124
|
+
const state = { tokens: 2, debt: 0, lastRefill: 1000 };
|
|
125
|
+
const result = policy.evaluate(state, 1000, 5);
|
|
126
|
+
|
|
127
|
+
expect(result.decision).toEqual({ kind: 'deny', retryAt: 1300 });
|
|
128
|
+
expect(result.nextState).toEqual(state);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should not mutate synced state on deny after time sync', () => {
|
|
132
|
+
const state = { tokens: 1, debt: 2, lastRefill: 1000 };
|
|
133
|
+
const result = policy.evaluate(state, 1100, 5);
|
|
134
|
+
|
|
135
|
+
expect(result.decision).toEqual({ kind: 'deny', retryAt: 1600 });
|
|
136
|
+
expect(result.nextState).toEqual({ tokens: 1, debt: 1, lastRefill: 1100 });
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('Reservation and Delay', () => {
|
|
141
|
+
const policy = new TokenBucketPolicy(10, 10, 20);
|
|
142
|
+
|
|
143
|
+
it('should delay request when tokens are insufficient and reservation is enabled', () => {
|
|
144
|
+
const state = { tokens: 2, debt: 0, lastRefill: 1000 };
|
|
145
|
+
const result = policy.evaluate(state, 1000, 5, true);
|
|
146
|
+
|
|
147
|
+
expect(result.decision).toEqual({ kind: 'delay', runAt: 1300 });
|
|
148
|
+
expect(result.nextState).toEqual({ tokens: 0, debt: 3, lastRefill: 1000 });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should accumulate debt across multiple reservations', () => {
|
|
152
|
+
const state = { tokens: 0, debt: 3, lastRefill: 1000 };
|
|
153
|
+
const result = policy.evaluate(state, 1000, 5, true);
|
|
154
|
+
|
|
155
|
+
expect(result.decision).toEqual({ kind: 'delay', runAt: 1800 });
|
|
156
|
+
expect(result.nextState).toEqual({ tokens: 0, debt: 8, lastRefill: 1000 });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should deny reservation if maxDebt is exceeded', () => {
|
|
160
|
+
const state = { tokens: 0, debt: 15, lastRefill: 1000 };
|
|
161
|
+
const result = policy.evaluate(state, 1000, 10, true);
|
|
162
|
+
|
|
163
|
+
expect(result.decision.kind).toBe('deny');
|
|
164
|
+
expect(result.nextState).toEqual(state);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should accurately calculate retryAt when maxDebt is exceeded (debt clearance logic)', () => {
|
|
168
|
+
const state = { tokens: 0, debt: 15, lastRefill: 1000 };
|
|
169
|
+
const result = policy.evaluate(state, 1000, 10, true);
|
|
170
|
+
|
|
171
|
+
expect(result.decision).toEqual({ kind: 'deny', retryAt: 1500 });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should allow unlimited reservation growth when maxDebt is Infinity', () => {
|
|
175
|
+
const infiniteDebtPolicy = new TokenBucketPolicy(10, 10, Number.POSITIVE_INFINITY);
|
|
176
|
+
const state = { tokens: 0, debt: 50, lastRefill: 1000 };
|
|
177
|
+
const result = infiniteDebtPolicy.evaluate(state, 1000, 10, true);
|
|
178
|
+
|
|
179
|
+
expect(result.decision).toEqual({ kind: 'delay', runAt: 7000 });
|
|
180
|
+
expect(result.nextState).toEqual({ tokens: 0, debt: 60, lastRefill: 1000 });
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('Refill and State Sync', () => {
|
|
185
|
+
const policy = new TokenBucketPolicy(10, 10);
|
|
186
|
+
|
|
187
|
+
it('should refill tokens accurately based on elapsed time', () => {
|
|
188
|
+
const state = { tokens: 2, debt: 0, lastRefill: 1000 };
|
|
189
|
+
const result = policy.evaluate(state, 1500, 1);
|
|
190
|
+
|
|
191
|
+
expect(result.decision.kind).toBe('allow');
|
|
192
|
+
expect(result.nextState).toEqual({ tokens: 6, debt: 0, lastRefill: 1500 });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should cap refilled tokens at the maximum capacity', () => {
|
|
196
|
+
const state = { tokens: 5, debt: 0, lastRefill: 1000 };
|
|
197
|
+
const result = policy.evaluate(state, 5000, 1);
|
|
198
|
+
|
|
199
|
+
expect(result.decision.kind).toBe('allow');
|
|
200
|
+
expect(result.nextState).toEqual({ tokens: 9, debt: 0, lastRefill: 5000 });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('should pay off debt before accumulating tokens', () => {
|
|
204
|
+
const state = { tokens: 0, debt: 5, lastRefill: 1000 };
|
|
205
|
+
const result = policy.evaluate(state, 1800, 1);
|
|
206
|
+
|
|
207
|
+
expect(result.decision.kind).toBe('allow');
|
|
208
|
+
expect(result.nextState).toEqual({ tokens: 2, debt: 0, lastRefill: 1800 });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should partially pay off debt if elapsed time is short', () => {
|
|
212
|
+
const state = { tokens: 0, debt: 10, lastRefill: 1000 };
|
|
213
|
+
const result = policy.evaluate(state, 1400, 2, true);
|
|
214
|
+
|
|
215
|
+
expect(result.decision).toEqual({ kind: 'delay', runAt: 2200 });
|
|
216
|
+
expect(result.nextState).toEqual({ tokens: 0, debt: 8, lastRefill: 1400 });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('should handle fractional refillRate precisely for allow path', () => {
|
|
220
|
+
const fractionalPolicy = new TokenBucketPolicy(10, 2.5);
|
|
221
|
+
const state = { tokens: 1, debt: 0, lastRefill: 1000 };
|
|
222
|
+
const result = fractionalPolicy.evaluate(state, 1400, 1);
|
|
223
|
+
|
|
224
|
+
expect(result.decision).toEqual({ kind: 'allow' });
|
|
225
|
+
expect(result.nextState).toEqual({ tokens: 1, debt: 0, lastRefill: 1400 });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should round up retryAt with fractional refillRate', () => {
|
|
229
|
+
const fractionalPolicy = new TokenBucketPolicy(10, 2.5);
|
|
230
|
+
const state = { tokens: 0, debt: 0, lastRefill: 1000 };
|
|
231
|
+
const result = fractionalPolicy.evaluate(state, 1000, 1);
|
|
232
|
+
|
|
233
|
+
expect(result.decision).toEqual({ kind: 'deny', retryAt: 1400 });
|
|
234
|
+
expect(result.nextState).toEqual(state);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('should round up runAt with fractional refillRate when reserving', () => {
|
|
238
|
+
const fractionalPolicy = new TokenBucketPolicy(10, 2.5);
|
|
239
|
+
const state = { tokens: 0, debt: 0, lastRefill: 1000 };
|
|
240
|
+
const result = fractionalPolicy.evaluate(state, 1000, 1, true);
|
|
241
|
+
|
|
242
|
+
expect(result.decision).toEqual({ kind: 'delay', runAt: 1400 });
|
|
243
|
+
expect(result.nextState).toEqual({ tokens: 0, debt: 1, lastRefill: 1000 });
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe('Revert', () => {
|
|
249
|
+
const policy = new TokenBucketPolicy(10, 10);
|
|
250
|
+
|
|
251
|
+
it('should add reverted cost back to tokens when there is no debt', () => {
|
|
252
|
+
const state = { tokens: 5, debt: 0, lastRefill: 1000 };
|
|
253
|
+
const result = policy.revert(state, 3, 1500);
|
|
254
|
+
|
|
255
|
+
expect(result).toEqual({ tokens: 10, debt: 0, lastRefill: 1500 });
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('should not exceed capacity when reverting tokens', () => {
|
|
259
|
+
const state = { tokens: 8, debt: 0, lastRefill: 1000 };
|
|
260
|
+
const result = policy.revert(state, 5, 1000);
|
|
261
|
+
|
|
262
|
+
expect(result).toEqual({ tokens: 10, debt: 0, lastRefill: 1000 });
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('should return exact cost to tokens when capacity allows', () => {
|
|
266
|
+
const state = { tokens: 3, debt: 0, lastRefill: 1000 };
|
|
267
|
+
const result = policy.revert(state, 3, 1000);
|
|
268
|
+
|
|
269
|
+
expect(result).toEqual({ tokens: 6, debt: 0, lastRefill: 1000 });
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('should decrease debt when cost <= debt (no spill to tokens)', () => {
|
|
273
|
+
const state = { tokens: 0, debt: 5, lastRefill: 1000 };
|
|
274
|
+
const result = policy.revert(state, 3, 1000);
|
|
275
|
+
|
|
276
|
+
expect(result).toEqual({ tokens: 0, debt: 2, lastRefill: 1000 });
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('should zero out debt and spill remainder to tokens', () => {
|
|
280
|
+
const state = { tokens: 0, debt: 2, lastRefill: 1000 };
|
|
281
|
+
const result = policy.revert(state, 5, 1000);
|
|
282
|
+
|
|
283
|
+
expect(result).toEqual({ tokens: 3, debt: 0, lastRefill: 1000 });
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('should zero out debt exactly when cost === debt', () => {
|
|
287
|
+
const state = { tokens: 0, debt: 4, lastRefill: 1000 };
|
|
288
|
+
const result = policy.revert(state, 4, 1000);
|
|
289
|
+
|
|
290
|
+
expect(result).toEqual({ tokens: 0, debt: 0, lastRefill: 1000 });
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should sync state before reverting: elapsed time reduces debt first', () => {
|
|
294
|
+
const state = { tokens: 0, debt: 8, lastRefill: 1000 };
|
|
295
|
+
const result = policy.revert(state, 2, 1500);
|
|
296
|
+
|
|
297
|
+
expect(result).toEqual({ tokens: 0, debt: 1, lastRefill: 1500 });
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('should sync state before reverting: elapsed time clears debt and fills tokens', () => {
|
|
301
|
+
const state = { tokens: 0, debt: 3, lastRefill: 1000 };
|
|
302
|
+
const result = policy.revert(state, 1, 1500);
|
|
303
|
+
|
|
304
|
+
expect(result).toEqual({ tokens: 3, debt: 0, lastRefill: 1500 });
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('should update lastRefill to now after sync', () => {
|
|
308
|
+
const state = { tokens: 0, debt: 5, lastRefill: 1000 };
|
|
309
|
+
const result = policy.revert(state, 1, 2000);
|
|
310
|
+
|
|
311
|
+
expect(result.lastRefill).toBe(2000);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('should handle cost=1 (minimum) correctly', () => {
|
|
315
|
+
const state = { tokens: 0, debt: 1, lastRefill: 1000 };
|
|
316
|
+
const result = policy.revert(state, 1, 1000);
|
|
317
|
+
|
|
318
|
+
expect(result).toEqual({ tokens: 0, debt: 0, lastRefill: 1000 });
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('should handle revert when tokens are already at capacity (no debt)', () => {
|
|
322
|
+
const state = { tokens: 10, debt: 0, lastRefill: 1000 };
|
|
323
|
+
const result = policy.revert(state, 5, 1000);
|
|
324
|
+
|
|
325
|
+
expect(result).toEqual({ tokens: 10, debt: 0, lastRefill: 1000 });
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('should handle revert with lastRefill=0 (initial state)', () => {
|
|
329
|
+
const state = { tokens: 0, debt: 0, lastRefill: 0 };
|
|
330
|
+
const result = policy.revert(state, 3, 5000);
|
|
331
|
+
|
|
332
|
+
expect(result).toEqual({ tokens: 10, debt: 0, lastRefill: 5000 });
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('should handle revert when now === lastRefill (no time elapsed)', () => {
|
|
336
|
+
const state = { tokens: 4, debt: 3, lastRefill: 2000 };
|
|
337
|
+
const result = policy.revert(state, 2, 2000);
|
|
338
|
+
|
|
339
|
+
expect(result).toEqual({ tokens: 4, debt: 1, lastRefill: 2000 });
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('should handle revert when elapsed time fully covers debt and fills tokens to capacity', () => {
|
|
343
|
+
const state = { tokens: 0, debt: 3, lastRefill: 1000 };
|
|
344
|
+
const result = policy.revert(state, 5, 3000);
|
|
345
|
+
|
|
346
|
+
expect(result).toEqual({ tokens: 10, debt: 0, lastRefill: 3000 });
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('should handle full capacity revert on spill with partial debt', () => {
|
|
350
|
+
const state = { tokens: 7, debt: 2, lastRefill: 1000 };
|
|
351
|
+
const result = policy.revert(state, 5, 1000);
|
|
352
|
+
|
|
353
|
+
expect(result).toEqual({ tokens: 10, debt: 0, lastRefill: 1000 });
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('should never produce negative debt after revert', () => {
|
|
357
|
+
const state = { tokens: 0, debt: 2, lastRefill: 1000 };
|
|
358
|
+
const result = policy.revert(state, 10, 1000);
|
|
359
|
+
|
|
360
|
+
expect(result.debt).toBe(0);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('should never exceed capacity after revert even with large spill', () => {
|
|
364
|
+
const state = { tokens: 9, debt: 1, lastRefill: 1000 };
|
|
365
|
+
const result = policy.revert(state, 10, 1000);
|
|
366
|
+
|
|
367
|
+
expect(result.tokens).toBe(10);
|
|
368
|
+
expect(result.debt).toBe(0);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ExecutionTickets } from '../../../src/runtime/execution-tickets.js';
|
|
3
|
+
|
|
4
|
+
describe('ExecutionTickets', () => {
|
|
5
|
+
describe('State', () => {
|
|
6
|
+
it('should start empty', () => {
|
|
7
|
+
const tickets = new ExecutionTickets();
|
|
8
|
+
|
|
9
|
+
expect(tickets.size).toBe(0);
|
|
10
|
+
expect(tickets.isEmpty).toBe(true);
|
|
11
|
+
expect(tickets.peek()).toBeUndefined();
|
|
12
|
+
expect(tickets.consume()).toBeUndefined();
|
|
13
|
+
expect(tickets.dropLast()).toBeUndefined();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should update size and isEmpty after add and consume', () => {
|
|
17
|
+
const tickets = new ExecutionTickets();
|
|
18
|
+
|
|
19
|
+
tickets.add(10);
|
|
20
|
+
expect(tickets.isEmpty).toBe(false);
|
|
21
|
+
expect(tickets.size).toBe(1);
|
|
22
|
+
|
|
23
|
+
const consumed = tickets.consume();
|
|
24
|
+
expect(consumed).toBe(10);
|
|
25
|
+
expect(tickets.size).toBe(0);
|
|
26
|
+
expect(tickets.isEmpty).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('FIFO behavior', () => {
|
|
31
|
+
it('should peek next without consuming', () => {
|
|
32
|
+
const tickets = new ExecutionTickets();
|
|
33
|
+
|
|
34
|
+
tickets.add(1);
|
|
35
|
+
tickets.add(2);
|
|
36
|
+
|
|
37
|
+
expect(tickets.peek()).toBe(1);
|
|
38
|
+
expect(tickets.size).toBe(2);
|
|
39
|
+
expect(tickets.peek()).toBe(1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should consume in FIFO order', () => {
|
|
43
|
+
const tickets = new ExecutionTickets();
|
|
44
|
+
|
|
45
|
+
tickets.add(5);
|
|
46
|
+
tickets.add(6);
|
|
47
|
+
tickets.add(7);
|
|
48
|
+
|
|
49
|
+
expect(tickets.consume()).toBe(5);
|
|
50
|
+
expect(tickets.consume()).toBe(6);
|
|
51
|
+
expect(tickets.consume()).toBe(7);
|
|
52
|
+
expect(tickets.consume()).toBeUndefined();
|
|
53
|
+
|
|
54
|
+
expect(tickets.size).toBe(0);
|
|
55
|
+
expect(tickets.isEmpty).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('LIFO behavior', () => {
|
|
60
|
+
it('should drop last in LIFO order from the end', () => {
|
|
61
|
+
const tickets = new ExecutionTickets();
|
|
62
|
+
|
|
63
|
+
tickets.add(1);
|
|
64
|
+
tickets.add(2);
|
|
65
|
+
tickets.add(3);
|
|
66
|
+
|
|
67
|
+
expect(tickets.dropLast()).toBe(3);
|
|
68
|
+
expect(tickets.size).toBe(2);
|
|
69
|
+
|
|
70
|
+
expect(tickets.peek()).toBe(1);
|
|
71
|
+
expect(tickets.consume()).toBe(1);
|
|
72
|
+
expect(tickets.consume()).toBe(2);
|
|
73
|
+
expect(tickets.consume()).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should return undefined when dropping from empty', () => {
|
|
77
|
+
const tickets = new ExecutionTickets();
|
|
78
|
+
|
|
79
|
+
expect(tickets.dropLast()).toBeUndefined();
|
|
80
|
+
expect(tickets.size).toBe(0);
|
|
81
|
+
expect(tickets.isEmpty).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('Clear', () => {
|
|
86
|
+
it('should remove all tickets and reset state', () => {
|
|
87
|
+
const tickets = new ExecutionTickets();
|
|
88
|
+
|
|
89
|
+
tickets.add(100);
|
|
90
|
+
tickets.add(200);
|
|
91
|
+
|
|
92
|
+
expect(tickets.size).toBe(2);
|
|
93
|
+
expect(tickets.isEmpty).toBe(false);
|
|
94
|
+
|
|
95
|
+
tickets.clear();
|
|
96
|
+
|
|
97
|
+
expect(tickets.size).toBe(0);
|
|
98
|
+
expect(tickets.isEmpty).toBe(true);
|
|
99
|
+
expect(tickets.peek()).toBeUndefined();
|
|
100
|
+
expect(tickets.consume()).toBeUndefined();
|
|
101
|
+
expect(tickets.dropLast()).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should be safe to clear multiple times', () => {
|
|
105
|
+
const tickets = new ExecutionTickets();
|
|
106
|
+
|
|
107
|
+
tickets.clear();
|
|
108
|
+
tickets.clear();
|
|
109
|
+
|
|
110
|
+
expect(tickets.size).toBe(0);
|
|
111
|
+
expect(tickets.isEmpty).toBe(true);
|
|
112
|
+
|
|
113
|
+
tickets.add(1);
|
|
114
|
+
expect(tickets.size).toBe(1);
|
|
115
|
+
|
|
116
|
+
tickets.clear();
|
|
117
|
+
expect(tickets.size).toBe(0);
|
|
118
|
+
expect(tickets.isEmpty).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|