perf,feat(events),ops: third optimization pass
Overview cold start: - InstanceResourceService persists every live fetch into status_snapshots (category 'resources') and serves a snapshot younger than 2 minutes before calling upstream, so the first fleet overview after a restart costs zero device requests; instance deletion cleans up via FK cascade Bounded retention for the four fastest-growing tables (15-minute sweep): - connection_logs: one row per probe per beat had no automatic cleanup - audit_events: new pruneBefore (90d) - operation_preparations: every prepare/retry attempt inserted a row, terminal rows now expire after 7 days - notification_queue: terminal rows pruned after 30 days SSE events now cover instance lifecycle: - create/update emit instance envelopes from the routes; the two-phase delete emits a job envelope on every terminal transition plus an instance envelope when the node actually disappears, so fleet and instance views invalidate in real time Linux ops: - 'install.sh unit' writes systemd user units for API and gateway with Restart=on-failure, 0600 secret injection, and MULTI_SIMADMIN_SYSTEMD_UNIT wired so console self-update restarts via systemctl -- closing the self-update loop on Linux
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { EventEnvelope } from '../../application/events/event-journal.js';
|
||||
import { buildApp } from '../../app.js';
|
||||
import {
|
||||
InstanceSessionStore,
|
||||
@@ -78,13 +79,36 @@ const fixture = () => {
|
||||
idFactory: () => `operation-${++id}`,
|
||||
tokenFactory: () => Buffer.alloc(32, 11).toString('base64url'),
|
||||
});
|
||||
const events: Array<Record<string, unknown>> = [];
|
||||
const onEvent = (envelope: EventEnvelope): void => {
|
||||
events.push(envelope as unknown as Record<string, unknown>);
|
||||
};
|
||||
const app = buildApp({
|
||||
registerRoutes: (scope) =>
|
||||
registerInstanceRoutes(scope, { instances, connections, login, deletion }),
|
||||
registerInstanceRoutes(scope, { instances, connections, login, deletion, onEvent }),
|
||||
});
|
||||
return { app, instances };
|
||||
return { app, instances, events };
|
||||
};
|
||||
|
||||
describe('instance route SSE events', () => {
|
||||
it('emits an instance envelope when a node is created', async () => {
|
||||
const { app, events } = fixture();
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/instances',
|
||||
payload: {
|
||||
name: 'Alpha',
|
||||
origin: 'http://192.168.1.10:8080',
|
||||
password: { action: 'set', password: 'never-return-this' },
|
||||
},
|
||||
});
|
||||
expect(created.statusCode).toBe(201);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({ kind: 'instance', requestId: expect.any(String) });
|
||||
expect(String(events[0]?.instanceId)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('instance HTTP routes', () => {
|
||||
it('creates, lists, gets, updates and deletes redacted instance resources', async () => {
|
||||
const { app } = fixture();
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
DeleteInstanceOperationError,
|
||||
} from '../../application/operations/delete-instance-operation.js';
|
||||
import type { InstanceResourceService } from '../../application/resources/instance-resource-service.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { EventEnvelope } from '../../application/events/event-journal.js';
|
||||
import {
|
||||
MessageServiceError,
|
||||
type InstanceMessageService,
|
||||
@@ -33,6 +35,8 @@ export interface InstanceRoutesOptions {
|
||||
readonly resources?: InstanceResourceService;
|
||||
readonly messages?: InstanceMessageService;
|
||||
readonly registerDeletionPreparationRoute?: boolean;
|
||||
/** Receives instance lifecycle envelopes so the SSE journal can invalidate fleet views. */
|
||||
readonly onEvent?: (envelope: EventEnvelope) => void;
|
||||
}
|
||||
const problem = (
|
||||
request: FastifyRequest,
|
||||
@@ -56,6 +60,25 @@ const problem = (
|
||||
detail,
|
||||
requestId: request.id,
|
||||
});
|
||||
const emitInstanceEvent = (
|
||||
options: { readonly onEvent?: (envelope: EventEnvelope) => void },
|
||||
instanceId: string,
|
||||
requestId: string,
|
||||
): void => {
|
||||
if (!options.onEvent) return;
|
||||
try {
|
||||
options.onEvent({
|
||||
kind: 'instance',
|
||||
id: randomUUID(),
|
||||
occurredAt: new Date().toISOString(),
|
||||
requestId,
|
||||
instanceId,
|
||||
});
|
||||
} catch {
|
||||
// A journal failure must never fail the mutation itself.
|
||||
}
|
||||
};
|
||||
|
||||
const domainProblem = (request: FastifyRequest, error: InstanceServiceError) => {
|
||||
const status = instanceErrorStatus[error.code];
|
||||
return problem(
|
||||
@@ -344,6 +367,7 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const created = await options.instances.create(bodyInput(request.body));
|
||||
emitInstanceEvent(options, created.id, request.id);
|
||||
return reply.header('ETag', etag(created)).code(201).send(created);
|
||||
} catch (error) {
|
||||
if (error instanceof InstanceServiceError)
|
||||
@@ -434,6 +458,7 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
|
||||
revision(request),
|
||||
bodyPatch(request.body),
|
||||
);
|
||||
emitInstanceEvent(options, (request.params as { instanceId: string }).instanceId, request.id);
|
||||
reply.header('ETag', etag(result));
|
||||
return result;
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user