feat: Add event-based automation triggers (#2537)
This commit is contained in:
committed by
dermotduffy
parent
b701366762
commit
a31816c168
@@ -1,7 +1,14 @@
|
||||
import PQueue from 'p-queue';
|
||||
|
||||
type UnsubscribeFn = () => Promise<void>;
|
||||
type SubscribeFn = () => Promise<UnsubscribeFn>;
|
||||
type UnsubscribeCallback = () => Promise<void>;
|
||||
type SubscribeCallback = () => Promise<UnsubscribeCallback>;
|
||||
|
||||
/**
|
||||
* Extracts the key from a request. Used by `KeyedSubscriptionManager` and any
|
||||
* higher-level wrapper that shares its request-to-key mapping (e.g. the HASS
|
||||
* connection subscription manager).
|
||||
*/
|
||||
export type GetKeyCallback<R, K> = (request: R) => K;
|
||||
|
||||
/**
|
||||
* Manages subscriptions keyed by `K`: the first subscriber for a key invokes
|
||||
@@ -15,27 +22,36 @@ type SubscribeFn = () => Promise<UnsubscribeFn>;
|
||||
*/
|
||||
export class KeyedSubscriptionManager<K, R> {
|
||||
private _requests: R[] = [];
|
||||
private _unsubscribers = new Map<K, UnsubscribeFn>();
|
||||
private _unsubscribers = new Map<K, UnsubscribeCallback>();
|
||||
private _queues = new Map<K, PQueue>();
|
||||
private _getKeyFn: (request: R) => K;
|
||||
private _getKeyCallback: GetKeyCallback<R, K>;
|
||||
|
||||
constructor(getKeyFn: (request: R) => K) {
|
||||
this._getKeyFn = getKeyFn;
|
||||
constructor(getKeyCallback: GetKeyCallback<R, K>) {
|
||||
this._getKeyCallback = getKeyCallback;
|
||||
}
|
||||
|
||||
public async subscribe(request: R, subscribeFn: SubscribeFn): Promise<void> {
|
||||
const key = this._getKeyFn(request);
|
||||
public async subscribe(
|
||||
request: R,
|
||||
subscribeCallback: SubscribeCallback,
|
||||
): Promise<void> {
|
||||
const key = this._getKeyCallback(request);
|
||||
await this._queueFor(key).add(async () => {
|
||||
this._requests.push(request);
|
||||
if (!this._unsubscribers.has(key)) {
|
||||
const unsubscribe = await subscribeFn();
|
||||
this._unsubscribers.set(key, unsubscribe);
|
||||
try {
|
||||
this._unsubscribers.set(key, await subscribeCallback());
|
||||
} catch (e) {
|
||||
// Roll back the orphan request so it doesn't sit in `_requests`
|
||||
// dispatching against a connection that was never established.
|
||||
this._requests = this._requests.filter((r) => r !== request);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async unsubscribe(request: R): Promise<void> {
|
||||
const key = this._getKeyFn(request);
|
||||
const key = this._getKeyCallback(request);
|
||||
await this._queueFor(key).add(async () => {
|
||||
this._requests = this._requests.filter((r) => r !== request);
|
||||
if (!this._hasSubscribers(key)) {
|
||||
@@ -47,7 +63,7 @@ export class KeyedSubscriptionManager<K, R> {
|
||||
}
|
||||
|
||||
public getRequestsForKey(key: K): readonly R[] {
|
||||
return this._requests.filter((r) => this._getKeyFn(r) === key);
|
||||
return this._requests.filter((r) => this._getKeyCallback(r) === key);
|
||||
}
|
||||
|
||||
private _queueFor(key: K): PQueue {
|
||||
@@ -60,6 +76,6 @@ export class KeyedSubscriptionManager<K, R> {
|
||||
}
|
||||
|
||||
private _hasSubscribers(key: K): boolean {
|
||||
return this._requests.some((r) => this._getKeyFn(r) === key);
|
||||
return this._requests.some((r) => this._getKeyCallback(r) === key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Default jitter range applied to each computed delay: a random multiplier in
|
||||
// [50%, 100%] of the pre-jitter value, avoiding thundering-herd retries when
|
||||
// multiple instances back off in lockstep.
|
||||
const DEFAULT_JITTER_MIN = 0.5;
|
||||
const DEFAULT_JITTER_MAX = 1.0;
|
||||
|
||||
export interface ExponentialBackoffOptions {
|
||||
// Delay for the first retry (attempt 1). Subsequent attempts double the delay
|
||||
// until `maxSeconds` is reached.
|
||||
baseSeconds: number;
|
||||
|
||||
// Upper bound on the delay after exponential growth. The delay never exceeds
|
||||
// this regardless of attempt count.
|
||||
maxSeconds: number;
|
||||
|
||||
// Random multiplier applied to each computed delay. Defaults to
|
||||
// [DEFAULT_JITTER_MIN, DEFAULT_JITTER_MAX].
|
||||
jitterMin?: number;
|
||||
jitterMax?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful exponential-backoff delay calculator. Holds an attempt counter,
|
||||
* returns the next delay on each `next()` call, and can be `reset()` after a
|
||||
* successful operation.
|
||||
*
|
||||
* Example:
|
||||
* ```ts
|
||||
* const backoff = new ExponentialBackoff({ baseSeconds: 1, maxSeconds: 300 });
|
||||
* // 1st failure -> backoff.next() returns ~1s (jittered).
|
||||
* // 2nd failure -> ~2s.
|
||||
* // 3rd failure -> ~4s. ... -> 300s ceiling.
|
||||
* // After success: backoff.reset().
|
||||
* ```
|
||||
*/
|
||||
export class ExponentialBackoff {
|
||||
private _baseSeconds = 0;
|
||||
private _maxSeconds = 0;
|
||||
private _jitterMin = DEFAULT_JITTER_MIN;
|
||||
private _jitterMax = DEFAULT_JITTER_MAX;
|
||||
private _attempts = 0;
|
||||
|
||||
constructor(options: ExponentialBackoffOptions) {
|
||||
this.setOptions(options);
|
||||
}
|
||||
|
||||
public setOptions(options: ExponentialBackoffOptions): void {
|
||||
this._baseSeconds = options.baseSeconds;
|
||||
this._maxSeconds = options.maxSeconds;
|
||||
this._jitterMin = options.jitterMin ?? DEFAULT_JITTER_MIN;
|
||||
this._jitterMax = options.jitterMax ?? DEFAULT_JITTER_MAX;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next delay in seconds and increments the attempt counter. The
|
||||
* pre-jitter delay is `baseSeconds * 2^(attempts before increment)`, capped
|
||||
* at `maxSeconds`. Jitter is a random multiplier in [jitterMin, jitterMax].
|
||||
*/
|
||||
public next(): number {
|
||||
const delay = this.peek();
|
||||
this._attempts += 1;
|
||||
return delay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns what the next `next()` call would return WITHOUT incrementing the
|
||||
* counter. Useful for "re-arm at the same backoff level" cases (a scheduled
|
||||
* retry deferred for an unrelated reason; don't compound the backoff). Note
|
||||
* jitter is re-rolled each call, so two consecutive `peek()`s may return
|
||||
* slightly different values for the same attempt count.
|
||||
*/
|
||||
public peek(): number {
|
||||
const exp = Math.min(this._maxSeconds, this._baseSeconds * 2 ** this._attempts);
|
||||
const jitter = this._jitterMin + Math.random() * (this._jitterMax - this._jitterMin);
|
||||
return exp * jitter;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this._attempts = 0;
|
||||
}
|
||||
|
||||
public getAttempts(): number {
|
||||
return this._attempts;
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,22 @@ import { allPromises } from '../basic';
|
||||
type InitializationCallback = () => Promise<void>;
|
||||
|
||||
/**
|
||||
* Manages initialization state & calling initializers. There is no guarantee
|
||||
* something will not be initialized twice unless there are concurrency controls
|
||||
* applied to the usage of this class.
|
||||
* Manages initialization state and runs initializers.
|
||||
*
|
||||
* Safe when `uninitialize()` is called while an (async) initializer is still
|
||||
* running: that initializer's result is discarded instead of marking the aspect
|
||||
* initialized again. (Two initializers running for the same aspect at once is
|
||||
* still the caller's job to avoid.)
|
||||
*/
|
||||
export class Initializer {
|
||||
private _initialized: Set<string> = new Set();
|
||||
|
||||
// Bumped on every `uninitialize()`. An `initializeIfNecessary()` captures the
|
||||
// generation before awaiting its initializer and, on completion, only records
|
||||
// success if the generation is unchanged -- i.e. no `uninitialize()` for that
|
||||
// aspect landed while it was running.
|
||||
private _generation: Map<string, number> = new Map();
|
||||
|
||||
public async initializeMultipleIfNecessary(
|
||||
aspects: Record<string, InitializationCallback>,
|
||||
): Promise<void> {
|
||||
@@ -26,14 +35,22 @@ export class Initializer {
|
||||
if (this._initialized.has(aspect)) {
|
||||
return;
|
||||
}
|
||||
const generation = this._generation.get(aspect) ?? 0;
|
||||
if (initializer) {
|
||||
await initializer();
|
||||
}
|
||||
// If `uninitialize()` ran while we were awaiting, a newer attempt has taken
|
||||
// over -- throw this result away (don't mark it initialized) so a stale
|
||||
// result can't leave the card stuck, and a fresh attempt runs next time.
|
||||
if ((this._generation.get(aspect) ?? 0) !== generation) {
|
||||
return;
|
||||
}
|
||||
this._initialized.add(aspect);
|
||||
}
|
||||
|
||||
public uninitialize(aspect: string): void {
|
||||
this._initialized.delete(aspect);
|
||||
this._generation.set(aspect, (this._generation.get(aspect) ?? 0) + 1);
|
||||
}
|
||||
|
||||
public isInitialized(aspect: string): boolean {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { ExponentialBackoff, ExponentialBackoffOptions } from './exponential-backoff';
|
||||
import { Timer } from './timer';
|
||||
|
||||
// Expand a plain `number` (fixed delay in seconds) into the equivalent
|
||||
// `ExponentialBackoffOptions`: base = max so growth flattens, jitter pinned to
|
||||
// 1.0 so the delay is exactly N every time.
|
||||
const convertToBackoffOptions = (
|
||||
options: ExponentialBackoffOptions | number,
|
||||
): ExponentialBackoffOptions => {
|
||||
if (typeof options === 'number') {
|
||||
return {
|
||||
baseSeconds: options,
|
||||
maxSeconds: options,
|
||||
jitterMin: 1,
|
||||
jitterMax: 1,
|
||||
};
|
||||
}
|
||||
return options;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pairs an `ExponentialBackoff` with a `Timer` for retry scheduling. Each
|
||||
* `schedule(...)` call fires after the current backoff delay; `advance()`
|
||||
* bumps the counter for next time.
|
||||
*
|
||||
* Constructor and `setOptions` accept either `ExponentialBackoffOptions`
|
||||
* (growth + jitter) or a plain `number` (fixed delay in seconds, no growth,
|
||||
* no jitter). Internally a number expands to `{ baseSeconds: N, maxSeconds:
|
||||
* N, jitterMin: 1, jitterMax: 1 }` so all methods behave uniformly -- callers
|
||||
* never branch.
|
||||
*
|
||||
* Typical patterns:
|
||||
* - "Failure happened, retry later, count this failure": `schedule(cb)`.
|
||||
* - "Retry deferred for an unrelated reason; don't compound":
|
||||
* `schedule(cb, { advance: false })` (re-arms at the current delay).
|
||||
* - "Attempt happened, count it separately from scheduling": `advance()`.
|
||||
* - "Operation succeeded": `reset()`.
|
||||
* - "Caller going away": `cancel()`.
|
||||
*/
|
||||
export class RetryTimer {
|
||||
private readonly _backoff: ExponentialBackoff;
|
||||
private readonly _timer = new Timer();
|
||||
|
||||
constructor(options: ExponentialBackoffOptions | number) {
|
||||
this._backoff = new ExponentialBackoff(convertToBackoffOptions(options));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the backoff configuration. Cheap and idempotent: doesn't cancel
|
||||
* pending callbacks or reset the attempt counter, so it's safe to call on
|
||||
* every scheduling pass regardless of whether the options actually changed.
|
||||
* Call `reset()` separately if zeroing the counter is desired (e.g. on a
|
||||
* semantically distinct mode switch).
|
||||
*/
|
||||
public setOptions(options: ExponentialBackoffOptions | number): void {
|
||||
this._backoff.setOptions(convertToBackoffOptions(options));
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule `callback` to fire after the current delay, then advance the
|
||||
* attempt counter so the next schedule uses a longer delay. Pass
|
||||
* `{ advance: false }` to re-arm at the current delay without counting it
|
||||
* (e.g. a retry that may be gated and re-scheduled). Any pending callback
|
||||
* is canceled before scheduling.
|
||||
*/
|
||||
public schedule(callback: () => void, options?: { advance?: boolean }): void {
|
||||
this._timer.start(this._backoff.peek(), callback);
|
||||
if (options?.advance !== false) {
|
||||
this._backoff.next();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bump the attempt counter without scheduling. For flows where the schedule
|
||||
* call and the "attempt happened, count it" event are separate (e.g. the
|
||||
* scheduled callback may or may not actually retry, depending on a gate).
|
||||
*/
|
||||
public advance(): void {
|
||||
this._backoff.next();
|
||||
}
|
||||
|
||||
public cancel(): void {
|
||||
this._timer.stop();
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this._timer.stop();
|
||||
this._backoff.reset();
|
||||
}
|
||||
|
||||
public isRunning(): boolean {
|
||||
return this._timer.isRunning();
|
||||
}
|
||||
|
||||
public getAttempts(): number {
|
||||
return this._backoff.getAttempts();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user