fix(instances): release the hub binding before forgetting a node

Deletion now follows the published contract: prepare returns the one-time
token and the console spends it on DELETE /api/v1/instances/:id with
If-Match, where the generic execute endpoint had been rejecting it as an
operation the safe transport cannot replay.

An online device is told to release its own binding first. A refusal keeps
the record and reports UNBIND_FAILED on the job item so the console can say
why the node is still there; a node that never answers a heartbeat is
forgotten locally.
This commit is contained in:
chick
2026-09-05 19:52:27 +08:00
parent 5016586922
commit 8b7df8cb65
11 changed files with 691 additions and 45 deletions
@@ -6,6 +6,8 @@ import { InstanceService, InstanceServiceError } from '../instances/instance-ser
export const DELETE_INSTANCE_PARAMETER_SCHEMA_ID = 'deleteInstance.parameters.v1' as const;
const OPERATION_ID = 'deleteInstance';
const ACTOR = 'loopback-control-plane';
/** Job item code for "the device still holds its central binding", surfaced to the console. */
export const BINDING_REFUSAL_CODE = 'UNBIND_FAILED' as const;
const TTL_MS = 5 * 60 * 1000;
const PARAMETERS_DIGEST = createHash('sha256')
.update(JSON.stringify({ parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] }))
@@ -53,9 +55,23 @@ interface ExecuteInput {
readonly actor: string;
readonly requestId: string;
}
/**
* Answer to "release the central binding on this device before we forget it".
* `skipped` means the node never answered a heartbeat, so there is nobody to notify and the
* console deletes its own record; the device re-binds if it ever comes back. `failed` keeps the
* record, because a device that refused or timed out must not be silently orphaned.
*/
export type BindingRelease =
| { readonly status: 'released' }
| { readonly status: 'skipped' }
| { readonly status: 'failed' };
interface Options {
readonly db: Database.Database;
readonly instances: InstanceService;
/** Optional so a control plane without a device transport can still delete. */
readonly releaseBinding?: (instanceId: string, requestId: string) => Promise<BindingRelease>;
readonly now?: () => Date;
readonly idFactory?: () => string;
readonly tokenFactory?: () => string;
@@ -73,6 +89,7 @@ const validRevision = (value: unknown): value is number =>
export class DeleteInstanceOperation {
private readonly db: Database.Database;
private readonly instances: InstanceService;
private readonly releaseBinding: Options['releaseBinding'];
private readonly clock: () => Date;
private readonly id: () => string;
private readonly token: () => string;
@@ -80,6 +97,7 @@ export class DeleteInstanceOperation {
constructor(options: Options) {
this.db = options.db;
this.instances = options.instances;
this.releaseBinding = options.releaseBinding;
this.clock = options.now ?? (() => new Date());
this.id = options.idFactory ?? randomUUID;
this.token = options.tokenFactory ?? (() => randomBytes(32).toString('base64url'));
@@ -294,6 +312,14 @@ export class DeleteInstanceOperation {
'Confirmation could not be accepted',
);
// An online device is told to release its central binding first, and a refusal keeps the
// record so the operator can retry instead of losing sight of a device that is still
// reporting to a hub. Only a node that cannot be reached at all is forgotten locally.
const release = await this.releaseBindingPort(input.instanceId, input.requestId);
if (release?.status === 'failed') {
this.closeAsFailed(ids, input.requestId, BINDING_REFUSAL_CODE);
return this.job(ids.job);
}
try {
await this.instances.delete(input.instanceId, input.revision, { allowJobHistory: true });
const finished = this.clock().toISOString();
@@ -339,6 +365,53 @@ export class DeleteInstanceOperation {
return this.job(ids.job);
}
/** A transport that throws while the node was reachable counts as a refusal, never a pass. */
private async releaseBindingPort(
instanceId: string,
requestId: string,
): Promise<BindingRelease | undefined> {
if (!this.releaseBinding) return undefined;
try {
return await this.releaseBinding(instanceId, requestId);
} catch {
return { status: 'failed' };
}
}
/** Closes the job without touching the instance row, so the device stays listed and retryable. */
private closeAsFailed(
ids: { job: string; item: string; attempt: string },
requestId: string,
resultCode: string,
): void {
const finished = this.clock().toISOString();
this.db.transaction(() => {
this.db
.prepare(
`UPDATE job_items
SET status='failed',result_code=?,error_json=?,finished_at=?,updated_at=?
WHERE id=? AND status='running'`,
)
.run(
resultCode,
JSON.stringify({ status: 502, code: resultCode, requestId }),
finished,
finished,
ids.item,
);
this.db
.prepare(
"UPDATE job_attempts SET status='failed',finished_at=? WHERE id=? AND status='running'",
)
.run(finished, ids.attempt);
this.db
.prepare(
"UPDATE jobs SET status='failed',finished_at=?,updated_at=? WHERE id=? AND status='running'",
)
.run(finished, finished, ids.job);
})();
}
reconcileInterruptedJobs(): number {
const finished = this.clock().toISOString();
return this.db.transaction(() => {
@@ -369,7 +442,7 @@ export class DeleteInstanceOperation {
private job(id: string): Job {
const row = this.db
.prepare(
'SELECT operation_id,status,root_job_id,retry_of_job_id,created_at FROM jobs WHERE id=?',
'SELECT operation_id,status,root_job_id,retry_of_job_id,created_at,request_id FROM jobs WHERE id=?',
)
.get(id) as {
operation_id: string;
@@ -377,13 +450,17 @@ export class DeleteInstanceOperation {
root_job_id: string;
retry_of_job_id: string | null;
created_at: string;
request_id: string;
};
const items = this.db
.prepare('SELECT id,instance_id,status FROM job_items WHERE job_id=? ORDER BY created_at,id')
.prepare(
'SELECT id,instance_id,status,result_code FROM job_items WHERE job_id=? ORDER BY created_at,id',
)
.all(id) as Array<{
id: string;
instance_id: string;
status: 'succeeded' | 'failed' | 'unknown-result';
result_code: string | null;
}>;
const attempts = this.db
.prepare(
@@ -401,7 +478,25 @@ export class DeleteInstanceOperation {
status: row.status,
...(row.retry_of_job_id ? { retryOfJobId: row.retry_of_job_id } : {}),
rootJobId: row.root_job_id,
items: items.map((item) => ({ id: item.id, targetId: item.instance_id, state: item.status })),
items: items.map((item) => ({
id: item.id,
targetId: item.instance_id,
state: item.status,
// The console reads this code straight out of the execute response to explain why the
// node it just confirmed is still in the list.
...(item.result_code === BINDING_REFUSAL_CODE
? {
error: {
type: 'about:blank',
title: 'Job item failed',
status: 502,
detail: 'The job item did not complete successfully.',
code: BINDING_REFUSAL_CODE,
requestId: row.request_id,
},
}
: {}),
})),
attempts: attempts.map((attempt) => ({
id: attempt.id,
state: attempt.status,