feat: Add event-based automation triggers (#2537)
This commit is contained in:
committed by
dermotduffy
parent
b701366762
commit
a31816c168
@@ -86,6 +86,24 @@ describe('KeyedSubscriptionManager', () => {
|
||||
expect(manager.getRequestsForKey('a')).toEqual([reqA2]);
|
||||
});
|
||||
|
||||
it('should roll back the request when the underlying subscribeFn rejects', async () => {
|
||||
const manager = create();
|
||||
const subscribeFn = vi.fn().mockRejectedValue(new Error('ws-fail'));
|
||||
|
||||
const req = { key: 'a', callback: vi.fn() };
|
||||
await expect(manager.subscribe(req, subscribeFn)).rejects.toThrow('ws-fail');
|
||||
|
||||
// The failed subscriber must not be left dispatching against a
|
||||
// never-established connection.
|
||||
expect(manager.getRequestsForKey('a')).toEqual([]);
|
||||
|
||||
// A subsequent successful subscribe should re-attempt the underlying call.
|
||||
const successFn = vi.fn().mockResolvedValue(vi.fn());
|
||||
await manager.subscribe(req, successFn);
|
||||
expect(successFn).toBeCalledTimes(1);
|
||||
expect(manager.getRequestsForKey('a')).toEqual([req]);
|
||||
});
|
||||
|
||||
it('should treat unsubscribe of an unknown request as a no-op', async () => {
|
||||
const manager = create();
|
||||
const unsub = vi.fn();
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ExponentialBackoff } from '../../src/utils/exponential-backoff';
|
||||
|
||||
describe('ExponentialBackoff', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should start at attempts=0', () => {
|
||||
const backoff = new ExponentialBackoff({ baseSeconds: 1, maxSeconds: 60 });
|
||||
expect(backoff.getAttempts()).toBe(0);
|
||||
});
|
||||
|
||||
it('should compute exponential delays with the configured base', () => {
|
||||
// Pin jitter to 1.0 so we can read the raw exponential values.
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
const backoff = new ExponentialBackoff({
|
||||
baseSeconds: 1,
|
||||
maxSeconds: 60,
|
||||
jitterMin: 1,
|
||||
jitterMax: 1,
|
||||
});
|
||||
|
||||
expect(backoff.next()).toBe(1);
|
||||
expect(backoff.next()).toBe(2);
|
||||
expect(backoff.next()).toBe(4);
|
||||
expect(backoff.next()).toBe(8);
|
||||
expect(backoff.next()).toBe(16);
|
||||
});
|
||||
|
||||
it('should cap delays at maxSeconds', () => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
const backoff = new ExponentialBackoff({
|
||||
baseSeconds: 1,
|
||||
maxSeconds: 10,
|
||||
jitterMin: 1,
|
||||
jitterMax: 1,
|
||||
});
|
||||
|
||||
backoff.next();
|
||||
backoff.next();
|
||||
backoff.next();
|
||||
backoff.next();
|
||||
|
||||
expect(backoff.next()).toBe(10);
|
||||
expect(backoff.next()).toBe(10);
|
||||
});
|
||||
|
||||
it('should apply jitter within the configured range', () => {
|
||||
// Math.random() returns 0; jitter = jitterMin.
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0);
|
||||
const backoff = new ExponentialBackoff({
|
||||
baseSeconds: 10,
|
||||
maxSeconds: 100,
|
||||
jitterMin: 0.5,
|
||||
jitterMax: 1.0,
|
||||
});
|
||||
|
||||
expect(backoff.next()).toBe(5);
|
||||
});
|
||||
|
||||
it('should increment the attempt counter on each next()', () => {
|
||||
const backoff = new ExponentialBackoff({ baseSeconds: 1, maxSeconds: 60 });
|
||||
|
||||
expect(backoff.getAttempts()).toBe(0);
|
||||
backoff.next();
|
||||
expect(backoff.getAttempts()).toBe(1);
|
||||
backoff.next();
|
||||
expect(backoff.getAttempts()).toBe(2);
|
||||
});
|
||||
|
||||
it('should reset the attempt counter and start over', () => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
const backoff = new ExponentialBackoff({
|
||||
baseSeconds: 1,
|
||||
maxSeconds: 60,
|
||||
jitterMin: 1,
|
||||
jitterMax: 1,
|
||||
});
|
||||
|
||||
backoff.next();
|
||||
backoff.next();
|
||||
backoff.next();
|
||||
expect(backoff.getAttempts()).toBe(3);
|
||||
|
||||
backoff.reset();
|
||||
expect(backoff.getAttempts()).toBe(0);
|
||||
expect(backoff.next()).toBe(1);
|
||||
});
|
||||
|
||||
it('should default jitter to [0.5, 1.0] when not provided', () => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0);
|
||||
const backoff = new ExponentialBackoff({ baseSeconds: 4, maxSeconds: 100 });
|
||||
|
||||
// jitter = 0.5 + 0 * (1.0 - 0.5) = 0.5; delay = 4 * 0.5 = 2.
|
||||
expect(backoff.next()).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -71,4 +71,36 @@ describe('Initializer', () => {
|
||||
|
||||
expect(initializer.isInitialized('foo')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should discard an initialization that was uninitialized while it was running', async () => {
|
||||
const initializer = new Initializer();
|
||||
|
||||
let finishInitializer: () => void = () => undefined;
|
||||
const initializing = initializer.initializeIfNecessary(
|
||||
'foo',
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishInitializer = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
// A uninitialize lands while the initializer is still running.
|
||||
initializer.uninitialize('foo');
|
||||
|
||||
finishInitializer();
|
||||
await initializing;
|
||||
|
||||
expect(initializer.isInitialized('foo')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should initialize again after being uninitialized', async () => {
|
||||
const initializer = new Initializer();
|
||||
|
||||
await initializer.initializeIfNecessary('foo');
|
||||
initializer.uninitialize('foo');
|
||||
expect(initializer.isInitialized('foo')).toBeFalsy();
|
||||
|
||||
await initializer.initializeIfNecessary('foo');
|
||||
expect(initializer.isInitialized('foo')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { RetryTimer } from '../../src/utils/retry-timer';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('RetryTimer', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should schedule a callback after the current backoff delay', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 });
|
||||
const cb = vi.fn();
|
||||
|
||||
timer.schedule(cb);
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(cb).not.toBeCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(cb).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should advance the counter on schedule by default', () => {
|
||||
const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 });
|
||||
timer.schedule(() => {});
|
||||
expect(timer.getAttempts()).toBe(1);
|
||||
timer.schedule(() => {});
|
||||
expect(timer.getAttempts()).toBe(2);
|
||||
});
|
||||
|
||||
it('should not advance the counter when schedule is called with advance: false', () => {
|
||||
const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 });
|
||||
timer.schedule(() => {}, { advance: false });
|
||||
expect(timer.getAttempts()).toBe(0);
|
||||
timer.schedule(() => {}, { advance: false });
|
||||
expect(timer.getAttempts()).toBe(0);
|
||||
});
|
||||
|
||||
it('should advance the counter via advance()', () => {
|
||||
const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 });
|
||||
timer.advance();
|
||||
expect(timer.getAttempts()).toBe(1);
|
||||
timer.advance();
|
||||
expect(timer.getAttempts()).toBe(2);
|
||||
});
|
||||
|
||||
it('should use a longer delay after advance()', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 });
|
||||
const cb = vi.fn();
|
||||
|
||||
timer.advance();
|
||||
timer.schedule(cb);
|
||||
|
||||
// Counter is 1, delay should be base * 2^1 = 2 seconds.
|
||||
vi.advanceTimersByTime(1999);
|
||||
expect(cb).not.toBeCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(cb).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should cancel a pending callback', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 });
|
||||
const cb = vi.fn();
|
||||
|
||||
timer.schedule(cb);
|
||||
timer.cancel();
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(cb).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should reset both the timer and the counter', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 });
|
||||
const cb = vi.fn();
|
||||
|
||||
timer.advance();
|
||||
timer.advance();
|
||||
timer.schedule(cb, { advance: false });
|
||||
expect(timer.getAttempts()).toBe(2);
|
||||
expect(timer.isRunning()).toBe(true);
|
||||
|
||||
timer.reset();
|
||||
expect(timer.getAttempts()).toBe(0);
|
||||
expect(timer.isRunning()).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(cb).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should report running state', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 });
|
||||
|
||||
expect(timer.isRunning()).toBe(false);
|
||||
timer.schedule(() => {});
|
||||
expect(timer.isRunning()).toBe(true);
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(timer.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it('should advance the counter when schedule is called with advance: true', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 });
|
||||
|
||||
timer.schedule(() => {}, { advance: true });
|
||||
expect(timer.getAttempts()).toBe(1);
|
||||
timer.schedule(() => {}, { advance: true });
|
||||
expect(timer.getAttempts()).toBe(2);
|
||||
});
|
||||
|
||||
it('should produce a fixed delay when configured with base=max and jitter=1', () => {
|
||||
// The "static delay" idiom: callers wanting a non-growing delay configure
|
||||
// the backoff to flatten out. No special mode in the class.
|
||||
vi.useFakeTimers();
|
||||
const timer = new RetryTimer({
|
||||
baseSeconds: 30,
|
||||
maxSeconds: 30,
|
||||
jitterMin: 1,
|
||||
jitterMax: 1,
|
||||
});
|
||||
const cb = vi.fn();
|
||||
|
||||
timer.advance();
|
||||
timer.advance();
|
||||
timer.schedule(cb);
|
||||
vi.advanceTimersByTime(29_999);
|
||||
expect(cb).not.toBeCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(cb).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should accept a plain number as shorthand for a fixed delay', () => {
|
||||
vi.useFakeTimers();
|
||||
const timer = new RetryTimer(30);
|
||||
const cb = vi.fn();
|
||||
|
||||
timer.advance();
|
||||
timer.schedule(cb);
|
||||
vi.advanceTimersByTime(29_999);
|
||||
expect(cb).not.toBeCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(cb).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('setOptions', () => {
|
||||
it('should apply the new backoff config to the next schedule', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
const timer = new RetryTimer({
|
||||
baseSeconds: 1,
|
||||
maxSeconds: 60,
|
||||
jitterMin: 1,
|
||||
jitterMax: 1,
|
||||
});
|
||||
const cb = vi.fn();
|
||||
|
||||
timer.setOptions({
|
||||
baseSeconds: 10,
|
||||
maxSeconds: 10,
|
||||
jitterMin: 1,
|
||||
jitterMax: 1,
|
||||
});
|
||||
timer.schedule(cb);
|
||||
vi.advanceTimersByTime(9_999);
|
||||
expect(cb).not.toBeCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(cb).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should preserve the attempt counter and any pending callback', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
const timer = new RetryTimer({
|
||||
baseSeconds: 1,
|
||||
maxSeconds: 60,
|
||||
jitterMin: 1,
|
||||
jitterMax: 1,
|
||||
});
|
||||
const cb = vi.fn();
|
||||
|
||||
timer.advance();
|
||||
timer.advance();
|
||||
timer.schedule(cb, { advance: false });
|
||||
expect(timer.getAttempts()).toBe(2);
|
||||
expect(timer.isRunning()).toBe(true);
|
||||
|
||||
// Idempotent setOptions doesn't touch counter or pending timer.
|
||||
timer.setOptions({
|
||||
baseSeconds: 1,
|
||||
maxSeconds: 60,
|
||||
jitterMin: 1,
|
||||
jitterMax: 1,
|
||||
});
|
||||
expect(timer.getAttempts()).toBe(2);
|
||||
expect(timer.isRunning()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user