diff --git a/doc/idle-gateway-credential-expiry.md b/doc/idle-gateway-credential-expiry.md index 427adcdc325..f996cd6acbb 100644 --- a/doc/idle-gateway-credential-expiry.md +++ b/doc/idle-gateway-credential-expiry.md @@ -26,7 +26,9 @@ reconstructs existing rows but does not provide continuous replica discovery. ## Worker contract -`nextCredentialExpiryAt` reads the earliest non-null expiry across both tables. +`nextCredentialExpiryAt` reads one earliest non-null expiry from each table using +expiry-first indexes, then compares those at most two values. The migration adds +the session expiry index; the token expiry index already exists. An empty credential store or retained non-expiring tokens returns no deadline. `registerGatewayCredentialExpiryWork` supplies cleanup and deadline selection to the shared `registerDeadlineWork` helper. Provider and authentication behavior @@ -47,6 +49,12 @@ in-flight work. ## Evidence and limits +The concurrent session index runs through the migration executor's existing +per-file top-level path. Initial application and repeat application were tested. +A PostgreSQL plan with 20,000 sessions and 20,000 tokens, including 19,000 +non-expiring tokens, used both expiry indexes and read one row from each. +This establishes bounded lookup on that fixture, not production latency. + The new PostgreSQL suite exercises real session/token creation, configured idle state, non-expiring history, expiry ordering, revoked-token cleanup, outer commit and rollback, startup reconstruction, a backlog larger than one cleanup batch, diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 5149c1c9b27..9a3c7b66012 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -504,6 +504,7 @@ const MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS = new Set([ "0332_little_night_nurse.sql", "0345_luxuriant_carmella_unuscione.sql", "0369_late_logan.sql", + "0378_mean_scream.sql", ]); async function applyPendingMigrationsManually( diff --git a/packages/db/src/migrations/0378_mean_scream.sql b/packages/db/src/migrations/0378_mean_scream.sql new file mode 100644 index 00000000000..8827365ffdd --- /dev/null +++ b/packages/db/src/migrations/0378_mean_scream.sql @@ -0,0 +1 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS "tool_gateway_sessions_expiry_idx" ON "tool_gateway_sessions" USING btree ("expires_at"); diff --git a/packages/db/src/migrations/meta/0373_snapshot.json b/packages/db/src/migrations/meta/0378_snapshot.json similarity index 99% rename from packages/db/src/migrations/meta/0373_snapshot.json rename to packages/db/src/migrations/meta/0378_snapshot.json index 3019f5fde87..95c60a814e9 100644 --- a/packages/db/src/migrations/meta/0373_snapshot.json +++ b/packages/db/src/migrations/meta/0378_snapshot.json @@ -1,6 +1,6 @@ { - "id": "406521f8-5352-415b-805d-786854381e03", - "prevId": "9c1b4cac-52f1-4c6e-89b3-c9a7816016c7", + "id": "01c4455a-e59c-43e6-940b-09b9c76171d8", + "prevId": "a0fcd9b8-f125-4985-8795-f567466b9aef", "version": "7", "dialect": "postgresql", "tables": { @@ -28508,6 +28508,13 @@ "primaryKey": false, "notNull": true }, + "checkout_name": { + "name": "checkout_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, "object_key": { "name": "object_key", "type": "text", @@ -28601,8 +28608,8 @@ "method": "btree", "with": {} }, - "execution_workspace_restore_points_live_generation_uniq": { - "name": "execution_workspace_restore_points_live_generation_uniq", + "execution_workspace_restore_points_live_status_uniq": { + "name": "execution_workspace_restore_points_live_status_uniq", "columns": [ { "expression": "execution_workspace_id", @@ -28615,10 +28622,16 @@ "isExpression": false, "asc": true, "nulls": "last" + }, + { + "expression": "checkout_name", + "isExpression": false, + "asc": true, + "nulls": "last" } ], "isUnique": true, - "where": "\"execution_workspace_restore_points\".\"status\" <> 'deleted'", + "where": "\"execution_workspace_restore_points\".\"status\" in ('pending', 'active', 'expiring')", "concurrently": false, "method": "btree", "with": {} @@ -55184,6 +55197,21 @@ "method": "btree", "with": {} }, + "tool_gateway_sessions_expiry_idx": { + "name": "tool_gateway_sessions_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, "tool_gateway_sessions_run_idx": { "name": "tool_gateway_sessions_run_idx", "columns": [ diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index c69031562fa..672cac04c29 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2633,6 +2633,13 @@ "when": 1792790134086, "tag": "0377_clear_settled_native_run_process_identity", "breakpoints": true + }, + { + "idx": 378, + "version": "7", + "when": 1792861534317, + "tag": "0378_mean_scream", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/tool_access.ts b/packages/db/src/schema/tool_access.ts index 5f356e7a8d5..5d749801b6b 100644 --- a/packages/db/src/schema/tool_access.ts +++ b/packages/db/src/schema/tool_access.ts @@ -710,6 +710,7 @@ export const toolGatewaySessions = pgTable( uniqueIndex("tool_gateway_sessions_token_hash_uq").on(table.tokenHash), index("tool_gateway_sessions_company_agent_idx").on(table.companyId, table.agentId), index("tool_gateway_sessions_company_expires_idx").on(table.companyId, table.expiresAt), + index("tool_gateway_sessions_expiry_idx").on(table.expiresAt), index("tool_gateway_sessions_run_idx").on(table.companyId, table.runId), index("tool_gateway_sessions_issue_idx").on(table.companyId, table.issueId), index("tool_gateway_sessions_gateway_idx").on(table.companyId, table.gatewayId), diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index a1ddc29bbd1..f43f2e950d6 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -11609,9 +11609,12 @@ export function createToolGatewayService( async nextCredentialExpiryAt(): Promise { const [row] = await db.execute<{ at: string | Date | null }>(sql` select min(expires_at) as at from ( - select ${toolMcpGatewayTokens.expiresAt} as expires_at from ${toolMcpGatewayTokens} + (select ${toolMcpGatewayTokens.expiresAt} as expires_at from ${toolMcpGatewayTokens} + where ${toolMcpGatewayTokens.expiresAt} is not null + order by ${toolMcpGatewayTokens.expiresAt} limit 1) union all - select ${toolGatewaySessions.expiresAt} as expires_at from ${toolGatewaySessions} + (select ${toolGatewaySessions.expiresAt} as expires_at from ${toolGatewaySessions} + order by ${toolGatewaySessions.expiresAt} limit 1) ) expiries `); return row?.at == null ? null : new Date(row.at).getTime();