feat: Add event-based automation triggers (#2537)

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:13 -07:00
committed by dermotduffy
parent b701366762
commit a31816c168
109 changed files with 4607 additions and 1623 deletions
@@ -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);
}
}
+85
View File
@@ -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;
}
}
+20 -3
View File
@@ -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 {
+98
View File
@@ -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();
}
}