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:
chick
2026-09-07 02:43:11 +08:00
parent c1be714ba4
commit 70e598208a
11 changed files with 362 additions and 6 deletions
@@ -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;
}),