From be6cbe7de51d7f7ebe8fa29bed2ac8edbc4aafdb Mon Sep 17 00:00:00 2001 From: KaspaPulse Date: Wed, 26 Aug 2026 00:08:29 +0000 Subject: [PATCH] fix(sessions): preserve legacy data when exclusive projection fails (#11469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated in a combined sub-batch worktree off release/v3.8.51 tip. - Focused test: exclusive-session-observability.test.ts — part of sub-batch's 165/165 node:test run - typecheck:core, file-size, changelog-integrity, complexity, cognitive-complexity — all OK - Full-repo lint: 228 pre-existing dashboard react-hooks/* findings, unrelated to this diff Thanks for the narrow failure boundary and the privacy-conscious warning (proven not to leak the caught error's sensitive fields) — a projection failure discarding valid legacy data was a real regression from #11389. --- ...-sessions-exclusive-projection-fallback.md | 1 + src/app/api/sessions/route.ts | 53 +++++++----- .../exclusive-session-observability.test.ts | 80 ++++++++++++++++++- 3 files changed, 114 insertions(+), 20 deletions(-) create mode 100644 changelog.d/fixes/pending-sessions-exclusive-projection-fallback.md diff --git a/changelog.d/fixes/pending-sessions-exclusive-projection-fallback.md b/changelog.d/fixes/pending-sessions-exclusive-projection-fallback.md new file mode 100644 index 0000000000..133deefe41 --- /dev/null +++ b/changelog.d/fixes/pending-sessions-exclusive-projection-fallback.md @@ -0,0 +1 @@ +- **fix(sessions):** preserve legacy Sessions data when the additive exclusive-session projection is unavailable, returning an empty projection and warning only once per contiguous outage instead of failing the endpoint; the Sessions badge intentionally reflects the merged legacy and exclusive row count introduced by [#11389](https://github.com/diegosouzapw/OmniRoute/pull/11389) diff --git a/src/app/api/sessions/route.ts b/src/app/api/sessions/route.ts index 062704ee85..19d1611ab9 100644 --- a/src/app/api/sessions/route.ts +++ b/src/app/api/sessions/route.ts @@ -10,32 +10,47 @@ import { getExclusiveLeaseOccupancy } from "@/lib/db/exclusiveConnectionLeases"; import { getProviderConnectionDisplayMetadata } from "@/lib/db/providers"; import { getAccountDisplayName } from "@/lib/display/names"; import { getPendingRequests } from "@/lib/usage/usageHistory"; -import { buildExclusiveDashboardSessions } from "@/lib/sessionObservability"; +import { + buildExclusiveDashboardSessions, + type ExclusiveDashboardSession, +} from "@/lib/sessionObservability"; + +const EXCLUSIVE_PROJECTION_WARNING = "[SESSIONS] Exclusive session projection unavailable"; +let exclusiveProjectionWarningEmitted = false; export async function GET() { try { const sessions = getActiveSessions(); const count = getActiveSessionCount(); const byApiKey = getAllActiveSessionCountsByKey(); + let exclusiveSessions: ExclusiveDashboardSession[] = []; - // Reuse the hard-lease authority added by #10362. The API-key policy derives - // the managed candidate set; SQLite occupancy is the source of truth for - // which of those connections are actually leased right now. - const managedConnectionIds = Array.from(await getExclusiveLeaseConnectionIds()); - const occupancy = getExclusiveLeaseOccupancy(managedConnectionIds); - const leasedConnectionIds = new Set(occupancy.keys()); - const connectionNames = new Map( - getProviderConnectionDisplayMetadata([...leasedConnectionIds]).map((connection) => [ - connection.id, - getAccountDisplayName(connection), - ]) - ); - const exclusiveSessions = buildExclusiveDashboardSessions( - leasedConnectionIds, - getPendingRequests().byAccount, - sessions, - connectionNames - ); + try { + // Reuse the hard-lease authority added by #10362. The API-key policy derives + // the managed candidate set; SQLite occupancy is the source of truth for + // which of those connections are actually leased right now. + const managedConnectionIds = Array.from(await getExclusiveLeaseConnectionIds()); + const occupancy = getExclusiveLeaseOccupancy(managedConnectionIds); + const leasedConnectionIds = new Set(occupancy.keys()); + const connectionNames = new Map( + getProviderConnectionDisplayMetadata([...leasedConnectionIds]).map((connection) => [ + connection.id, + getAccountDisplayName(connection), + ]) + ); + exclusiveSessions = buildExclusiveDashboardSessions( + leasedConnectionIds, + getPendingRequests().byAccount, + sessions, + connectionNames + ); + exclusiveProjectionWarningEmitted = false; + } catch { + if (!exclusiveProjectionWarningEmitted) { + exclusiveProjectionWarningEmitted = true; + console.warn(EXCLUSIVE_PROJECTION_WARNING); + } + } return NextResponse.json({ count, diff --git a/tests/unit/exclusive-session-observability.test.ts b/tests/unit/exclusive-session-observability.test.ts index 149c5936fd..d5fd11a4a8 100644 --- a/tests/unit/exclusive-session-observability.test.ts +++ b/tests/unit/exclusive-session-observability.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import test from "node:test"; +import test, { mock } from "node:test"; import { buildExclusiveDashboardSessions, @@ -319,6 +319,84 @@ test("sessions API keeps legacy fields additive and decorates only in-flight lea assert.deepEqual(releasedBody.exclusiveSessions, []); }); +test("sessions API deduplicates projection warnings per contiguous outage", async () => { + const secretFailure = { + ownerHash: "d".repeat(64), + generation: 91, + apiKeyId: "private-api-key-id", + credential: "private-credential", + token: "private-token", + connectionIdentity: "private-connection-identity", + leaseOwnership: "private-lease-ownership", + fencingMaterial: "private-fencing-material", + }; + const nowMock = mock.method(Date, "now", () => BASE_TIME); + sessionManager.touchSession("legacy-fallback", "legacy-connection"); + sessionManager.registerKeySession("legacy-key", "legacy-fallback"); + + const db = core.getDbInstance(); + const originalPrepare = db.prepare.bind(db); + let projectionFails = true; + const prepareMock = mock.method(db, "prepare", (sql: string) => { + if (projectionFails) throw new Error(JSON.stringify(secretFailure)); + return originalPrepare(sql); + }); + const warnings: unknown[][] = []; + const warnMock = mock.method(console, "warn", (...args: unknown[]) => { + warnings.push(args); + }); + + try { + const firstResponse = await sessionsRoute.GET(); + const firstBody = (await firstResponse.json()) as { + count: number; + sessions: Array<{ sessionId: string; connectionId: string | null }>; + byApiKey: Record; + exclusiveSessions: unknown[]; + }; + const secondResponse = await sessionsRoute.GET(); + const secondBody = (await secondResponse.json()) as typeof firstBody; + + assert.equal(firstResponse.status, 200); + assert.equal(secondResponse.status, 200); + assert.deepEqual(secondBody, firstBody); + assert.equal(firstBody.count, 1); + assert.equal(firstBody.sessions[0].sessionId, "legacy-fallback"); + assert.equal(firstBody.sessions[0].connectionId, "legacy-connection"); + assert.deepEqual(firstBody.byApiKey, { "legacy-key": 1 }); + assert.deepEqual(firstBody.exclusiveSessions, []); + assert.deepEqual(warnings, [["[SESSIONS] Exclusive session projection unavailable"]]); + + projectionFails = false; + const recoveredResponse = await sessionsRoute.GET(); + assert.equal(recoveredResponse.status, 200); + projectionFails = true; + + const laterOutageResponse = await sessionsRoute.GET(); + const laterOutageBody = (await laterOutageResponse.json()) as typeof firstBody; + assert.equal(laterOutageResponse.status, 200); + assert.deepEqual(laterOutageBody, firstBody); + assert.deepEqual(warnings, [ + ["[SESSIONS] Exclusive session projection unavailable"], + ["[SESSIONS] Exclusive session projection unavailable"], + ]); + + const observableOutput = JSON.stringify({ + firstBody, + secondBody, + laterOutageBody, + warnings, + }); + for (const forbidden of Object.values(secretFailure)) { + assert.equal(observableOutput.includes(String(forbidden)), false); + } + } finally { + warnMock.mock.restore(); + prepareMock.mock.restore(); + nowMock.mock.restore(); + } +}); + test("sessions route keeps raw lease SQL out of the API and sanitizes failures", () => { const route = fs.readFileSync( new URL("../../src/app/api/sessions/route.ts", import.meta.url),