Back to Quality Engineering Toolkit

capstone-quality-engineering-toolkit/src/asyncUtilities.ts

import { ToolkitError } from "./errors.js";
import type { Clock, EvidenceSnapshot, FailureKind, PollingResult, RetryResult } from "./models.js";

export class DeterministicClock implements Clock {
  private elapsedMs = 0;
  readonly sleepRequests: number[] = [];

  now(): number {
    return this.elapsedMs;
  }

  async sleep(milliseconds: number): Promise<void> {
    if (!Number.isFinite(milliseconds) || milliseconds < 0) {
      throw new Error("A deterministic clock cannot sleep for a negative or non-finite duration.");
    }

    this.sleepRequests.push(milliseconds);
    this.elapsedMs += milliseconds;
  }
}

export interface PollingOptions<T> {
  readonly operationName: string;
  operation(): Promise<T>;
  isComplete(value: T): boolean;
  describe(value: T): string;
  readonly timeoutMs: number;
  readonly intervalMs: number;
  readonly clock: Clock;
}

function assertPollingOptions(timeoutMs: number, intervalMs: number): void {
  if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
    throw new ToolkitError("invalid-input", "timeoutMs must be a non-negative finite number.", {
      operation: "poll",
      expected: "timeoutMs: non-negative finite number",
    });
  }

  if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
    throw new ToolkitError("invalid-input", "intervalMs must be a positive finite number.", {
      operation: "poll",
      expected: "intervalMs: positive finite number",
    });
  }
}

export async function pollUntil<T>(options: PollingOptions<T>): Promise<PollingResult<T>> {
  assertPollingOptions(options.timeoutMs, options.intervalMs);

  const startedAtMs = options.clock.now();
  let attempts = 0;
  let lastObservedState = "No result was observed.";

  while (true) {
    attempts += 1;
    const value = await options.operation();
    const elapsedMs = options.clock.now() - startedAtMs;
    lastObservedState = options.describe(value);

    if (options.isComplete(value)) {
      return { value, attempts, elapsedMs };
    }

    if (elapsedMs >= options.timeoutMs) {
      throw new ToolkitError(
        "timeout",
        `Timed out waiting for ${options.operationName} after ${elapsedMs} ms.`,
        {
          operation: options.operationName,
          attempt: attempts,
          elapsedMs,
          expected: "completion condition",
          observed: lastObservedState,
        },
      );
    }

    await options.clock.sleep(Math.min(options.intervalMs, options.timeoutMs - elapsedMs));
  }
}

export interface RetryOptions<T> {
  readonly operationName: string;
  operation(): Promise<T>;
  shouldRetry(error: unknown): boolean;
  readonly maxAttempts: number;
  readonly delayMs: number;
  readonly clock: Clock;
}

function assertRetryOptions(maxAttempts: number, delayMs: number): void {
  if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
    throw new ToolkitError("invalid-input", "maxAttempts must be an integer of at least 1.", {
      operation: "retry",
      expected: "maxAttempts: integer >= 1",
    });
  }

  if (!Number.isFinite(delayMs) || delayMs < 0) {
    throw new ToolkitError("invalid-input", "delayMs must be a non-negative finite number.", {
      operation: "retry",
      expected: "delayMs: non-negative finite number",
    });
  }
}

function terminalKind(error: unknown): FailureKind {
  return error instanceof ToolkitError ? error.kind : "unexpected-result";
}

export async function retryBounded<T>(options: RetryOptions<T>): Promise<RetryResult<T>> {
  assertRetryOptions(options.maxAttempts, options.delayMs);

  for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) {
    try {
      return { value: await options.operation(), attempts: attempt };
    } catch (error: unknown) {
      const retryable = options.shouldRetry(error);
      if (!retryable || attempt === options.maxAttempts) {
        const exhausted = retryable && attempt === options.maxAttempts;
        throw new ToolkitError(
          terminalKind(error),
          exhausted
            ? `${options.operationName} exhausted its bounded attempts at attempt ${attempt}.`
            : `${options.operationName} stopped because the failure is not retryable at attempt ${attempt}.`,
          { operation: options.operationName, attempt, retryExhausted: exhausted },
          error,
        );
      }

      await options.clock.sleep(options.delayMs);
    }
  }

  throw new Error("Unreachable retry state.");
}

export type ReadinessState = "pending" | "ready";

export function createScriptedReadiness(states: readonly ReadinessState[]): () => Promise<ReadinessState> {
  if (states.length === 0) {
    throw new Error("A scripted readiness operation needs at least one state.");
  }

  let index = 0;
  return async () => {
    const state = states[index] ?? states[states.length - 1];
    index += 1;
    if (state === undefined) {
      throw new Error("Unreachable scripted readiness state.");
    }

    return state;
  };
}

/**
 * Simulates an idempotent read of fixture evidence: retrying it does not create
 * or modify data. A real write must establish idempotency before using retry.
 */
export function createTransientSnapshotReader(snapshotId = "fixture-snapshot-001"): () => Promise<EvidenceSnapshot> {
  let invocationCount = 0;
  return async () => {
    invocationCount += 1;
    if (invocationCount === 1) {
      throw new ToolkitError(
        "dependency-failure",
        "Simulated evidence dependency is temporarily unavailable.",
        { operation: "read-evidence-snapshot", attempt: invocationCount },
      );
    }

    return { snapshotId };
  };
}