Tie keepalives to successful chat inference

1385bdd1ce3c8b3af2691f342db02a8735e56997

permissionBRICK <40219477+permissionBRICK@users.noreply.github.com>

6 files changed, +267 -65Ignore whitespace
public/script.js+32 -2
@@ -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 }
52665266
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 }
53355359
5360 await beginProviderRequest();
5336 streamingProcessor.generator = await sendStreamingRequest(type, generate_data, { jsonSchema });5361 streamingProcessor.generator = await sendStreamingRequest(type, generate_data, { jsonSchema });
53375362
5338 hideSwipeButtons();5363 hideSwipeButtons();
@@ -5350,6 +5375,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
53505375
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 }
53955424
@@ -5530,12 +5559,13 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
5530 * @param {Error|object} exception Error or response JSON5559 * @param {Error|object} exception Error or response JSON
5531 * @throws {Error|object} Re-throws the exception5560 * @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 message5563 // 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 }
55385567
5568 await finishProviderRequest(false);
5539 unblockGeneration(type);5569 unblockGeneration(type);
5540 console.log(exception);5570 console.log(exception);
5541 streamingProcessor = null;5571 streamingProcessor = null;
public/scripts/events.js+2 -0
@@ -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',
public/scripts/extensions/keepalive/index.js+90 -42
@@ -27,6 +27,7 @@ const KEEPALIVE_RESPONSE_LENGTH = 1;
27const HEARTBEAT_INTERVAL_MS = 20000;27const HEARTBEAT_INTERVAL_MS = 20000;
28const RETRY_INTERVAL_MS = 5000;28const RETRY_INTERVAL_MS = 5000;
29const SETTINGS_REFRESH_DELAY_MS = 300;29const SETTINGS_REFRESH_DELAY_MS = 300;
30const MAIN_CHAT_GENERATION_TYPES = new Set(['normal', 'regenerate', 'swipe', 'continue']);
3031
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.
32const KEEPALIVE_INJECT_ID = 'keepalive_ping';33const KEEPALIVE_INJECT_ID = 'keepalive_ping';
@@ -54,15 +55,16 @@ let generationRefreshTimer = null;
54let retryTimer = null;55let retryTimer = null;
55let preparingJob = false;56let preparingJob = false;
56let lifecycleVersion = 0;57let lifecycleVersion = 0;
57let foregroundGenerationActive = false;
58let temporaryConnectionDepth = 0;58let temporaryConnectionDepth = 0;
59let refreshAfterTemporaryConnection = false;59let refreshAfterTemporaryConnection = false;
60const activeMainRequests = new Map();
61let latestSuccessfulRequestStartedAt = 0;
60// Whether keepalive is "armed" for the CURRENT chat. It stays dormant until the user sends a new62// 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 not63// 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.
63let armed = false;65let 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.
66const lastActivity = {};68const lastActivity = {};
67// Last successful backend keepalive, keyed by connection profile. The backend returns this after a69// 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}
149151
150async function sendBackendHeartbeat(activity = false) {152async 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}
186188
187function 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 */
198function onMessageSent() {194function onMessageSent() {
199 armed = true;195 armed = true;
200 markActivity();196 queueJobRefresh();
201}197}
202198
203/**199/**
@@ -206,6 +202,8 @@ function onMessageSent() {
206 */202 */
207function onChatChanged() {203function onChatChanged() {
208 armed = false;204 armed = false;
205 activeMainRequests.clear();
206 latestSuccessfulRequestStartedAt = 0;
209 void stopBackendJob();207 void stopBackendJob();
210}208}
211209
@@ -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}
508513
509function onGenerationStarted(type, _options, dryRun) {514async function pauseBackendJob() {
510 // Quiet/background generations (image prompts, summaries, helpers, etc.) use a different model515 if (!jobRegistered) {
511 // or context and do not refresh the foreground text-chat cache.
512 if (dryRun || type === 'quiet') {
513 return;516 return;
514 }517 }
515 // Regenerates, swipes, and continues send the current prompt to the provider without emitting518 try {
516 // MESSAGE_SENT. They are just as valid proof of a warm prompt as a newly typed message.519 const response = await postKeepaliveBackend('pause', {});
517 foregroundGenerationActive = true;520 if (response.status === 404) {
518 armed = true;521 jobRegistered = false;
519 markActivity();522 }
523 } catch (error) {
524 console.debug('[Keepalive] Could not pause backend job:', error);
525 }
520}526}
521527
522function onGenerationEnded() {528async function resumeBackendJob(activityStartedAt = null) {
523 if (!foregroundGenerationActive) {529 if (!jobRegistered) {
524 return;530 return;
525 }531 }
526 foregroundGenerationActive = false;532 const elapsedMs = Number.isFinite(activityStartedAt)
527 markActivity();533 ? Math.max(0, Date.now() - activityStartedAt)
528 scheduleGenerationJobRefresh();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
550async function onGenerationRequestStarted(request) {
551 if (temporaryConnectionDepth > 0 || !request?.id || !MAIN_CHAT_GENERATION_TYPES.has(request.type)) {
552 return;
553 }
554 armed = true;
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 }
529}562}
530563
531function onMessageReceived(_messageId, type) {564async function onGenerationRequestFinished(request) {
532 // Image-generation extensions add their results as chat messages. Those messages neither used565 const activeRequest = activeMainRequests.get(request?.id);
533 // nor refreshed the foreground text cache, so they must not move its keepalive deadline.566 if (!activeRequest) {
534 if (type === 'extension') {567 return;
568 }
569 activeMainRequests.delete(request.id);
570
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;
576 }
577
578 if (activeMainRequests.size > 0) {
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}
541591
@@ -580,17 +630,15 @@ async function init() {
580 setupListeners();630 setupListeners();
581 setupHeartbeatLoop();631 setupHeartbeatLoop();
582632
583 // A new user message arms keepalive before generation starts. Foreground generations that do633 // 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);
586636
587 // Only actual text-provider generations reset the cache deadline. Local chat edits still637 // 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 that638 // 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);
public/scripts/extensions/keepalive/manifest.json+1 -1
@@ -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"
src/endpoints/keepalive.js+91 -17
@@ -7,6 +7,7 @@ export const router = express.Router();
7export const FRONTEND_LEASE_MS = 5 * 60 * 1000;7export const FRONTEND_LEASE_MS = 5 * 60 * 1000;
8const MIN_INTERVAL_MS = 60 * 1000;8const MIN_INTERVAL_MS = 60 * 1000;
9const MAX_INTERVAL_MS = 24 * 60 * 60 * 1000;9const MAX_INTERVAL_MS = 24 * 60 * 60 * 1000;
10const FAILED_REQUEST_RETRY_MS = 5 * 1000;
10const EXPIRED_JOB_RETENTION_MS = MAX_INTERVAL_MS;11const EXPIRED_JOB_RETENTION_MS = MAX_INTERVAL_MS;
11const MAX_EXPIRED_JOBS = 1000;12const MAX_EXPIRED_JOBS = 1000;
12const MAX_JOB_ID_LENGTH = 128;13const 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;
63 this.#jobs.set(key, {65 const requestedNextRun = now + delayMs;
64 ...job,66 if (existing) {
65 intervalMs,67 // Re-registering updates the replay payload, but cannot claim that the provider cache
66 lastSeen: now,68 // was refreshed. Mutate the existing object so an in-flight dispatch can still clear
67 nextRun: now + delayMs,69 // its state, while preserving an earlier deadline and any foreground-request pause.
68 inFlight: false,70 Object.assign(existing, job, {
69 lastRun: null,71 intervalMs,
70 });72 lastSeen: now,
73 nextRun: Math.min(existing.nextRun, requestedNextRun),
74 });
75 } else {
76 this.#jobs.set(key, {
77 ...job,
78 intervalMs,
79 lastSeen: now,
80 nextRun: requestedNextRun,
81 inFlight: false,
82 paused: false,
83 lastRun: null,
84 });
85 }
71 this.#schedule();86 this.#schedule();
72 return this.#jobs.get(key);87 return this.#jobs.get(key);
73 }88 }
7489
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 }
96108
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 }
123162 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 {
176216
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});
303344
345router.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
359router.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
304router.post('/stop', (request, response) => {378router.post('/stop', (request, response) => {
305 try {379 try {
306 const id = getJobId(request);380 const id = getJobId(request);
tests/keepalive-scheduler.test.js+51 -3
@@ -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 });
6565
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 });
6995
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 });
72103
73 expect(scheduler.get('user:tab').nextRun).toBe(110000);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();
118
119 expect(dispatch).toHaveBeenCalledTimes(1);
120 expect(scheduler.get('user:tab').nextRun).toBe(65000);
121 debug.mockRestore();
74 });122 });
75123
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 () => {