Tie keepalives to successful chat inference
| @@ -5264,6 +5264,30 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro | |||
| 5264 | return Promise.resolve(); | 5264 | return Promise.resolve(); |
| 5265 | } | 5265 | } |
| 5266 | 5266 | ||
| 5267 | let activeProviderRequest = null; | ||
| 5268 | |||
| 5269 | async function beginProviderRequest() { | ||
| 5270 | activeProviderRequest = { | ||
| 5271 | id: uuidv4(), | ||
| 5272 | type, | ||
| 5273 | startedAt: Date.now(), | ||
| 5274 | }; | ||
| 5275 | await eventSource.emit(event_types.GENERATION_REQUEST_STARTED, activeProviderRequest); | ||
| 5276 | } | ||
| 5277 | |||
| 5278 | async function finishProviderRequest(success) { | ||
| 5279 | if (!activeProviderRequest) { | ||
| 5280 | return; | ||
| 5281 | } | ||
| 5282 | const request = activeProviderRequest; | ||
| 5283 | activeProviderRequest = null; | ||
| 5284 | await eventSource.emit(event_types.GENERATION_REQUEST_FINISHED, { | ||
| 5285 | ...request, | ||
| 5286 | success: !!success, | ||
| 5287 | finishedAt: Date.now(), | ||
| 5288 | }); | ||
| 5289 | } | ||
| 5290 | |||
| 5267 | /** | 5291 | /** |
| 5268 | * Saves itemized prompt bits and calls streaming or non-streaming generation API. | 5292 | * Saves itemized prompt bits and calls streaming or non-streaming generation API. |
| 5269 | * @returns {Promise<void|*|Awaited<*>|String|{fromStream}|string|undefined|Object>} | 5293 | * @returns {Promise<void|*|Awaited<*>|String|{fromStream}|string|undefined|Object>} |
| @@ -5333,6 +5357,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro | |||
| 5333 | streamingProcessor.firstMessageText = ''; | 5357 | streamingProcessor.firstMessageText = ''; |
| 5334 | } | 5358 | } |
| 5335 | 5359 | ||
| 5360 | await beginProviderRequest(); | ||
| 5336 | streamingProcessor.generator = await sendStreamingRequest(type, generate_data, { jsonSchema }); | 5361 | streamingProcessor.generator = await sendStreamingRequest(type, generate_data, { jsonSchema }); |
| 5337 | 5362 | ||
| 5338 | hideSwipeButtons(); | 5363 | hideSwipeButtons(); |
| @@ -5350,6 +5375,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro | |||
| 5350 | 5375 | ||
| 5351 | const isStreamFinished = streamingProcessor && !streamingProcessor.isStopped && streamingProcessor.isFinished; | 5376 | const isStreamFinished = streamingProcessor && !streamingProcessor.isStopped && streamingProcessor.isFinished; |
| 5352 | const isStreamWithToolCalls = streamingProcessor && Array.isArray(streamingProcessor.toolCalls) && streamingProcessor.toolCalls.length; | 5377 | const isStreamWithToolCalls = streamingProcessor && Array.isArray(streamingProcessor.toolCalls) && streamingProcessor.toolCalls.length; |
| 5378 | await finishProviderRequest(isStreamFinished); | ||
| 5353 | if (canPerformToolCalls && isStreamFinished && isStreamWithToolCalls) { | 5379 | if (canPerformToolCalls && isStreamFinished && isStreamWithToolCalls) { |
| 5354 | const lastMessage = chat[chat.length - 1]; | 5380 | const lastMessage = chat[chat.length - 1]; |
| 5355 | const hasToolCalls = ToolManager.hasToolCalls(streamingProcessor.toolCalls); | 5381 | const hasToolCalls = ToolManager.hasToolCalls(streamingProcessor.toolCalls); |
| @@ -5389,7 +5415,10 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro | |||
| 5389 | }); | 5415 | }); |
| 5390 | } | 5416 | } |
| 5391 | } else { | 5417 | } else { |
| 5392 | return await sendGenerationRequest(type, generate_data, { jsonSchema }); | 5418 | await beginProviderRequest(); |
| 5419 | const data = await sendGenerationRequest(type, generate_data, { jsonSchema }); | ||
| 5420 | await finishProviderRequest(!data?.error); | ||
| 5421 | return data; | ||
| 5393 | } | 5422 | } |
| 5394 | } | 5423 | } |
| 5395 | 5424 | ||
| @@ -5530,12 +5559,13 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro | |||
| 5530 | * @param {Error|object} exception Error or response JSON | 5559 | * @param {Error|object} exception Error or response JSON |
| 5531 | * @throws {Error|object} Re-throws the exception | 5560 | * @throws {Error|object} Re-throws the exception |
| 5532 | */ | 5561 | */ |
| 5533 | function onError(exception) { | 5562 | async function onError(exception) { |
| 5534 | // if the response JSON was thrown (novel|textgenerationwebui|kobold), show the error message | 5563 | // if the response JSON was thrown (novel|textgenerationwebui|kobold), show the error message |
| 5535 | if (typeof exception?.error?.message === 'string') { | 5564 | if (typeof exception?.error?.message === 'string') { |
| 5536 | toastr.error(exception.error.message, t`Text generation error`, { timeOut: 10000, extendedTimeOut: 20000 }); | 5565 | toastr.error(exception.error.message, t`Text generation error`, { timeOut: 10000, extendedTimeOut: 20000 }); |
| 5537 | } | 5566 | } |
| 5538 | 5567 | ||
| 5568 | await finishProviderRequest(false); | ||
| 5539 | unblockGeneration(type); | 5569 | unblockGeneration(type); |
| 5540 | console.log(exception); | 5570 | console.log(exception); |
| 5541 | streamingProcessor = null; | 5571 | streamingProcessor = null; |
| @@ -23,6 +23,8 @@ export const event_types = { | |||
| 23 | GENERATION_STARTED: 'generation_started', | 23 | GENERATION_STARTED: 'generation_started', |
| 24 | GENERATION_STOPPED: 'generation_stopped', | 24 | GENERATION_STOPPED: 'generation_stopped', |
| 25 | GENERATION_ENDED: 'generation_ended', | 25 | GENERATION_ENDED: 'generation_ended', |
| 26 | GENERATION_REQUEST_STARTED: 'generation_request_started', | ||
| 27 | GENERATION_REQUEST_FINISHED: 'generation_request_finished', | ||
| 26 | SD_PROMPT_PROCESSING: 'sd_prompt_processing', | 28 | SD_PROMPT_PROCESSING: 'sd_prompt_processing', |
| 27 | EXTENSIONS_FIRST_LOAD: 'extensions_first_load', | 29 | EXTENSIONS_FIRST_LOAD: 'extensions_first_load', |
| 28 | EXTENSION_SETTINGS_LOADED: 'extension_settings_loaded', | 30 | EXTENSION_SETTINGS_LOADED: 'extension_settings_loaded', |
| @@ -27,6 +27,7 @@ const KEEPALIVE_RESPONSE_LENGTH = 1; | |||
| 27 | const HEARTBEAT_INTERVAL_MS = 20000; | 27 | const HEARTBEAT_INTERVAL_MS = 20000; |
| 28 | const RETRY_INTERVAL_MS = 5000; | 28 | const RETRY_INTERVAL_MS = 5000; |
| 29 | const SETTINGS_REFRESH_DELAY_MS = 300; | 29 | const SETTINGS_REFRESH_DELAY_MS = 300; |
| 30 | const MAIN_CHAT_GENERATION_TYPES = new Set(['normal', 'regenerate', 'swipe', 'continue']); | ||
| 30 | 31 | ||
| 31 | // Extension-prompt key used to inject the keepalive message as a USER message at depth 0. | 32 | // Extension-prompt key used to inject the keepalive message as a USER message at depth 0. |
| 32 | const KEEPALIVE_INJECT_ID = 'keepalive_ping'; | 33 | const KEEPALIVE_INJECT_ID = 'keepalive_ping'; |
| @@ -54,15 +55,16 @@ let generationRefreshTimer = null; | |||
| 54 | let retryTimer = null; | 55 | let retryTimer = null; |
| 55 | let preparingJob = false; | 56 | let preparingJob = false; |
| 56 | let lifecycleVersion = 0; | 57 | let lifecycleVersion = 0; |
| 57 | let foregroundGenerationActive = false; | ||
| 58 | let temporaryConnectionDepth = 0; | 58 | let temporaryConnectionDepth = 0; |
| 59 | let refreshAfterTemporaryConnection = false; | 59 | let refreshAfterTemporaryConnection = false; |
| 60 | const activeMainRequests = new Map(); | ||
| 61 | let latestSuccessfulRequestStartedAt = 0; | ||
| 60 | // Whether keepalive is "armed" for the CURRENT chat. It stays dormant until the user sends a new | 62 | // Whether keepalive is "armed" for the CURRENT chat. It stays dormant until the user sends a new |
| 61 | // message or starts another foreground generation in the open chat. Merely loading a chat does not | 63 | // message or starts another foreground generation in the open chat. Merely loading a chat does not |
| 62 | // prove that its prompt is cached server-side. Reset on page reload and whenever the chat changes. | 64 | // prove that its prompt is cached server-side. Reset on page reload and whenever the chat changes. |
| 63 | let armed = false; | 65 | let armed = false; |
| 64 | // Per-profile "time since last message", keyed by connection profile id (or NONE_KEY). | 66 | // Per-profile time of the last verified successful main-chat inference request, keyed by |
| 65 | // In-memory only: on reload every profile is treated as freshly active. | 67 | // connection profile id (or NONE_KEY). In-memory only; a reload starts disarmed. |
| 66 | const lastActivity = {}; | 68 | const lastActivity = {}; |
| 67 | // Last successful backend keepalive, keyed by connection profile. The backend returns this after a | 69 | // Last successful backend keepalive, keyed by connection profile. The backend returns this after a |
| 68 | // sleeping tab wakes so we can distinguish a still-warm prompt from an expired one. | 70 | // sleeping tab wakes so we can distinguish a still-warm prompt from an expired one. |
| @@ -147,7 +149,7 @@ async function stopBackendJob() { | |||
| 147 | } | 149 | } |
| 148 | } | 150 | } |
| 149 | 151 | ||
| 150 | async function sendBackendHeartbeat(activity = false) { | 152 | async function sendBackendHeartbeat() { |
| 151 | if (temporaryConnectionDepth > 0 || !armed || !isKeepaliveEnabledForActive()) { | 153 | if (temporaryConnectionDepth > 0 || !armed || !isKeepaliveEnabledForActive()) { |
| 152 | return; | 154 | return; |
| 153 | } | 155 | } |
| @@ -165,7 +167,7 @@ async function sendBackendHeartbeat(activity = false) { | |||
| 165 | const version = lifecycleVersion; | 167 | const version = lifecycleVersion; |
| 166 | const profileKey = getActiveProfileKey(); | 168 | const profileKey = getActiveProfileKey(); |
| 167 | try { | 169 | try { |
| 168 | const response = await postKeepaliveBackend('heartbeat', { activity }); | 170 | const response = await postKeepaliveBackend('heartbeat', {}); |
| 169 | const state = await response.json().catch(() => ({})); | 171 | const state = await response.json().catch(() => ({})); |
| 170 | if (version !== lifecycleVersion) { | 172 | if (version !== lifecycleVersion) { |
| 171 | return; | 173 | return; |
| @@ -184,20 +186,14 @@ async function sendBackendHeartbeat(activity = false) { | |||
| 184 | } | 186 | } |
| 185 | } | 187 | } |
| 186 | 188 | ||
| 187 | function markActivity() { | ||
| 188 | if (preparingJob || temporaryConnectionDepth > 0) { | ||
| 189 | return; | ||
| 190 | } | ||
| 191 | lastActivity[getActiveProfileKey()] = Date.now(); | ||
| 192 | void sendBackendHeartbeat(true); | ||
| 193 | } | ||
| 194 | |||
| 195 | /** | 189 | /** |
| 196 | * A new user message arms keepalive before its foreground generation begins. | 190 | * A new user message may lead to a main-chat request, but does not prove the provider accepted it. |
| 191 | * Arm the feature and refresh only the replay payload; the deadline remains unchanged until the | ||
| 192 | * request-success event arrives. | ||
| 197 | */ | 193 | */ |
| 198 | function onMessageSent() { | 194 | function onMessageSent() { |
| 199 | armed = true; | 195 | armed = true; |
| 200 | markActivity(); | 196 | queueJobRefresh(); |
| 201 | } | 197 | } |
| 202 | 198 | ||
| 203 | /** | 199 | /** |
| @@ -206,6 +202,8 @@ function onMessageSent() { | |||
| 206 | */ | 202 | */ |
| 207 | function onChatChanged() { | 203 | function onChatChanged() { |
| 208 | armed = false; | 204 | armed = false; |
| 205 | activeMainRequests.clear(); | ||
| 206 | latestSuccessfulRequestStartedAt = 0; | ||
| 209 | void stopBackendJob(); | 207 | void stopBackendJob(); |
| 210 | } | 208 | } |
| 211 | 209 | ||
| @@ -308,11 +306,15 @@ async function registerBackendJob() { | |||
| 308 | return; | 306 | return; |
| 309 | } | 307 | } |
| 310 | const profileKey = getActiveProfileKey(); | 308 | const profileKey = getActiveProfileKey(); |
| 309 | if (!getLastWarmTime(profileKey)) { | ||
| 310 | return; | ||
| 311 | } | ||
| 311 | if (isWarmPromptExpired(profileKey)) { | 312 | if (isWarmPromptExpired(profileKey)) { |
| 312 | disarmExpiredJob(); | 313 | disarmExpiredJob(); |
| 313 | return; | 314 | return; |
| 314 | } | 315 | } |
| 315 | const version = lifecycleVersion; | 316 | const version = lifecycleVersion; |
| 317 | const hadRegisteredJob = jobRegistered; | ||
| 316 | try { | 318 | try { |
| 317 | if (isGenerating()) { | 319 | if (isGenerating()) { |
| 318 | throw new Error('Waiting for the active generation to finish'); | 320 | throw new Error('Waiting for the active generation to finish'); |
| @@ -343,7 +345,10 @@ async function registerBackendJob() { | |||
| 343 | clearTimeout(retryTimer); | 345 | clearTimeout(retryTimer); |
| 344 | jobRegistered = true; | 346 | jobRegistered = true; |
| 345 | } catch (error) { | 347 | } catch (error) { |
| 346 | jobRegistered = false; | 348 | // A failed payload refresh does not mean the existing backend job disappeared. Keeping |
| 349 | // this state is especially important while a foreground request is starting, because its | ||
| 350 | // lifecycle handler still needs to pause that existing job before provider dispatch. | ||
| 351 | jobRegistered = hadRegisteredJob; | ||
| 347 | console.debug('[Keepalive] Could not register backend job:', error); | 352 | console.debug('[Keepalive] Could not register backend job:', error); |
| 348 | clearTimeout(retryTimer); | 353 | clearTimeout(retryTimer); |
| 349 | retryTimer = setTimeout(queueJobRefresh, RETRY_INTERVAL_MS); | 354 | retryTimer = setTimeout(queueJobRefresh, RETRY_INTERVAL_MS); |
| @@ -506,36 +511,81 @@ function setupListeners() { | |||
| 506 | }); | 511 | }); |
| 507 | } | 512 | } |
| 508 | 513 | ||
| 509 | function onGenerationStarted(type, _options, dryRun) { | 514 | async function pauseBackendJob() { |
| 510 | // Quiet/background generations (image prompts, summaries, helpers, etc.) use a different model | 515 | if (!jobRegistered) { |
| 511 | // or context and do not refresh the foreground text-chat cache. | 516 | return; |
| 512 | if (dryRun || type === 'quiet') { | 517 | } |
| 518 | try { | ||
| 519 | const response = await postKeepaliveBackend('pause', {}); | ||
| 520 | if (response.status === 404) { | ||
| 521 | jobRegistered = false; | ||
| 522 | } | ||
| 523 | } catch (error) { | ||
| 524 | console.debug('[Keepalive] Could not pause backend job:', error); | ||
| 525 | } | ||
| 526 | } | ||
| 527 | |||
| 528 | async function resumeBackendJob(activityStartedAt = null) { | ||
| 529 | if (!jobRegistered) { | ||
| 530 | return; | ||
| 531 | } | ||
| 532 | const elapsedMs = Number.isFinite(activityStartedAt) | ||
| 533 | ? Math.max(0, Date.now() - activityStartedAt) | ||
| 534 | : null; | ||
| 535 | const profileKey = getActiveProfileKey(); | ||
| 536 | try { | ||
| 537 | const response = await postKeepaliveBackend('resume', { | ||
| 538 | activityElapsedMs: elapsedMs, | ||
| 539 | }); | ||
| 540 | const state = await response.json().catch(() => ({})); | ||
| 541 | updateLastKeepalive(profileKey, state.lastRun); | ||
| 542 | if (response.status === 404) { | ||
| 543 | jobRegistered = false; | ||
| 544 | } | ||
| 545 | } catch (error) { | ||
| 546 | console.debug('[Keepalive] Could not resume backend job:', error); | ||
| 547 | } | ||
| 548 | } | ||
| 549 | |||
| 550 | async function onGenerationRequestStarted(request) { | ||
| 551 | if (temporaryConnectionDepth > 0 || !request?.id || !MAIN_CHAT_GENERATION_TYPES.has(request.type)) { | ||
| 513 | return; | 552 | return; |
| 514 | } | 553 | } |
| 515 | // Regenerates, swipes, and continues send the current prompt to the provider without emitting | ||
| 516 | // MESSAGE_SENT. They are just as valid proof of a warm prompt as a newly typed message. | ||
| 517 | foregroundGenerationActive = true; | ||
| 518 | armed = true; | 554 | armed = true; |
| 519 | markActivity(); | 555 | activeMainRequests.set(request.id, { |
| 556 | profileKey: getActiveProfileKey(), | ||
| 557 | startedAt: Number(request.startedAt) || Date.now(), | ||
| 558 | }); | ||
| 559 | if (activeMainRequests.size === 1) { | ||
| 560 | await pauseBackendJob(); | ||
| 561 | } | ||
| 520 | } | 562 | } |
| 521 | 563 | ||
| 522 | function onGenerationEnded() { | 564 | async function onGenerationRequestFinished(request) { |
| 523 | if (!foregroundGenerationActive) { | 565 | const activeRequest = activeMainRequests.get(request?.id); |
| 566 | if (!activeRequest) { | ||
| 524 | return; | 567 | return; |
| 525 | } | 568 | } |
| 526 | foregroundGenerationActive = false; | 569 | activeMainRequests.delete(request.id); |
| 527 | markActivity(); | 570 | |
| 528 | scheduleGenerationJobRefresh(); | 571 | if (request.success) { |
| 572 | const startedAt = activeRequest.startedAt; | ||
| 573 | lastActivity[activeRequest.profileKey] = Math.max(lastActivity[activeRequest.profileKey] || 0, startedAt); | ||
| 574 | latestSuccessfulRequestStartedAt = Math.max(latestSuccessfulRequestStartedAt, startedAt); | ||
| 575 | armed = true; | ||
| 529 | } | 576 | } |
| 530 | 577 | ||
| 531 | function onMessageReceived(_messageId, type) { | 578 | if (activeMainRequests.size > 0) { |
| 532 | // Image-generation extensions add their results as chat messages. Those messages neither used | ||
| 533 | // nor refreshed the foreground text cache, so they must not move its keepalive deadline. | ||
| 534 | if (type === 'extension') { | ||
| 535 | return; | 579 | return; |
| 536 | } | 580 | } |
| 537 | foregroundGenerationActive = false; | 581 | |
| 538 | markActivity(); | 582 | const successfulStartedAt = latestSuccessfulRequestStartedAt || null; |
| 583 | latestSuccessfulRequestStartedAt = 0; | ||
| 584 | await resumeBackendJob(successfulStartedAt); | ||
| 585 | |||
| 586 | // A failed request may still have changed local chat content, so refresh the replay payload, | ||
| 587 | // but registerBackendJob() will retain the old deadline. A successful request supplies the | ||
| 588 | // verified activity timestamp used for the new deadline. | ||
| 539 | scheduleGenerationJobRefresh(); | 589 | scheduleGenerationJobRefresh(); |
| 540 | } | 590 | } |
| 541 | 591 | ||
| @@ -580,17 +630,15 @@ async function init() { | |||
| 580 | setupListeners(); | 630 | setupListeners(); |
| 581 | setupHeartbeatLoop(); | 631 | setupHeartbeatLoop(); |
| 582 | 632 | ||
| 583 | // A new user message arms keepalive before generation starts. Foreground generations that do | 633 | // A user message arms keepalive, but only the exact provider-request lifecycle below is allowed |
| 584 | // not send a new message (regenerate/swipe/continue) are armed by onGenerationStarted instead. | 634 | // to move its deadline. Regenerate/swipe/continue requests arm it when their request starts. |
| 585 | eventSource.on(event_types.MESSAGE_SENT, onMessageSent); | 635 | eventSource.on(event_types.MESSAGE_SENT, onMessageSent); |
| 586 | 636 | ||
| 587 | // Only actual text-provider generations reset the cache deadline. Local chat edits still | 637 | // Only actual text-provider generations reset the cache deadline. Local chat edits still |
| 588 | // refresh the stored request so the server replays the latest prompt without pretending that | 638 | // refresh the stored request so the server replays the latest prompt without pretending that |
| 589 | // those edits refreshed the provider cache. | 639 | // those edits refreshed the provider cache. |
| 590 | eventSource.on(event_types.MESSAGE_RECEIVED, onMessageReceived); | 640 | eventSource.on(event_types.GENERATION_REQUEST_STARTED, onGenerationRequestStarted); |
| 591 | eventSource.on(event_types.GENERATION_STARTED, onGenerationStarted); | 641 | eventSource.on(event_types.GENERATION_REQUEST_FINISHED, onGenerationRequestFinished); |
| 592 | eventSource.on(event_types.GENERATION_ENDED, onGenerationEnded); | ||
| 593 | eventSource.on(event_types.GENERATION_STOPPED, () => { foregroundGenerationActive = false; }); | ||
| 594 | eventSource.on(event_types.MESSAGE_EDITED, onChatContentChanged); | 642 | eventSource.on(event_types.MESSAGE_EDITED, onChatContentChanged); |
| 595 | eventSource.on(event_types.MESSAGE_DELETED, onChatContentChanged); | 643 | eventSource.on(event_types.MESSAGE_DELETED, onChatContentChanged); |
| 596 | eventSource.on(event_types.MESSAGE_UPDATED, onChatContentChanged); | 644 | eventSource.on(event_types.MESSAGE_UPDATED, onChatContentChanged); |
| @@ -6,7 +6,7 @@ | |||
| 6 | "js": "index.js", | 6 | "js": "index.js", |
| 7 | "css": "", | 7 | "css": "", |
| 8 | "author": "SillyTavern", | 8 | "author": "SillyTavern", |
| 9 | "version": "1.1.5", | 9 | "version": "1.1.6", |
| 10 | "homePage": "https://github.com/SillyTavern/SillyTavern", | 10 | "homePage": "https://github.com/SillyTavern/SillyTavern", |
| 11 | "hooks": { | 11 | "hooks": { |
| 12 | "activate": "init" | 12 | "activate": "init" |
| @@ -7,6 +7,7 @@ export const router = express.Router(); | |||
| 7 | export const FRONTEND_LEASE_MS = 5 * 60 * 1000; | 7 | export const FRONTEND_LEASE_MS = 5 * 60 * 1000; |
| 8 | const MIN_INTERVAL_MS = 60 * 1000; | 8 | const MIN_INTERVAL_MS = 60 * 1000; |
| 9 | const MAX_INTERVAL_MS = 24 * 60 * 60 * 1000; | 9 | const MAX_INTERVAL_MS = 24 * 60 * 60 * 1000; |
| 10 | const FAILED_REQUEST_RETRY_MS = 5 * 1000; | ||
| 10 | const EXPIRED_JOB_RETENTION_MS = MAX_INTERVAL_MS; | 11 | const EXPIRED_JOB_RETENTION_MS = MAX_INTERVAL_MS; |
| 11 | const MAX_EXPIRED_JOBS = 1000; | 12 | const MAX_EXPIRED_JOBS = 1000; |
| 12 | const MAX_JOB_ID_LENGTH = 128; | 13 | const MAX_JOB_ID_LENGTH = 128; |
| @@ -55,24 +56,38 @@ export class KeepaliveScheduler { | |||
| 55 | const now = this.#now(); | 56 | const now = this.#now(); |
| 56 | this.#pruneExpiredJobs(now); | 57 | this.#pruneExpiredJobs(now); |
| 57 | this.#expiredJobs.delete(key); | 58 | this.#expiredJobs.delete(key); |
| 59 | const existing = this.#jobs.get(key); | ||
| 58 | const intervalMs = Math.min(MAX_INTERVAL_MS, Math.max(MIN_INTERVAL_MS, Number(job.intervalMs) || MIN_INTERVAL_MS)); | 60 | const intervalMs = Math.min(MAX_INTERVAL_MS, Math.max(MIN_INTERVAL_MS, Number(job.intervalMs) || MIN_INTERVAL_MS)); |
| 59 | const requestedDelay = Number(job.delayMs); | 61 | const requestedDelay = Number(job.delayMs); |
| 60 | const delayMs = Number.isFinite(requestedDelay) | 62 | const delayMs = Number.isFinite(requestedDelay) |
| 61 | ? Math.min(intervalMs, Math.max(0, requestedDelay)) | 63 | ? Math.min(intervalMs, Math.max(0, requestedDelay)) |
| 62 | : intervalMs; | 64 | : intervalMs; |
| 65 | const requestedNextRun = now + delayMs; | ||
| 66 | if (existing) { | ||
| 67 | // Re-registering updates the replay payload, but cannot claim that the provider cache | ||
| 68 | // was refreshed. Mutate the existing object so an in-flight dispatch can still clear | ||
| 69 | // its state, while preserving an earlier deadline and any foreground-request pause. | ||
| 70 | Object.assign(existing, job, { | ||
| 71 | intervalMs, | ||
| 72 | lastSeen: now, | ||
| 73 | nextRun: Math.min(existing.nextRun, requestedNextRun), | ||
| 74 | }); | ||
| 75 | } else { | ||
| 63 | this.#jobs.set(key, { | 76 | this.#jobs.set(key, { |
| 64 | ...job, | 77 | ...job, |
| 65 | intervalMs, | 78 | intervalMs, |
| 66 | lastSeen: now, | 79 | lastSeen: now, |
| 67 | nextRun: now + delayMs, | 80 | nextRun: requestedNextRun, |
| 68 | inFlight: false, | 81 | inFlight: false, |
| 82 | paused: false, | ||
| 69 | lastRun: null, | 83 | lastRun: null, |
| 70 | }); | 84 | }); |
| 85 | } | ||
| 71 | this.#schedule(); | 86 | this.#schedule(); |
| 72 | return this.#jobs.get(key); | 87 | return this.#jobs.get(key); |
| 73 | } | 88 | } |
| 74 | 89 | ||
| 75 | heartbeat(key, activity = false, request = null) { | 90 | heartbeat(key, request = null) { |
| 76 | const job = this.#jobs.get(key); | 91 | const job = this.#jobs.get(key); |
| 77 | if (!job) { | 92 | if (!job) { |
| 78 | return null; | 93 | return null; |
| @@ -84,9 +99,6 @@ export class KeepaliveScheduler { | |||
| 84 | return null; | 99 | return null; |
| 85 | } | 100 | } |
| 86 | job.lastSeen = now; | 101 | job.lastSeen = now; |
| 87 | if (activity) { | ||
| 88 | job.nextRun = now + job.intervalMs; | ||
| 89 | } | ||
| 90 | if (request) { | 102 | if (request) { |
| 91 | job.request = request; | 103 | job.request = request; |
| 92 | } | 104 | } |
| @@ -94,6 +106,33 @@ export class KeepaliveScheduler { | |||
| 94 | return job; | 106 | return job; |
| 95 | } | 107 | } |
| 96 | 108 | ||
| 109 | pause(key) { | ||
| 110 | const job = this.#jobs.get(key); | ||
| 111 | if (!job) { | ||
| 112 | return null; | ||
| 113 | } | ||
| 114 | job.lastSeen = this.#now(); | ||
| 115 | job.paused = true; | ||
| 116 | this.#schedule(); | ||
| 117 | return job; | ||
| 118 | } | ||
| 119 | |||
| 120 | resume(key, activityElapsedMs = null) { | ||
| 121 | const job = this.#jobs.get(key); | ||
| 122 | if (!job) { | ||
| 123 | return null; | ||
| 124 | } | ||
| 125 | const now = this.#now(); | ||
| 126 | job.lastSeen = now; | ||
| 127 | job.paused = false; | ||
| 128 | const elapsedMs = Number(activityElapsedMs); | ||
| 129 | if (activityElapsedMs !== null && activityElapsedMs !== undefined && Number.isFinite(elapsedMs) && elapsedMs >= 0) { | ||
| 130 | job.nextRun = now + Math.max(0, job.intervalMs - elapsedMs); | ||
| 131 | } | ||
| 132 | this.#schedule(); | ||
| 133 | return job; | ||
| 134 | } | ||
| 135 | |||
| 97 | stop(key) { | 136 | stop(key) { |
| 98 | const removed = this.#jobs.delete(key); | 137 | const removed = this.#jobs.delete(key); |
| 99 | this.#expiredJobs.delete(key); | 138 | this.#expiredJobs.delete(key); |
| @@ -120,21 +159,22 @@ export class KeepaliveScheduler { | |||
| 120 | if (job.nextRun > now) { | 159 | if (job.nextRun > now) { |
| 121 | continue; | 160 | continue; |
| 122 | } | 161 | } |
| 123 | 162 | if (job.inFlight || job.paused) { | |
| 124 | job.nextRun = advanceDeadline(job.nextRun, job.intervalMs, now); | ||
| 125 | if (job.inFlight) { | ||
| 126 | continue; | 163 | continue; |
| 127 | } | 164 | } |
| 165 | const scheduledRun = job.nextRun; | ||
| 128 | job.inFlight = true; | 166 | job.inFlight = true; |
| 129 | pending.push(Promise.resolve(this.#dispatch(job)).then(() => { | 167 | pending.push(Promise.resolve(this.#dispatch(job)).then(() => { |
| 130 | const completedAt = this.#now(); | 168 | const completedAt = this.#now(); |
| 131 | job.lastRun = completedAt; | 169 | job.lastRun = completedAt; |
| 170 | job.nextRun = advanceDeadline(scheduledRun, job.intervalMs, completedAt); | ||
| 132 | const expiredJob = this.#expiredJobs.get(key); | 171 | const expiredJob = this.#expiredJobs.get(key); |
| 133 | if (expiredJob) { | 172 | if (expiredJob) { |
| 134 | expiredJob.lastRun = completedAt; | 173 | expiredJob.lastRun = completedAt; |
| 135 | } | 174 | } |
| 136 | }).catch(error => { | 175 | }).catch(error => { |
| 137 | console.debug(`[Keepalive] Backend request failed for ${job.id}:`, error); | 176 | console.debug(`[Keepalive] Backend request failed for ${job.id}:`, error); |
| 177 | job.nextRun = this.#now() + FAILED_REQUEST_RETRY_MS; | ||
| 138 | }).finally(() => { | 178 | }).finally(() => { |
| 139 | job.inFlight = false; | 179 | job.inFlight = false; |
| 140 | this.#schedule(); | 180 | this.#schedule(); |
| @@ -176,7 +216,8 @@ export class KeepaliveScheduler { | |||
| 176 | 216 | ||
| 177 | let nextWake = Infinity; | 217 | let nextWake = Infinity; |
| 178 | for (const job of this.#jobs.values()) { | 218 | for (const job of this.#jobs.values()) { |
| 179 | nextWake = Math.min(nextWake, job.nextRun, job.lastSeen + FRONTEND_LEASE_MS); | 219 | const nextRun = (job.inFlight || job.paused) ? Infinity : job.nextRun; |
| 220 | nextWake = Math.min(nextWake, nextRun, job.lastSeen + FRONTEND_LEASE_MS); | ||
| 180 | } | 221 | } |
| 181 | const delay = Math.max(0, nextWake - this.#now()); | 222 | const delay = Math.max(0, nextWake - this.#now()); |
| 182 | this.#timer = this.#setTimer(() => { | 223 | this.#timer = this.#setTimer(() => { |
| @@ -287,7 +328,7 @@ router.post('/heartbeat', (request, response) => { | |||
| 287 | try { | 328 | try { |
| 288 | const id = getJobId(request); | 329 | const id = getJobId(request); |
| 289 | const key = getJobKey(request, id); | 330 | const key = getJobKey(request, id); |
| 290 | const job = scheduler.heartbeat(key, !!request.body?.activity, getReplayRequest(request)); | 331 | const job = scheduler.heartbeat(key, getReplayRequest(request)); |
| 291 | if (!job) { | 332 | if (!job) { |
| 292 | const expiredJob = scheduler.getExpired(key); | 333 | const expiredJob = scheduler.getExpired(key); |
| 293 | return response.status(404).send({ | 334 | return response.status(404).send({ |
| @@ -301,6 +342,39 @@ router.post('/heartbeat', (request, response) => { | |||
| 301 | } | 342 | } |
| 302 | }); | 343 | }); |
| 303 | 344 | ||
| 345 | router.post('/pause', (request, response) => { | ||
| 346 | try { | ||
| 347 | const id = getJobId(request); | ||
| 348 | const job = scheduler.pause(getJobKey(request, id)); | ||
| 349 | if (!job) { | ||
| 350 | return response.status(404).send({ expired: false }); | ||
| 351 | } | ||
| 352 | console.info(`[Keepalive] Paused job ${id} while foreground inference is running`); | ||
| 353 | return response.send({ ok: true, nextRun: job.nextRun, lastRun: job.lastRun, leaseMs: FRONTEND_LEASE_MS }); | ||
| 354 | } catch (error) { | ||
| 355 | return response.status(400).send({ error: String(error.message || error) }); | ||
| 356 | } | ||
| 357 | }); | ||
| 358 | |||
| 359 | router.post('/resume', (request, response) => { | ||
| 360 | try { | ||
| 361 | const id = getJobId(request); | ||
| 362 | const job = scheduler.resume(getJobKey(request, id), request.body?.activityElapsedMs); | ||
| 363 | if (!job) { | ||
| 364 | const expiredJob = scheduler.getExpired(getJobKey(request, id)); | ||
| 365 | return response.status(404).send({ | ||
| 366 | expired: !!expiredJob, | ||
| 367 | lastRun: expiredJob?.lastRun ?? null, | ||
| 368 | }); | ||
| 369 | } | ||
| 370 | const reset = request.body?.activityElapsedMs !== null && request.body?.activityElapsedMs !== undefined; | ||
| 371 | console.info(`[Keepalive] Resumed job ${id}; deadline ${reset ? 'reset by successful foreground inference' : 'preserved after unsuccessful foreground inference'}`); | ||
| 372 | return response.send({ ok: true, nextRun: job.nextRun, lastRun: job.lastRun, leaseMs: FRONTEND_LEASE_MS }); | ||
| 373 | } catch (error) { | ||
| 374 | return response.status(400).send({ error: String(error.message || error) }); | ||
| 375 | } | ||
| 376 | }); | ||
| 377 | |||
| 304 | router.post('/stop', (request, response) => { | 378 | router.post('/stop', (request, response) => { |
| 305 | try { | 379 | try { |
| 306 | const id = getJobId(request); | 380 | const id = getJobId(request); |
| @@ -63,14 +63,62 @@ describe('KeepaliveScheduler', () => { | |||
| 63 | expect(scheduler.get('user:tab').lastRun).toBe(190000); | 63 | expect(scheduler.get('user:tab').lastRun).toBe(190000); |
| 64 | }); | 64 | }); |
| 65 | 65 | ||
| 66 | test('activity resets the next keepalive deadline', () => { | 66 | test('re-registering refreshes payload without postponing the deadline', () => { |
| 67 | const { scheduler, setNow } = createScheduler(); | ||
| 68 | scheduler.register('user:tab', { id: 'tab', payload: { version: 1 }, intervalMs: 60000, delayMs: 60000 }); | ||
| 69 | |||
| 70 | setNow(50000); | ||
| 71 | scheduler.register('user:tab', { id: 'tab', payload: { version: 2 }, intervalMs: 60000, delayMs: 60000 }); | ||
| 72 | |||
| 73 | expect(scheduler.get('user:tab').nextRun).toBe(60000); | ||
| 74 | expect(scheduler.get('user:tab').payload).toEqual({ version: 2 }); | ||
| 75 | }); | ||
| 76 | |||
| 77 | test('pauses a due keepalive while foreground inference is running', async () => { | ||
| 78 | const { scheduler, dispatch, setNow } = createScheduler(); | ||
| 79 | scheduler.register('user:tab', { id: 'tab', intervalMs: 60000, delayMs: 60000 }); | ||
| 80 | |||
| 81 | setNow(50000); | ||
| 82 | scheduler.pause('user:tab'); | ||
| 83 | setNow(70000); | ||
| 84 | await scheduler.tick(); | ||
| 85 | expect(dispatch).not.toHaveBeenCalled(); | ||
| 86 | |||
| 87 | scheduler.resume('user:tab'); | ||
| 88 | await scheduler.tick(); | ||
| 89 | expect(dispatch).toHaveBeenCalledTimes(1); | ||
| 90 | }); | ||
| 91 | |||
| 92 | test('only a successful foreground request advances the deadline', () => { | ||
| 67 | const { scheduler, setNow } = createScheduler(); | 93 | const { scheduler, setNow } = createScheduler(); |
| 68 | scheduler.register('user:tab', { id: 'tab', intervalMs: 60000, delayMs: 60000 }); | 94 | scheduler.register('user:tab', { id: 'tab', intervalMs: 60000, delayMs: 60000 }); |
| 69 | 95 | ||
| 70 | setNow(50000); | 96 | setNow(50000); |
| 71 | scheduler.heartbeat('user:tab', true); | 97 | scheduler.pause('user:tab'); |
| 98 | setNow(70000); | ||
| 99 | scheduler.resume('user:tab', 30000); | ||
| 100 | |||
| 101 | expect(scheduler.get('user:tab').nextRun).toBe(100000); | ||
| 102 | }); | ||
| 103 | |||
| 104 | test('a failed keepalive retries without advancing a full interval', async () => { | ||
| 105 | let now = 0; | ||
| 106 | const dispatch = jest.fn().mockRejectedValue(new Error('HTTP 502')); | ||
| 107 | const scheduler = new KeepaliveScheduler({ | ||
| 108 | now: () => now, | ||
| 109 | dispatch, | ||
| 110 | setTimer: () => ({ unref() { } }), | ||
| 111 | clearTimer: () => { }, | ||
| 112 | }); | ||
| 113 | const debug = jest.spyOn(console, 'debug').mockImplementation(() => { }); | ||
| 114 | scheduler.register('user:tab', { id: 'tab', intervalMs: 60000, delayMs: 60000 }); | ||
| 115 | |||
| 116 | now = 60000; | ||
| 117 | await scheduler.tick(); | ||
| 72 | 118 | ||
| 73 | expect(scheduler.get('user:tab').nextRun).toBe(110000); | 119 | expect(dispatch).toHaveBeenCalledTimes(1); |
| 120 | expect(scheduler.get('user:tab').nextRun).toBe(65000); | ||
| 121 | debug.mockRestore(); | ||
| 74 | }); | 122 | }); |
| 75 | 123 | ||
| 76 | test('removes a job after five minutes without a frontend heartbeat', async () => { | 124 | test('removes a job after five minutes without a frontend heartbeat', async () => { |