Tie keepalives to successful chat inference
| @@ -5264,6 +5264,30 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro | ||
| 5264 | 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 | 5292 | * Saves itemized prompt bits and calls streaming or non-streaming generation API. |
| 5269 | 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 | 5357 | streamingProcessor.firstMessageText = ''; |
| 5334 | 5358 | } |
| 5335 | 5359 | |
| 5360 | + await beginProviderRequest(); | |
| 5336 | 5361 | streamingProcessor.generator = await sendStreamingRequest(type, generate_data, { jsonSchema }); |
| 5337 | 5362 | |
| 5338 | 5363 | hideSwipeButtons(); |
| @@ -5350,6 +5375,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro | ||
| 5350 | 5375 | |
| 5351 | 5376 | const isStreamFinished = streamingProcessor && !streamingProcessor.isStopped && streamingProcessor.isFinished; |
| 5352 | 5377 | const isStreamWithToolCalls = streamingProcessor && Array.isArray(streamingProcessor.toolCalls) && streamingProcessor.toolCalls.length; |
| 5378 | + await finishProviderRequest(isStreamFinished); | |
| 5353 | 5379 | if (canPerformToolCalls && isStreamFinished && isStreamWithToolCalls) { |
| 5354 | 5380 | const lastMessage = chat[chat.length - 1]; |
| 5355 | 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 | 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 | 5559 | * @param {Error|object} exception Error or response JSON |
| 5531 | 5560 | * @throws {Error|object} Re-throws the exception |
| 5532 | 5561 | */ |
| 5533 | 5562 | async function onError(exception) { |
| 5534 | 5563 | // if the response JSON was thrown (novel|textgenerationwebui|kobold), show the error message |
| 5535 | 5564 | if (typeof exception?.error?.message === 'string') { |
| 5536 | 5565 | toastr.error(exception.error.message, t`Text generation error`, { timeOut: 10000, extendedTimeOut: 20000 }); |
| 5537 | 5566 | } |
| 5538 | 5567 | |
| 5568 | + await finishProviderRequest(false); | |
| 5539 | 5569 | unblockGeneration(type); |
| 5540 | 5570 | console.log(exception); |
| 5541 | 5571 | streamingProcessor = null; |
| @@ -23,6 +23,8 @@ export const event_types = { | ||
| 23 | 23 | GENERATION_STARTED: 'generation_started', |
| 24 | 24 | GENERATION_STOPPED: 'generation_stopped', |
| 25 | 25 | GENERATION_ENDED: 'generation_ended', |
| 26 | + GENERATION_REQUEST_STARTED: 'generation_request_started', | |
| 27 | + GENERATION_REQUEST_FINISHED: 'generation_request_finished', | |
| 26 | 28 | SD_PROMPT_PROCESSING: 'sd_prompt_processing', |
| 27 | 29 | EXTENSIONS_FIRST_LOAD: 'extensions_first_load', |
| 28 | 30 | EXTENSION_SETTINGS_LOADED: 'extension_settings_loaded', |
| @@ -27,6 +27,7 @@ const KEEPALIVE_RESPONSE_LENGTH = 1; | ||
| 27 | 27 | const HEARTBEAT_INTERVAL_MS = 20000; |
| 28 | 28 | const RETRY_INTERVAL_MS = 5000; |
| 29 | 29 | const SETTINGS_REFRESH_DELAY_MS = 300; |
| 30 | +const MAIN_CHAT_GENERATION_TYPES = new Set(['normal', 'regenerate', 'swipe', 'continue']); | |
| 30 | 31 | |
| 31 | 32 | // Extension-prompt key used to inject the keepalive message as a USER message at depth 0. |
| 32 | 33 | const KEEPALIVE_INJECT_ID = 'keepalive_ping'; |
| @@ -54,15 +55,16 @@ let generationRefreshTimer = null; | ||
| 54 | 55 | let retryTimer = null; |
| 55 | 56 | let preparingJob = false; |
| 56 | 57 | let lifecycleVersion = 0; |
| 57 | -let foregroundGenerationActive = false; | |
| 58 | 58 | let temporaryConnectionDepth = 0; |
| 59 | 59 | let refreshAfterTemporaryConnection = false; |
| 60 | +const activeMainRequests = new Map(); | |
| 61 | +let latestSuccessfulRequestStartedAt = 0; | |
| 60 | 62 | // Whether keepalive is "armed" for the CURRENT chat. It stays dormant until the user sends a new |
| 61 | 63 | // message or starts another foreground generation in the open chat. Merely loading a chat does not |
| 62 | 64 | // prove that its prompt is cached server-side. Reset on page reload and whenever the chat changes. |
| 63 | 65 | let armed = false; |
| 64 | 66 | // Per-profile "time sinceof lastthe message",last keyedverified bysuccessful connectionmain-chat profileinference idrequest, (orkeyed NONE_KEY).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 | 68 | const lastActivity = {}; |
| 67 | 69 | // Last successful backend keepalive, keyed by connection profile. The backend returns this after a |
| 68 | 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 | 152 | async function sendBackendHeartbeat(activity = false) { |
| 151 | 153 | if (temporaryConnectionDepth > 0 || !armed || !isKeepaliveEnabledForActive()) { |
| 152 | 154 | return; |
| 153 | 155 | } |
| @@ -165,7 +167,7 @@ async function sendBackendHeartbeat(activity = false) { | ||
| 165 | 167 | const version = lifecycleVersion; |
| 166 | 168 | const profileKey = getActiveProfileKey(); |
| 167 | 169 | try { |
| 168 | 170 | const response = await postKeepaliveBackend('heartbeat', { activity }); |
| 169 | 171 | const state = await response.json().catch(() => ({})); |
| 170 | 172 | if (version !== lifecycleVersion) { |
| 171 | 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 | 190 | * A new user message armsmay keepalivelead beforeto itsa foregroundmain-chat generationrequest, beginsbut 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 | 194 | function onMessageSent() { |
| 199 | 195 | armed = true; |
| 200 | 196 | markActivityqueueJobRefresh(); |
| 201 | 197 | } |
| 202 | 198 | |
| 203 | 199 | /** |
| @@ -206,6 +202,8 @@ function onMessageSent() { | ||
| 206 | 202 | */ |
| 207 | 203 | function onChatChanged() { |
| 208 | 204 | armed = false; |
| 205 | + activeMainRequests.clear(); | |
| 206 | + latestSuccessfulRequestStartedAt = 0; | |
| 209 | 207 | void stopBackendJob(); |
| 210 | 208 | } |
| 211 | 209 | |
| @@ -308,11 +306,15 @@ async function registerBackendJob() { | ||
| 308 | 306 | return; |
| 309 | 307 | } |
| 310 | 308 | const profileKey = getActiveProfileKey(); |
| 309 | + if (!getLastWarmTime(profileKey)) { | |
| 310 | + return; | |
| 311 | + } | |
| 311 | 312 | if (isWarmPromptExpired(profileKey)) { |
| 312 | 313 | disarmExpiredJob(); |
| 313 | 314 | return; |
| 314 | 315 | } |
| 315 | 316 | const version = lifecycleVersion; |
| 317 | + const hadRegisteredJob = jobRegistered; | |
| 316 | 318 | try { |
| 317 | 319 | if (isGenerating()) { |
| 318 | 320 | throw new Error('Waiting for the active generation to finish'); |
| @@ -343,7 +345,10 @@ async function registerBackendJob() { | ||
| 343 | 345 | clearTimeout(retryTimer); |
| 344 | 346 | jobRegistered = true; |
| 345 | 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 | 352 | console.debug('[Keepalive] Could not register backend job:', error); |
| 348 | 353 | clearTimeout(retryTimer); |
| 349 | 354 | retryTimer = setTimeout(queueJobRefresh, RETRY_INTERVAL_MS); |
| @@ -506,36 +511,81 @@ function setupListeners() { | ||
| 506 | 511 | }); |
| 507 | 512 | } |
| 508 | 513 | |
| 509 | 514 | async function onGenerationStartedpauseBackendJob(type, _options, dryRun) { |
| 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 | 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 | 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 | 564 | async function onGenerationEndedonGenerationRequestFinished(request) { |
| 523 | - if (!foregroundGenerationActive) { | |
| 565 | + const activeRequest = activeMainRequests.get(request?.id); | |
| 566 | + if (!activeRequest) { | |
| 524 | 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 | 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 | 589 | scheduleGenerationJobRefresh(); |
| 540 | 590 | } |
| 541 | 591 | |
| @@ -580,17 +630,15 @@ async function init() { | ||
| 580 | 630 | setupListeners(); |
| 581 | 631 | setupHeartbeatLoop(); |
| 582 | 632 | |
| 583 | 633 | // A new user message arms keepalive, beforebut generationonly starts.the Foregroundexact generationsprovider-request thatlifecycle dobelow is allowed |
| 584 | 634 | // not sendto amove newits messagedeadline. (regenerateRegenerate/swipe/continue) arerequests armedarm byit onGenerationStartedwhen insteadtheir request starts. |
| 585 | 635 | eventSource.on(event_types.MESSAGE_SENT, onMessageSent); |
| 586 | 636 | |
| 587 | 637 | // Only actual text-provider generations reset the cache deadline. Local chat edits still |
| 588 | 638 | // refresh the stored request so the server replays the latest prompt without pretending that |
| 589 | 639 | // those edits refreshed the provider cache. |
| 590 | 640 | eventSource.on(event_types.MESSAGE_RECEIVEDGENERATION_REQUEST_STARTED, onMessageReceivedonGenerationRequestStarted); |
| 591 | 641 | eventSource.on(event_types.GENERATION_STARTEDGENERATION_REQUEST_FINISHED, onGenerationStartedonGenerationRequestFinished); |
| 592 | - eventSource.on(event_types.GENERATION_ENDED, onGenerationEnded); | |
| 593 | - eventSource.on(event_types.GENERATION_STOPPED, () => { foregroundGenerationActive = false; }); | |
| 594 | 642 | eventSource.on(event_types.MESSAGE_EDITED, onChatContentChanged); |
| 595 | 643 | eventSource.on(event_types.MESSAGE_DELETED, onChatContentChanged); |
| 596 | 644 | eventSource.on(event_types.MESSAGE_UPDATED, onChatContentChanged); |
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | "js": "index.js", |
| 7 | 7 | "css": "", |
| 8 | 8 | "author": "SillyTavern", |
| 9 | 9 | "version": "1.1.56", |
| 10 | 10 | "homePage": "https://github.com/SillyTavern/SillyTavern", |
| 11 | 11 | "hooks": { |
| 12 | 12 | "activate": "init" |
| @@ -7,6 +7,7 @@ export const router = express.Router(); | ||
| 7 | 7 | export const FRONTEND_LEASE_MS = 5 * 60 * 1000; |
| 8 | 8 | const MIN_INTERVAL_MS = 60 * 1000; |
| 9 | 9 | const MAX_INTERVAL_MS = 24 * 60 * 60 * 1000; |
| 10 | +const FAILED_REQUEST_RETRY_MS = 5 * 1000; | |
| 10 | 11 | const EXPIRED_JOB_RETENTION_MS = MAX_INTERVAL_MS; |
| 11 | 12 | const MAX_EXPIRED_JOBS = 1000; |
| 12 | 13 | const MAX_JOB_ID_LENGTH = 128; |
| @@ -55,24 +56,38 @@ export class KeepaliveScheduler { | ||
| 55 | 56 | const now = this.#now(); |
| 56 | 57 | this.#pruneExpiredJobs(now); |
| 57 | 58 | this.#expiredJobs.delete(key); |
| 59 | + const existing = this.#jobs.get(key); | |
| 58 | 60 | const intervalMs = Math.min(MAX_INTERVAL_MS, Math.max(MIN_INTERVAL_MS, Number(job.intervalMs) || MIN_INTERVAL_MS)); |
| 59 | 61 | const requestedDelay = Number(job.delayMs); |
| 60 | 62 | const delayMs = Number.isFinite(requestedDelay) |
| 61 | 63 | ? Math.min(intervalMs, Math.max(0, requestedDelay)) |
| 62 | 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 | 76 | this.#jobs.set(key, { |
| 64 | 77 | ...job, |
| 65 | 78 | intervalMs, |
| 66 | 79 | lastSeen: now, |
| 67 | 80 | nextRun: now + delayMsrequestedNextRun, |
| 68 | 81 | inFlight: false, |
| 82 | + paused: false, | |
| 69 | 83 | lastRun: null, |
| 70 | 84 | }); |
| 85 | + } | |
| 71 | 86 | this.#schedule(); |
| 72 | 87 | return this.#jobs.get(key); |
| 73 | 88 | } |
| 74 | 89 | |
| 75 | 90 | heartbeat(key, activity = false, request = null) { |
| 76 | 91 | const job = this.#jobs.get(key); |
| 77 | 92 | if (!job) { |
| 78 | 93 | return null; |
| @@ -84,9 +99,6 @@ export class KeepaliveScheduler { | ||
| 84 | 99 | return null; |
| 85 | 100 | } |
| 86 | 101 | job.lastSeen = now; |
| 87 | - if (activity) { | |
| 88 | - job.nextRun = now + job.intervalMs; | |
| 89 | - } | |
| 90 | 102 | if (request) { |
| 91 | 103 | job.request = request; |
| 92 | 104 | } |
| @@ -94,6 +106,33 @@ export class KeepaliveScheduler { | ||
| 94 | 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 | 136 | stop(key) { |
| 98 | 137 | const removed = this.#jobs.delete(key); |
| 99 | 138 | this.#expiredJobs.delete(key); |
| @@ -120,21 +159,22 @@ export class KeepaliveScheduler { | ||
| 120 | 159 | if (job.nextRun > now) { |
| 121 | 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 | 163 | continue; |
| 127 | 164 | } |
| 165 | + const scheduledRun = job.nextRun; | |
| 128 | 166 | job.inFlight = true; |
| 129 | 167 | pending.push(Promise.resolve(this.#dispatch(job)).then(() => { |
| 130 | 168 | const completedAt = this.#now(); |
| 131 | 169 | job.lastRun = completedAt; |
| 170 | + job.nextRun = advanceDeadline(scheduledRun, job.intervalMs, completedAt); | |
| 132 | 171 | const expiredJob = this.#expiredJobs.get(key); |
| 133 | 172 | if (expiredJob) { |
| 134 | 173 | expiredJob.lastRun = completedAt; |
| 135 | 174 | } |
| 136 | 175 | }).catch(error => { |
| 137 | 176 | console.debug(`[Keepalive] Backend request failed for ${job.id}:`, error); |
| 177 | + job.nextRun = this.#now() + FAILED_REQUEST_RETRY_MS; | |
| 138 | 178 | }).finally(() => { |
| 139 | 179 | job.inFlight = false; |
| 140 | 180 | this.#schedule(); |
| @@ -176,7 +216,8 @@ export class KeepaliveScheduler { | ||
| 176 | 216 | |
| 177 | 217 | let nextWake = Infinity; |
| 178 | 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 | 222 | const delay = Math.max(0, nextWake - this.#now()); |
| 182 | 223 | this.#timer = this.#setTimer(() => { |
| @@ -287,7 +328,7 @@ router.post('/heartbeat', (request, response) => { | ||
| 287 | 328 | try { |
| 288 | 329 | const id = getJobId(request); |
| 289 | 330 | const key = getJobKey(request, id); |
| 290 | 331 | const job = scheduler.heartbeat(key, !!request.body?.activity, getReplayRequest(request)); |
| 291 | 332 | if (!job) { |
| 292 | 333 | const expiredJob = scheduler.getExpired(key); |
| 293 | 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 | 378 | router.post('/stop', (request, response) => { |
| 305 | 379 | try { |
| 306 | 380 | const id = getJobId(request); |
| @@ -63,14 +63,62 @@ describe('KeepaliveScheduler', () => { | ||
| 63 | 63 | expect(scheduler.get('user:tab').lastRun).toBe(190000); |
| 64 | 64 | }); |
| 65 | 65 | |
| 66 | 66 | test('activityre-registering resetsrefreshes thepayload nextwithout keepalivepostponing 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 | 93 | const { scheduler, setNow } = createScheduler(); |
| 68 | 94 | scheduler.register('user:tab', { id: 'tab', intervalMs: 60000, delayMs: 60000 }); |
| 69 | 95 | |
| 70 | 96 | setNow(50000); |
| 71 | 97 | scheduler.heartbeatpause('user:tab', true); |
| 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 | 124 | test('removes a job after five minutes without a frontend heartbeat', async () => { |