mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-26 20:42:25 +03:00
feat(api): a host can say it is alive, and is told how often to
Second half of G1's "neslet registers against api.nestri.io and heartbeats".
Registration already worked; there was no heartbeat endpoint at all — grep for
it across apps/api and packages/core returned nothing, and neslet's own
main.rs says the same from its side.
POST /machine/heartbeat, machine credentials only. Two decisions worth stating
because neither is obvious from the diff:
**It returns the interval.** The auth middleware already touches lastSeen on
every authenticated machine request, so an endpoint that only did that would
add an endpoint and no capability. What a host cannot know on its own is how
often the control plane wants to hear from it, so the response carries the
cadence. A fleet whose interval can only change by shipping a new agent is a
fleet whose interval never changes.
**It takes no body.** neslet has a HostSummary ready to send, and week 2 owns
box state reporting. Accepting fields nothing acts on yet would mean a wire
shape we would have to keep, chosen before the thing that consumes it exists.
Online-ness is derived from lastSeen rather than stored: a host that stops
beating goes offline through the passage of time, which is the one mechanism
that cannot itself fail. Three missed beats, not one — a single missed beat is
a lost packet, and treating that as offline would make placement flap.
Also: the machine actor's teamID stops being optional. It was `...(teamId ? {}
: {})` in the middleware, a branch for a state that cannot exist now that
machine.team_id is notNull.
134 tests, 0 fail.
This commit is contained in:
@@ -171,15 +171,60 @@ export namespace Machine {
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* How often a host should say it is alive, in seconds.
|
||||
*
|
||||
* Returned to the host on every heartbeat rather than compiled into it: the
|
||||
* cadence is the control plane's business, and a fleet whose interval can
|
||||
* only be changed by shipping a new agent is a fleet whose interval never
|
||||
* changes. Thirty seconds is a placeholder — it is short enough that a dead
|
||||
* host is noticed within a session's setup, and long enough to be free.
|
||||
*/
|
||||
export const HEARTBEAT_SECONDS = 30;
|
||||
|
||||
/**
|
||||
* A host is considered offline once it has missed this many heartbeats.
|
||||
*
|
||||
* Three rather than one, because a single missed beat is a lost packet and
|
||||
* calling that "offline" would make placement flap.
|
||||
*/
|
||||
export const OFFLINE_AFTER_MISSED = 3;
|
||||
|
||||
/**
|
||||
* Record that a host is alive, and say when that was.
|
||||
*
|
||||
* Returns the stored timestamp rather than void so a caller can hand it
|
||||
* straight back to the host — which is what lets a heartbeat be one round
|
||||
* trip instead of a write followed by a read.
|
||||
*/
|
||||
export const touchLastSeen = fn(Info.shape.id, async (id) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.update(MachineTable)
|
||||
.set({ lastSeen: sql`now()` })
|
||||
.where(eq(MachineTable.id, id));
|
||||
.where(eq(MachineTable.id, id))
|
||||
.returning({ lastSeen: MachineTable.lastSeen })
|
||||
.then((rows) => rows.at(0)?.lastSeen ?? null);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether a host has beaten recently enough to place work on.
|
||||
*
|
||||
* Derived from `lastSeen` rather than stored as a column, so there is no
|
||||
* state to go stale when nothing is running to clear it — a host that stops
|
||||
* beating becomes offline by the passage of time, which is the one mechanism
|
||||
* that cannot itself fail.
|
||||
*/
|
||||
export function isOnline(lastSeen: Date | string | null): boolean {
|
||||
if (!lastSeen) {
|
||||
return false;
|
||||
}
|
||||
const at = lastSeen instanceof Date ? lastSeen : new Date(lastSeen);
|
||||
const age = (Date.now() - at.getTime()) / 1000;
|
||||
return age <= HEARTBEAT_SECONDS * OFFLINE_AFTER_MISSED;
|
||||
}
|
||||
|
||||
export const fromID = fn(Info.shape.id, async (id) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
|
||||
Reference in New Issue
Block a user