mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 10:46:51 +00:00
fix(fleet-sync): make control-identity-mismatch sticky and surface in UI (#1117)
Treat 409 CONTROL_IDENTITY_MISMATCH from a replica as a non-retriable
failure instead of looping the same 409 through the 5-minute retry
service forever and silently writing identical failure rows.
Backend
- DatabaseService: add `sticky_error_code`, `sticky_error_expected`,
`sticky_error_got` columns to `fleet_sync_status` via an idempotent
migration. New methods setFleetSyncSticky, getFleetSyncStickyCode,
clearFleetSyncStickyForNode. recordFleetSyncSuccess clears the sticky
flag on a clean push. getFailedSyncTargets SQL adds
`AND sticky_error_code IS NULL` so the retry loop skips sticky rows.
- FleetSyncService.executePushToNode: short-circuits at the top when
sticky is set (covers event-driven pushResourceAsync calls). On a 409
with code CONTROL_IDENTITY_MISMATCH, records the failure once and
pins sticky with the expected/got fingerprints carried in the 409 body.
- routes/nodes.ts: new POST /api/nodes/:id/fleet-sync/reset-anchor.
Admin + paid + node:manage. Proxies POST /api/fleet/role/reanchor to
the peer with `{override:true}` using the stored Bearer node_proxy
token. On peer 200, clears every sticky row for the node so the next
push re-anchors and resumes replication. Distinct 502 / 504 responses
for peer-rejected / peer-unreachable so the UI can show a useful toast.
Frontend
- New lib/fleetSyncApi.ts + hooks/useFleetSyncStatus.ts. Polling hook
(30s visibilityInterval) skips fetch when !isPaid.
- NodeManager.tsx: destructive banner per affected node listing both
fingerprints, with `Reset anchor on peer` and `Remove node` buttons.
Hidden for community-tier users via empty hook data.
- FleetConfiguration.tsx (Fleet -> Status): read-only `Policy sync`
SummaryRow per remote node card. In sync / degraded / paused with
a tooltip; no action buttons (the action lives in NodeManager).
Tests
- fleet-sync-service.test.ts: 4 new cases for sticky-set on first
mismatch, short-circuit on subsequent pushes, null fingerprints,
and non-mismatch failures not setting sticky.
- database-fleet-sync-sticky.test.ts (new): 6 cases pinning the DB
contract incl. retry-loop SQL filter and migration idempotency.
- nodes-fleet-sync-reset-anchor.test.ts (new): 6 cases covering
happy path, peer 401 -> 502, peer unreachable -> 504, local-node
rejection, unknown node id, and community-tier 403.
Gate parity (Directive 30): the new POST .../reset-anchor enforces
requireAdmin + requirePaid + node:manage (matches the existing read at
GET /api/fleet/sync-status). UI banner + SummaryRow only render when the
hook returns data, which it only does for paid-tier authed users. No
existing tier-gate file moved; this is greenfield parity.
Auth audit: the peer's POST /api/fleet/role/reanchor route already uses
requireAdmin, which accepts the central's stored node_proxy Bearer
token because authMiddleware maps `scope === 'node_proxy'` to
`req.user = { username: 'node-proxy', role: 'admin', userId: 0 }`.
No widening required.
Backend tsc clean. Frontend tsc -b clean. 59 fleet-sync tests pass; full
backend suite green minus the pre-existing Windows-only file-lock flake
on filesystem-backup.test.ts that reproduces unchanged on main.
This commit is contained in:
@@ -555,6 +555,9 @@ export interface FleetSyncStatus {
|
||||
last_success_at: number | null;
|
||||
last_failure_at: number | null;
|
||||
last_error: string | null;
|
||||
sticky_error_code: string | null;
|
||||
sticky_error_expected: string | null;
|
||||
sticky_error_got: string | null;
|
||||
}
|
||||
|
||||
export interface CveSuppression {
|
||||
@@ -659,6 +662,7 @@ export class DatabaseService {
|
||||
this.migrateAddNodeCordonFields();
|
||||
this.migrateAddBlueprintPinnedNode();
|
||||
this.migrateAutoHealNodeId();
|
||||
this.migrateFleetSyncStickyError();
|
||||
|
||||
// Reset the cache once at end of constructor in case any migration
|
||||
// populated it via getGlobalSettings() and a subsequent migration
|
||||
@@ -1581,6 +1585,12 @@ export class DatabaseService {
|
||||
this.tryAddColumn('blueprints', 'pinned_node_id', 'INTEGER');
|
||||
}
|
||||
|
||||
private migrateFleetSyncStickyError(): void {
|
||||
this.tryAddColumn('fleet_sync_status', 'sticky_error_code', 'TEXT');
|
||||
this.tryAddColumn('fleet_sync_status', 'sticky_error_expected', 'TEXT');
|
||||
this.tryAddColumn('fleet_sync_status', 'sticky_error_got', 'TEXT');
|
||||
}
|
||||
|
||||
private migrateAutoHealNodeId(): void {
|
||||
const markerKey = 'migration_auto_heal_node_scope_v1';
|
||||
const markerDone = this.getGlobalSettings()[markerKey] === '1';
|
||||
@@ -3931,11 +3941,15 @@ export class DatabaseService {
|
||||
const now = Date.now();
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO fleet_sync_status (node_id, resource, last_success_at, last_failure_at, last_error)
|
||||
VALUES (?, ?, ?, NULL, NULL)
|
||||
`INSERT INTO fleet_sync_status (node_id, resource, last_success_at, last_failure_at, last_error,
|
||||
sticky_error_code, sticky_error_expected, sticky_error_got)
|
||||
VALUES (?, ?, ?, NULL, NULL, NULL, NULL, NULL)
|
||||
ON CONFLICT(node_id, resource) DO UPDATE SET
|
||||
last_success_at = excluded.last_success_at,
|
||||
last_error = NULL`,
|
||||
last_error = NULL,
|
||||
sticky_error_code = NULL,
|
||||
sticky_error_expected = NULL,
|
||||
sticky_error_got = NULL`,
|
||||
)
|
||||
.run(nodeId, resource, now);
|
||||
}
|
||||
@@ -3953,6 +3967,64 @@ export class DatabaseService {
|
||||
.run(nodeId, resource, now, error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a (node, resource) pair as having hit a non-retriable failure. The
|
||||
* retry service skips sticky rows and the push paths short-circuit before
|
||||
* any HTTP call. The first such failure still records `last_failure_at` +
|
||||
* `last_error` via `recordFleetSyncFailure`; the sticky write is additive.
|
||||
*
|
||||
* `expected` and `got` carry the fingerprints from a 409
|
||||
* CONTROL_IDENTITY_MISMATCH response so the UI can render
|
||||
* "anchored to <expected>, this central is <got>" without parsing the
|
||||
* error string.
|
||||
*/
|
||||
public setFleetSyncSticky(
|
||||
nodeId: number,
|
||||
resource: string,
|
||||
code: string,
|
||||
expected: string | null,
|
||||
got: string | null,
|
||||
): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO fleet_sync_status (node_id, resource, sticky_error_code,
|
||||
sticky_error_expected, sticky_error_got)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(node_id, resource) DO UPDATE SET
|
||||
sticky_error_code = excluded.sticky_error_code,
|
||||
sticky_error_expected = excluded.sticky_error_expected,
|
||||
sticky_error_got = excluded.sticky_error_got`,
|
||||
)
|
||||
.run(nodeId, resource, code, expected, got);
|
||||
}
|
||||
|
||||
public getFleetSyncStickyCode(nodeId: number, resource: string): string | null {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT sticky_error_code FROM fleet_sync_status
|
||||
WHERE node_id = ? AND resource = ?`,
|
||||
)
|
||||
.get(nodeId, resource) as { sticky_error_code: string | null } | undefined;
|
||||
return row?.sticky_error_code ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear every sticky-error row for one node id. Used by the
|
||||
* reset-anchor endpoint after the peer has acknowledged a reanchor; the
|
||||
* next push attempt re-tries normally.
|
||||
*/
|
||||
public clearFleetSyncStickyForNode(nodeId: number): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE fleet_sync_status
|
||||
SET sticky_error_code = NULL,
|
||||
sticky_error_expected = NULL,
|
||||
sticky_error_got = NULL
|
||||
WHERE node_id = ?`,
|
||||
)
|
||||
.run(nodeId);
|
||||
}
|
||||
|
||||
public getFailedSyncTargets(resource: string, maxAgeMs: number): FleetSyncStatus[] {
|
||||
const cutoff = Date.now() - maxAgeMs;
|
||||
return this.db
|
||||
@@ -3960,7 +4032,8 @@ export class DatabaseService {
|
||||
`SELECT * FROM fleet_sync_status
|
||||
WHERE resource = ?
|
||||
AND (last_failure_at IS NOT NULL AND last_failure_at > ?)
|
||||
AND (last_success_at IS NULL OR last_success_at < last_failure_at)`,
|
||||
AND (last_success_at IS NULL OR last_success_at < last_failure_at)
|
||||
AND sticky_error_code IS NULL`,
|
||||
)
|
||||
.all(resource, cutoff) as FleetSyncStatus[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user