fix(broker): DB stale sweeper reconciled with in-memory leases; flipped live presences get their socket closed
Addendum 5 follow-up. sweepStalePresences() flipped presence.disconnected_at purely on last_ping_at age, with no knowledge of the in-memory lease, and a flip never closed anything — so a session could be hidden from list_peers (DB-backed) while pushes still reached it (memory-backed) and its daemon was never told to reconnect: the half-visible ghost seen in prod at 2026-09-08 22:05Z. - lease.ts: liveLeasePresenceIds() (online + OPEN + pong within 75 s) and reconcileFlippedPresences() (flipped ids that still hold an ONLINE lease); STALE_PONG_THRESHOLD_MS becomes the single shared constant. - broker.ts: sweepStalePresences(keep) skips the live set and returns the ids it flipped; setStaleSweepReconciler() lets index.ts (owner of `connections`) feed the keep-set and react to flips without a circular import. - index.ts: registers the reconciler before startSweepers(); every flipped presence that still has an online lease is evicted via evictPresenceFully(), which closes the socket (4004) so the daemon reconnects and re-hellos. Offline (in-grace) leases are left to their grace timer on purpose — the 90 s cutoff can land inside the window. Tests: 4 pure cases in tests/lease.test.ts (keep-set selection incl. the 75 s edge, the ghost case, in-grace exemption, empty). Broker tsc clean; suite 79 passed, 6 files fail only for missing DATABASE_URL / bun broker (pre-existing, environment). Cambio: barrido DB de presencias reconciliado con las leases en memoria HARD RULES touched: ninguna (broker only, no schema) Blast radius: broker prod (all meshes) · revert: trivial (1 commit) Smoke: 🟡 tests puros + tsc; verificación en vivo post-deploy: last_ping_at avanza ≥10 min tras un reattach y ningún 'stale sweep flipped' en logs Veredicto: safe — deploy junto al resto de fix/daemon-restart-mesh-blackout-1.37.1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@ import {
|
|||||||
isNotNull,
|
isNotNull,
|
||||||
isNull,
|
isNull,
|
||||||
lt,
|
lt,
|
||||||
|
notInArray,
|
||||||
or,
|
or,
|
||||||
sql,
|
sql,
|
||||||
} from "drizzle-orm";
|
} from "drizzle-orm";
|
||||||
@@ -285,18 +286,43 @@ export async function refreshQueueDepth(): Promise<void> {
|
|||||||
/**
|
/**
|
||||||
* Sweep stale presences: mark as disconnected if last_ping_at is older
|
* Sweep stale presences: mark as disconnected if last_ping_at is older
|
||||||
* than 90s (3 missed pings at the 30s interval = dead session).
|
* than 90s (3 missed pings at the 30s interval = dead session).
|
||||||
|
*
|
||||||
|
* 2026-09-08 (Addendum 5 follow-up): the DB column is only a projection of
|
||||||
|
* the in-memory lease. `keep` = presence ids whose lease is online with an
|
||||||
|
* OPEN, recently-ponged socket — those are never flipped, whatever the
|
||||||
|
* column says (a lagging or failed heartbeat write must not hide a live
|
||||||
|
* session from `list_peers`). Returns the ids that WERE flipped so the
|
||||||
|
* caller can reconcile them against memory and close any socket that is
|
||||||
|
* still open (otherwise the client is never told and lives on as a ghost).
|
||||||
*/
|
*/
|
||||||
export async function sweepStalePresences(): Promise<void> {
|
export async function sweepStalePresences(keep: ReadonlySet<string> = new Set()): Promise<string[]> {
|
||||||
const cutoff = new Date(Date.now() - 90_000); // 3 missed pings
|
const cutoff = new Date(Date.now() - 90_000); // 3 missed pings
|
||||||
await db
|
const conds = [
|
||||||
.update(presence)
|
|
||||||
.set({ disconnectedAt: new Date() })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
isNull(presence.disconnectedAt),
|
isNull(presence.disconnectedAt),
|
||||||
lt(presence.lastPingAt, cutoff),
|
lt(presence.lastPingAt, cutoff),
|
||||||
),
|
];
|
||||||
);
|
if (keep.size > 0) conds.push(notInArray(presence.id, [...keep]));
|
||||||
|
const flipped = await db
|
||||||
|
.update(presence)
|
||||||
|
.set({ disconnectedAt: new Date() })
|
||||||
|
.where(and(...conds))
|
||||||
|
.returning({ id: presence.id });
|
||||||
|
return flipped.map((r) => r.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook the stale sweeper into the in-memory lease map (lives in index.ts,
|
||||||
|
* which owns `connections`; broker.ts must not import it). `keep()` is
|
||||||
|
* evaluated right before each sweep; `onFlipped` receives the ids the
|
||||||
|
* sweep disconnected, if any.
|
||||||
|
*/
|
||||||
|
export interface StaleSweepReconciler {
|
||||||
|
keep: () => ReadonlySet<string>;
|
||||||
|
onFlipped: (ids: string[]) => void;
|
||||||
|
}
|
||||||
|
let staleSweepReconciler: StaleSweepReconciler | null = null;
|
||||||
|
export function setStaleSweepReconciler(r: StaleSweepReconciler | null): void {
|
||||||
|
staleSweepReconciler = r;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2651,9 +2677,10 @@ export function startSweepers(): void {
|
|||||||
);
|
);
|
||||||
}, PENDING_SWEEP_INTERVAL_MS);
|
}, PENDING_SWEEP_INTERVAL_MS);
|
||||||
staleTimer = setInterval(() => {
|
staleTimer = setInterval(() => {
|
||||||
sweepStalePresences().catch((e) =>
|
const r = staleSweepReconciler;
|
||||||
console.error("[broker] stale presence sweep:", e),
|
sweepStalePresences(r ? r.keep() : undefined)
|
||||||
);
|
.then((flipped) => { if (r && flipped.length > 0) r.onFlipped(flipped); })
|
||||||
|
.catch((e) => console.error("[broker] stale presence sweep:", e));
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
claimSweepTimer = setInterval(() => {
|
claimSweepTimer = setInterval(() => {
|
||||||
sweepExpiredClaims()
|
sweepExpiredClaims()
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
handleHookSetStatus,
|
handleHookSetStatus,
|
||||||
heartbeat,
|
heartbeat,
|
||||||
restorePresence,
|
restorePresence,
|
||||||
|
setStaleSweepReconciler,
|
||||||
insertFileKeys,
|
insertFileKeys,
|
||||||
joinGroup,
|
joinGroup,
|
||||||
joinMesh,
|
joinMesh,
|
||||||
@@ -129,6 +130,9 @@ import {
|
|||||||
dedupTargets,
|
dedupTargets,
|
||||||
findZombieSockets,
|
findZombieSockets,
|
||||||
reattachLease,
|
reattachLease,
|
||||||
|
liveLeasePresenceIds,
|
||||||
|
reconcileFlippedPresences,
|
||||||
|
STALE_PONG_THRESHOLD_MS,
|
||||||
CLOSE_CODE_PRESENCE_EVICTED,
|
CLOSE_CODE_PRESENCE_EVICTED,
|
||||||
CLOSE_CODE_NO_PRESENCE,
|
CLOSE_CODE_NO_PRESENCE,
|
||||||
} from "./lease";
|
} from "./lease";
|
||||||
@@ -5742,7 +5746,8 @@ async function main(): Promise<void> {
|
|||||||
// ping interval) are considered half-dead — kernel hasn't yet RST'd
|
// ping interval) are considered half-dead — kernel hasn't yet RST'd
|
||||||
// the socket but no application traffic is flowing. Force-terminate
|
// the socket but no application traffic is flowing. Force-terminate
|
||||||
// them to fire the close handler and free the connection slot.
|
// them to fire the close handler and free the connection slot.
|
||||||
const STALE_PONG_THRESHOLD_MS = 75_000;
|
// STALE_PONG_THRESHOLD_MS (75 s) is shared with the DB stale-sweep
|
||||||
|
// reconciler and lives in lease.ts.
|
||||||
const pingInterval = setInterval(() => {
|
const pingInterval = setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
@@ -5806,6 +5811,25 @@ async function main(): Promise<void> {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
queueDepthTimer.unref();
|
queueDepthTimer.unref();
|
||||||
|
|
||||||
|
// 2026-09-08 (Addendum 5 follow-up): the DB stale sweeper must not flip
|
||||||
|
// a presence whose lease is alive in memory, and whatever it does flip
|
||||||
|
// that still holds an online lease must be evicted (socket closed) so
|
||||||
|
// the owning daemon reconnects instead of becoming a half-visible ghost.
|
||||||
|
setStaleSweepReconciler({
|
||||||
|
keep: () => liveLeasePresenceIds(connections, Date.now(), STALE_PONG_THRESHOLD_MS),
|
||||||
|
onFlipped: (ids) => {
|
||||||
|
const ghosts = reconcileFlippedPresences(ids, (pid) => connections.get(pid));
|
||||||
|
for (const pid of ghosts) {
|
||||||
|
const conn = connections.get(pid);
|
||||||
|
log.warn("stale sweep flipped a presence with a live lease — evicting so the client reconnects", {
|
||||||
|
presence_id: pid,
|
||||||
|
session_pubkey: conn?.sessionPubkey?.slice(0, 12) ?? "(member-WS)",
|
||||||
|
last_pong_ago_ms: conn ? Date.now() - conn.lastPongAt : -1,
|
||||||
|
});
|
||||||
|
void evictPresenceFully(pid);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
startSweepers();
|
startSweepers();
|
||||||
startDbHealth();
|
startDbHealth();
|
||||||
serviceManager.startHealthChecks();
|
serviceManager.startHealthChecks();
|
||||||
|
|||||||
@@ -177,3 +177,69 @@ export function reattachLease<WS>(
|
|||||||
conn.leaseUntil = 0;
|
conn.leaseUntil = 0;
|
||||||
return wasState;
|
return wasState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── DB stale-sweep reconciliation (2026-09-08, Addendum 5 follow-up) ──────
|
||||||
|
//
|
||||||
|
// `sweepStalePresences` (broker.ts) flips `presence.disconnected_at` purely
|
||||||
|
// on `last_ping_at` age. That column is bumped by `heartbeat()` on every
|
||||||
|
// pong, so a live lease normally never goes stale — but the DB write can
|
||||||
|
// lag or fail while the in-memory lease is perfectly healthy, and the
|
||||||
|
// reverse can happen too: the row is flipped while a socket is still OPEN
|
||||||
|
// and its owner (the daemon) is never told. Both halves are closed here:
|
||||||
|
// 1. `liveLeasePresenceIds` = the set the sweeper must NOT flip
|
||||||
|
// (online lease, OPEN socket, pong within the stale-pong window).
|
||||||
|
// 2. `reconcileFlippedPresences` = of the ids the sweeper DID flip, the
|
||||||
|
// ones that still hold an online in-memory lease. The caller evicts
|
||||||
|
// them (which closes the socket) so the client reconnects and
|
||||||
|
// re-hellos instead of living on as a half-visible ghost.
|
||||||
|
// Offline-leased entries are left to their grace timer on purpose: the
|
||||||
|
// sweep cutoff (90 s since last ping) can land inside the grace window,
|
||||||
|
// and evicting there would shorten the grace the lease model promises.
|
||||||
|
|
||||||
|
/** Broker-side stale-pong window: 2.5x the 30 s ping cadence. */
|
||||||
|
export const STALE_PONG_THRESHOLD_MS = 75_000;
|
||||||
|
|
||||||
|
export interface LivenessConnView<WS extends { readyState: number; OPEN: number }>
|
||||||
|
extends LeaseConnView<WS> {
|
||||||
|
lastPongAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Presence ids whose lease is online, whose socket is OPEN and whose last
|
||||||
|
* pong is within `stalePongMs`. The DB stale sweeper must skip these — the
|
||||||
|
* in-memory lease is the source of truth for liveness, the DB column is a
|
||||||
|
* projection of it.
|
||||||
|
*/
|
||||||
|
export function liveLeasePresenceIds<WS extends { readyState: number; OPEN: number }>(
|
||||||
|
conns: Iterable<[string, LivenessConnView<WS>]>,
|
||||||
|
now: number,
|
||||||
|
stalePongMs: number = STALE_PONG_THRESHOLD_MS,
|
||||||
|
): Set<string> {
|
||||||
|
const keep = new Set<string>();
|
||||||
|
for (const [pid, conn] of conns) {
|
||||||
|
if (conn.leaseState !== "online") continue;
|
||||||
|
if (conn.ws.readyState !== conn.ws.OPEN) continue;
|
||||||
|
if (now - conn.lastPongAt > stalePongMs) continue;
|
||||||
|
keep.add(pid);
|
||||||
|
}
|
||||||
|
return keep;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Of the presence ids the DB sweeper flipped to disconnected, return those
|
||||||
|
* that still have an ONLINE in-memory lease. Each one is a DB/memory
|
||||||
|
* split-brain: discovery (`list_peers`, DB-backed) already hides it while
|
||||||
|
* pushes (memory-backed) still reach it, and nothing tells the client.
|
||||||
|
* The caller must evict them (closing the socket) so the client reconnects.
|
||||||
|
*/
|
||||||
|
export function reconcileFlippedPresences<WS>(
|
||||||
|
flipped: Iterable<string>,
|
||||||
|
lookup: (presenceId: string) => LeaseConnView<WS> | undefined,
|
||||||
|
): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const pid of flipped) {
|
||||||
|
const conn = lookup(pid);
|
||||||
|
if (conn?.leaseState === "online") out.push(pid);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import {
|
|||||||
dedupTargets,
|
dedupTargets,
|
||||||
findZombieSockets,
|
findZombieSockets,
|
||||||
reattachLease,
|
reattachLease,
|
||||||
|
liveLeasePresenceIds,
|
||||||
|
reconcileFlippedPresences,
|
||||||
|
STALE_PONG_THRESHOLD_MS,
|
||||||
type DedupConnView,
|
type DedupConnView,
|
||||||
} from "../src/lease";
|
} from "../src/lease";
|
||||||
|
|
||||||
@@ -183,3 +186,42 @@ describe("reattachLease (RC-C ordering: swap before close)", () => {
|
|||||||
expect(conn.leaseState).toBe("online");
|
expect(conn.leaseState).toBe("online");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("DB stale-sweep reconciliation (Addendum 5 follow-up: sweeper vs in-memory lease)", () => {
|
||||||
|
const OPEN = 1, CLOSED = 3;
|
||||||
|
const sock = (readyState: number) => ({ readyState, OPEN });
|
||||||
|
const now = 1_000_000;
|
||||||
|
|
||||||
|
test("liveLeasePresenceIds keeps only online + OPEN + recently-ponged leases", () => {
|
||||||
|
const conns: Array<[string, { ws: { readyState: number; OPEN: number }; leaseState: "online" | "offline"; lastPongAt: number }]> = [
|
||||||
|
["alive", { ws: sock(OPEN), leaseState: "online", lastPongAt: now - 10_000 }],
|
||||||
|
["in-grace", { ws: sock(CLOSED), leaseState: "offline", lastPongAt: now - 10_000 }],
|
||||||
|
["half-dead", { ws: sock(OPEN), leaseState: "online", lastPongAt: now - STALE_PONG_THRESHOLD_MS - 1 }],
|
||||||
|
["closed-sock",{ ws: sock(CLOSED), leaseState: "online", lastPongAt: now - 1_000 }],
|
||||||
|
["edge", { ws: sock(OPEN), leaseState: "online", lastPongAt: now - STALE_PONG_THRESHOLD_MS }],
|
||||||
|
];
|
||||||
|
const keep = liveLeasePresenceIds(conns, now);
|
||||||
|
expect([...keep].sort()).toEqual(["alive", "edge"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("THE GHOST: sweeper flips a row whose lease is online → reconcile returns it so the caller evicts (closes the socket)", () => {
|
||||||
|
// Prod 2026-09-08 22:05Z: presence rows flipped to disconnected while
|
||||||
|
// the daemon's socket stayed OPEN and nobody told it. list_peers hid
|
||||||
|
// the session; pushes still reached it; it never reconnected.
|
||||||
|
const conns = new Map<string, { ws: object; leaseState: "online" | "offline" }>([
|
||||||
|
["ghost", { ws: {}, leaseState: "online" }],
|
||||||
|
["graced", { ws: {}, leaseState: "offline" }],
|
||||||
|
]);
|
||||||
|
const out = reconcileFlippedPresences(["ghost", "graced", "already-gone"], (id) => conns.get(id));
|
||||||
|
expect(out).toEqual(["ghost"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("offline (in-grace) leases are left to their grace timer, never evicted by the sweeper", () => {
|
||||||
|
const conns = new Map([["g", { ws: {}, leaseState: "offline" as const }]]);
|
||||||
|
expect(reconcileFlippedPresences(["g"], (id) => conns.get(id))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nothing flipped → nothing to reconcile", () => {
|
||||||
|
expect(reconcileFlippedPresences([], () => undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user