feat(api): add fleet snapshots and durable event stream
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { UpstreamError } from '../../infrastructure/transport/upstream-error.js';
|
||||
|
||||
export interface HealthTransportResponse {
|
||||
readonly status: number;
|
||||
readonly body: string;
|
||||
}
|
||||
|
||||
export type HealthSnapshotErrorCode =
|
||||
| 'AUTH_REQUIRED'
|
||||
| 'UNSUPPORTED'
|
||||
| 'UPSTREAM_DEGRADED'
|
||||
| 'UPSTREAM_TIMEOUT'
|
||||
| 'UPSTREAM_UNAVAILABLE'
|
||||
| 'RESPONSE_TOO_LARGE'
|
||||
| 'INVALID_RESPONSE';
|
||||
|
||||
export interface HealthSnapshotInstance {
|
||||
readonly id: string;
|
||||
readonly origin: string;
|
||||
}
|
||||
|
||||
export interface HealthEnvelope {
|
||||
readonly status?: string;
|
||||
readonly version?: string;
|
||||
readonly platform?: string;
|
||||
}
|
||||
|
||||
export type HealthSnapshotState = 'fresh' | 'stale' | 'unknown';
|
||||
|
||||
export interface HealthSnapshot {
|
||||
readonly instanceId: string;
|
||||
readonly category: 'health';
|
||||
readonly state: HealthSnapshotState;
|
||||
readonly payload: HealthEnvelope;
|
||||
readonly observedAt: string;
|
||||
readonly expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface StatusSnapshotServiceOptions {
|
||||
readonly db: Database.Database;
|
||||
readonly instances: { get(instanceId: string): Promise<HealthSnapshotInstance | undefined> };
|
||||
readonly transport: { get(url: string): Promise<HealthTransportResponse> };
|
||||
readonly ttlMs: number;
|
||||
readonly maxStaleMs: number;
|
||||
readonly now?: () => Date;
|
||||
readonly idFactory?: () => string;
|
||||
}
|
||||
|
||||
export class StatusSnapshotError extends Error {
|
||||
constructor(readonly code: 'INSTANCE_NOT_FOUND') {
|
||||
super(code);
|
||||
this.name = 'StatusSnapshotError';
|
||||
}
|
||||
}
|
||||
|
||||
interface SnapshotRow {
|
||||
payload_json: string;
|
||||
observed_at: string;
|
||||
}
|
||||
|
||||
type SnapshotErrorCode = HealthSnapshotErrorCode;
|
||||
|
||||
interface PersistedHealthEnvelope {
|
||||
readonly schemaVersion: 1;
|
||||
readonly data: HealthEnvelope;
|
||||
readonly errorCode: SnapshotErrorCode | null;
|
||||
readonly httpStatus: number | null;
|
||||
readonly fetchedAt: string;
|
||||
readonly durationMs: number;
|
||||
readonly freshness: HealthSnapshotState;
|
||||
readonly supported: boolean | null;
|
||||
readonly dataFetchedAt: string | null;
|
||||
}
|
||||
|
||||
interface ClassifiedSnapshot {
|
||||
readonly snapshot: HealthSnapshot;
|
||||
readonly persisted: PersistedHealthEnvelope;
|
||||
}
|
||||
|
||||
const MAX_FIELD_LENGTH = 128;
|
||||
const SAFE_FIELD = /^[^\x00-\x1f\x7f]*$/;
|
||||
|
||||
function parseEnvelope(body: string): HealthEnvelope | undefined {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(body);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined;
|
||||
const source = parsed as Record<string, unknown>;
|
||||
const envelope: { status?: string; version?: string; platform?: string } = {};
|
||||
for (const key of ['status', 'version', 'platform'] as const) {
|
||||
const value = source[key];
|
||||
if (value === undefined) continue;
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
value.length > MAX_FIELD_LENGTH ||
|
||||
!SAFE_FIELD.test(value)
|
||||
)
|
||||
return undefined;
|
||||
envelope[key] = value;
|
||||
}
|
||||
return Object.freeze(envelope);
|
||||
}
|
||||
|
||||
function safePreviousPayload(payloadJson: string): HealthEnvelope | undefined {
|
||||
let candidate = payloadJson;
|
||||
try {
|
||||
const parsed = JSON.parse(payloadJson) as { data?: unknown };
|
||||
if (parsed && typeof parsed === 'object' && 'data' in parsed)
|
||||
candidate = JSON.stringify(parsed.data);
|
||||
} catch {
|
||||
// parseEnvelope below handles malformed legacy payloads.
|
||||
}
|
||||
const envelope = parseEnvelope(candidate);
|
||||
return envelope && Object.keys(envelope).length > 0 ? envelope : undefined;
|
||||
}
|
||||
|
||||
function previousDataFetchedAt(previous: SnapshotRow): string {
|
||||
try {
|
||||
const parsed = JSON.parse(previous.payload_json) as { dataFetchedAt?: unknown };
|
||||
if (
|
||||
typeof parsed.dataFetchedAt === 'string' &&
|
||||
Number.isFinite(Date.parse(parsed.dataFetchedAt))
|
||||
) {
|
||||
return parsed.dataFetchedAt;
|
||||
}
|
||||
} catch {
|
||||
// Legacy rows use observed_at as their data fetch time.
|
||||
}
|
||||
return previous.observed_at;
|
||||
}
|
||||
|
||||
function responseErrorCode(response: HealthTransportResponse | undefined): SnapshotErrorCode {
|
||||
if (
|
||||
!response ||
|
||||
!Number.isSafeInteger(response.status) ||
|
||||
response.status < 100 ||
|
||||
response.status > 599
|
||||
) {
|
||||
return 'INVALID_RESPONSE';
|
||||
}
|
||||
if (response.status === 401 || response.status === 403) return 'AUTH_REQUIRED';
|
||||
if (response.status === 404) return 'UNSUPPORTED';
|
||||
if (response.status >= 500) return 'UPSTREAM_DEGRADED';
|
||||
return 'INVALID_RESPONSE';
|
||||
}
|
||||
|
||||
function transportErrorCode(error: unknown): SnapshotErrorCode {
|
||||
if (!(error instanceof UpstreamError)) return 'UPSTREAM_UNAVAILABLE';
|
||||
if (error.code === 'UPSTREAM_TIMEOUT') return 'UPSTREAM_TIMEOUT';
|
||||
if (error.code === 'UPSTREAM_RESPONSE_TOO_LARGE') return 'RESPONSE_TOO_LARGE';
|
||||
return 'UPSTREAM_UNAVAILABLE';
|
||||
}
|
||||
|
||||
const healthUrl = (origin: string): string => `${origin.replace(/\/$/, '')}/api/health`;
|
||||
|
||||
export class StatusSnapshotService {
|
||||
readonly #db: Database.Database;
|
||||
readonly #instances: StatusSnapshotServiceOptions['instances'];
|
||||
readonly #transport: StatusSnapshotServiceOptions['transport'];
|
||||
readonly #ttlMs: number;
|
||||
readonly #maxStaleMs: number;
|
||||
readonly #now: () => Date;
|
||||
readonly #id: () => string;
|
||||
readonly #latestGeneration = new Map<string, number>();
|
||||
#nextGeneration = 0;
|
||||
|
||||
constructor(options: StatusSnapshotServiceOptions) {
|
||||
if (!Number.isFinite(options.ttlMs) || options.ttlMs <= 0) {
|
||||
throw new RangeError('ttlMs must be greater than zero');
|
||||
}
|
||||
if (!Number.isFinite(options.maxStaleMs) || options.maxStaleMs < options.ttlMs) {
|
||||
throw new RangeError('maxStaleMs must be at least ttlMs');
|
||||
}
|
||||
this.#db = options.db;
|
||||
this.#instances = options.instances;
|
||||
this.#transport = options.transport;
|
||||
this.#ttlMs = options.ttlMs;
|
||||
this.#maxStaleMs = options.maxStaleMs;
|
||||
this.#now = options.now ?? (() => new Date());
|
||||
this.#id = options.idFactory ?? randomUUID;
|
||||
}
|
||||
|
||||
async refreshHealth(instanceId: string): Promise<HealthSnapshot> {
|
||||
const instance = await this.#instances.get(instanceId);
|
||||
if (!instance) throw new StatusSnapshotError('INSTANCE_NOT_FOUND');
|
||||
|
||||
const generation = ++this.#nextGeneration;
|
||||
this.#latestGeneration.set(instanceId, generation);
|
||||
|
||||
const started = this.#now();
|
||||
let response: HealthTransportResponse | undefined;
|
||||
let transportError: unknown;
|
||||
try {
|
||||
response = await this.#transport.get(healthUrl(instance.origin));
|
||||
} catch (error) {
|
||||
transportError = error;
|
||||
}
|
||||
const completed = this.#now();
|
||||
const observedAt = completed.toISOString();
|
||||
const previous = this.#previous(instanceId);
|
||||
const classified = this.#classify(
|
||||
instanceId,
|
||||
response,
|
||||
transportError,
|
||||
previous,
|
||||
completed,
|
||||
observedAt,
|
||||
Math.max(0, Math.min(Number.MAX_SAFE_INTEGER, completed.getTime() - started.getTime())),
|
||||
);
|
||||
const { snapshot } = classified;
|
||||
|
||||
if (this.#latestGeneration.get(instanceId) === generation) {
|
||||
this.#persist(snapshot, classified.persisted);
|
||||
this.#latestGeneration.delete(instanceId);
|
||||
}
|
||||
return Object.freeze({ ...snapshot, payload: Object.freeze({ ...snapshot.payload }) });
|
||||
}
|
||||
|
||||
#previous(instanceId: string): SnapshotRow | undefined {
|
||||
return this.#db
|
||||
.prepare(
|
||||
"SELECT payload_json,observed_at FROM status_snapshots WHERE instance_id=? AND category='health'",
|
||||
)
|
||||
.get(instanceId) as SnapshotRow | undefined;
|
||||
}
|
||||
|
||||
#classify(
|
||||
instanceId: string,
|
||||
response: HealthTransportResponse | undefined,
|
||||
transportError: unknown,
|
||||
previous: SnapshotRow | undefined,
|
||||
completed: Date,
|
||||
observedAt: string,
|
||||
durationMs: number,
|
||||
): ClassifiedSnapshot {
|
||||
if (
|
||||
response &&
|
||||
Number.isSafeInteger(response.status) &&
|
||||
response.status >= 200 &&
|
||||
response.status < 300 &&
|
||||
typeof response.body === 'string'
|
||||
) {
|
||||
const payload = parseEnvelope(response.body);
|
||||
if (payload) {
|
||||
const snapshot: HealthSnapshot = {
|
||||
instanceId,
|
||||
category: 'health',
|
||||
state: 'fresh',
|
||||
payload,
|
||||
observedAt,
|
||||
expiresAt: new Date(completed.getTime() + this.#ttlMs).toISOString(),
|
||||
};
|
||||
return {
|
||||
snapshot,
|
||||
persisted: this.#envelope(snapshot, null, response.status, durationMs, true, observedAt),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (response?.status === 404) {
|
||||
const snapshot = this.#unknown(instanceId, observedAt);
|
||||
return {
|
||||
snapshot,
|
||||
persisted: this.#envelope(snapshot, 'UNSUPPORTED', 404, durationMs, false, null),
|
||||
};
|
||||
}
|
||||
|
||||
const errorCode: SnapshotErrorCode = transportError
|
||||
? transportErrorCode(transportError)
|
||||
: responseErrorCode(response);
|
||||
const httpStatus =
|
||||
response &&
|
||||
Number.isSafeInteger(response.status) &&
|
||||
response.status >= 100 &&
|
||||
response.status <= 599
|
||||
? response.status
|
||||
: null;
|
||||
const previousPayload = previous && safePreviousPayload(previous.payload_json);
|
||||
const previousTime = previous ? Date.parse(previousDataFetchedAt(previous)) : Number.NaN;
|
||||
if (
|
||||
previousPayload &&
|
||||
Number.isFinite(previousTime) &&
|
||||
completed.getTime() - previousTime <= this.#maxStaleMs
|
||||
) {
|
||||
const snapshot: HealthSnapshot = {
|
||||
instanceId,
|
||||
category: 'health',
|
||||
state: 'stale',
|
||||
payload: previousPayload,
|
||||
observedAt,
|
||||
expiresAt: new Date(previousTime + this.#maxStaleMs).toISOString(),
|
||||
};
|
||||
return {
|
||||
snapshot,
|
||||
persisted: this.#envelope(
|
||||
snapshot,
|
||||
errorCode,
|
||||
httpStatus,
|
||||
durationMs,
|
||||
httpStatus === null ? null : true,
|
||||
previousDataFetchedAt(previous),
|
||||
),
|
||||
};
|
||||
}
|
||||
const snapshot = this.#unknown(instanceId, observedAt);
|
||||
return {
|
||||
snapshot,
|
||||
persisted: this.#envelope(
|
||||
snapshot,
|
||||
errorCode,
|
||||
httpStatus,
|
||||
durationMs,
|
||||
httpStatus === null ? null : true,
|
||||
null,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
#unknown(instanceId: string, observedAt: string): HealthSnapshot {
|
||||
return {
|
||||
instanceId,
|
||||
category: 'health',
|
||||
state: 'unknown',
|
||||
payload: {},
|
||||
observedAt,
|
||||
expiresAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
#envelope(
|
||||
snapshot: HealthSnapshot,
|
||||
errorCode: SnapshotErrorCode | null,
|
||||
httpStatus: number | null,
|
||||
durationMs: number,
|
||||
supported: boolean | null,
|
||||
dataFetchedAt: string | null,
|
||||
): PersistedHealthEnvelope {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
data: snapshot.payload,
|
||||
errorCode,
|
||||
httpStatus,
|
||||
fetchedAt: snapshot.observedAt,
|
||||
durationMs,
|
||||
freshness: snapshot.state,
|
||||
supported,
|
||||
dataFetchedAt,
|
||||
};
|
||||
}
|
||||
|
||||
#persist(snapshot: HealthSnapshot, persisted: PersistedHealthEnvelope): void {
|
||||
this.#db
|
||||
.prepare(
|
||||
`INSERT INTO status_snapshots
|
||||
(id,instance_id,category,state,payload_json,observed_at,expires_at,created_at)
|
||||
SELECT ?,id,'health',?,?,?,?,? FROM instances WHERE id=?
|
||||
ON CONFLICT(instance_id,category) DO UPDATE SET
|
||||
state=excluded.state,
|
||||
payload_json=excluded.payload_json,
|
||||
observed_at=excluded.observed_at,
|
||||
expires_at=excluded.expires_at
|
||||
WHERE excluded.observed_at > status_snapshots.observed_at`,
|
||||
)
|
||||
.run(
|
||||
this.#id(),
|
||||
snapshot.state,
|
||||
JSON.stringify(persisted),
|
||||
snapshot.observedAt,
|
||||
snapshot.expiresAt,
|
||||
snapshot.observedAt,
|
||||
snapshot.instanceId,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user