Keep expired keepalives dormant on wake
| @@ -37,11 +37,6 @@ const LEGACY_DEFAULT_MESSAGE = 'Keep the cache warm.'; | |||
| 37 | // Idle-tracking key used when no connection profile is active (manual API panel). | 37 | // Idle-tracking key used when no connection profile is active (manual API panel). |
| 38 | const NONE_KEY = '__none__'; | 38 | const NONE_KEY = '__none__'; |
| 39 | 39 | ||
| 40 | // When the active profile is already past its timeout (e.g. you just switched back | ||
| 41 | // to a profile that went cold), wait this short grace before pinging so an imminent | ||
| 42 | // user message can pre-empt the keepalive. | ||
| 43 | const EXPIRED_GRACE_MS = 3000; | ||
| 44 | |||
| 45 | const defaultSettings = { | 40 | const defaultSettings = { |
| 46 | enabled: false, | 41 | enabled: false, |
| 47 | timeoutSeconds: 295, | 42 | timeoutSeconds: 295, |
| @@ -66,6 +61,9 @@ let armed = false; | |||
| 66 | // Per-profile "time since last message", keyed by connection profile id (or NONE_KEY). | 61 | // Per-profile "time since last message", keyed by connection profile id (or NONE_KEY). |
| 67 | // In-memory only: on reload every profile is treated as freshly active. | 62 | // In-memory only: on reload every profile is treated as freshly active. |
| 68 | const lastActivity = {}; | 63 | const lastActivity = {}; |
| 64 | // Last successful backend keepalive, keyed by connection profile. The backend returns this after a | ||
| 65 | // sleeping tab wakes so we can distinguish a still-warm prompt from an expired one. | ||
| 66 | const lastKeepalive = {}; | ||
| 69 | 67 | ||
| 70 | // Per-document ids keep duplicated tabs independent. Jobs orphaned by a closed or reloaded | 68 | // Per-document ids keep duplicated tabs independent. Jobs orphaned by a closed or reloaded |
| 71 | // document are deliberately cleaned up by the backend lease. | 69 | // document are deliberately cleaned up by the backend lease. |
| @@ -101,6 +99,32 @@ function getTimeoutMs() { | |||
| 101 | return Math.max(60, Number(extension_settings[MODULE].timeoutSeconds) || 295) * 1000; | 99 | return Math.max(60, Number(extension_settings[MODULE].timeoutSeconds) || 295) * 1000; |
| 102 | } | 100 | } |
| 103 | 101 | ||
| 102 | function updateLastKeepalive(profileKey, value) { | ||
| 103 | const timestamp = Number(value); | ||
| 104 | if (Number.isFinite(timestamp) && timestamp > 0) { | ||
| 105 | lastKeepalive[profileKey] = Math.max(lastKeepalive[profileKey] || 0, timestamp); | ||
| 106 | } | ||
| 107 | } | ||
| 108 | |||
| 109 | function getLastWarmTime(profileKey = getActiveProfileKey()) { | ||
| 110 | return Math.max(lastActivity[profileKey] || 0, lastKeepalive[profileKey] || 0); | ||
| 111 | } | ||
| 112 | |||
| 113 | function isWarmPromptExpired(profileKey = getActiveProfileKey()) { | ||
| 114 | const lastWarmTime = getLastWarmTime(profileKey); | ||
| 115 | return lastWarmTime > 0 && (Date.now() - lastWarmTime) >= getTimeoutMs(); | ||
| 116 | } | ||
| 117 | |||
| 118 | function disarmExpiredJob() { | ||
| 119 | armed = false; | ||
| 120 | jobRegistered = false; | ||
| 121 | refreshRequested = false; | ||
| 122 | lifecycleVersion++; | ||
| 123 | clearTimeout(retryTimer); | ||
| 124 | clearTimeout(refreshTimer); | ||
| 125 | clearTimeout(generationRefreshTimer); | ||
| 126 | } | ||
| 127 | |||
| 104 | async function postKeepaliveBackend(path, body) { | 128 | async function postKeepaliveBackend(path, body) { |
| 105 | return fetch(`/api/keepalive/${path}`, { | 129 | return fetch(`/api/keepalive/${path}`, { |
| 106 | method: 'POST', | 130 | method: 'POST', |
| @@ -128,13 +152,28 @@ async function sendBackendHeartbeat(activity = false) { | |||
| 128 | // was missed (or registration failed), the regular heartbeat must self-heal instead of doing | 152 | // was missed (or registration failed), the regular heartbeat must self-heal instead of doing |
| 129 | // nothing forever. | 153 | // nothing forever. |
| 130 | if (!jobRegistered) { | 154 | if (!jobRegistered) { |
| 155 | if (isWarmPromptExpired()) { | ||
| 156 | disarmExpiredJob(); | ||
| 157 | return; | ||
| 158 | } | ||
| 131 | queueJobRefresh(); | 159 | queueJobRefresh(); |
| 132 | return; | 160 | return; |
| 133 | } | 161 | } |
| 162 | const version = lifecycleVersion; | ||
| 163 | const profileKey = getActiveProfileKey(); | ||
| 134 | try { | 164 | try { |
| 135 | const response = await postKeepaliveBackend('heartbeat', { activity }); | 165 | const response = await postKeepaliveBackend('heartbeat', { activity }); |
| 166 | const state = await response.json().catch(() => ({})); | ||
| 167 | if (version !== lifecycleVersion) { | ||
| 168 | return; | ||
| 169 | } | ||
| 170 | updateLastKeepalive(profileKey, state.lastRun); | ||
| 136 | if (response.status === 404) { | 171 | if (response.status === 404) { |
| 137 | jobRegistered = false; | 172 | jobRegistered = false; |
| 173 | if (isWarmPromptExpired(profileKey)) { | ||
| 174 | disarmExpiredJob(); | ||
| 175 | return; | ||
| 176 | } | ||
| 138 | queueJobRefresh(); | 177 | queueJobRefresh(); |
| 139 | } | 178 | } |
| 140 | } catch (error) { | 179 | } catch (error) { |
| @@ -261,6 +300,11 @@ async function registerBackendJob() { | |||
| 261 | await stopBackendJob(); | 300 | await stopBackendJob(); |
| 262 | return; | 301 | return; |
| 263 | } | 302 | } |
| 303 | const profileKey = getActiveProfileKey(); | ||
| 304 | if (isWarmPromptExpired(profileKey)) { | ||
| 305 | disarmExpiredJob(); | ||
| 306 | return; | ||
| 307 | } | ||
| 264 | const version = lifecycleVersion; | 308 | const version = lifecycleVersion; |
| 265 | try { | 309 | try { |
| 266 | if (isGenerating()) { | 310 | if (isGenerating()) { |
| @@ -271,13 +315,16 @@ async function registerBackendJob() { | |||
| 271 | return; | 315 | return; |
| 272 | } | 316 | } |
| 273 | const timeoutMs = getTimeoutMs(); | 317 | const timeoutMs = getTimeoutMs(); |
| 274 | const last = lastActivity[getActiveProfileKey()] ?? Date.now(); | 318 | const last = getLastWarmTime(profileKey) || Date.now(); |
| 275 | const remaining = timeoutMs - (Date.now() - last); | 319 | const remaining = timeoutMs - (Date.now() - last); |
| 276 | const delayMs = remaining > 0 ? remaining : EXPIRED_GRACE_MS; | 320 | if (remaining <= 0) { |
| 321 | disarmExpiredJob(); | ||
| 322 | return; | ||
| 323 | } | ||
| 277 | const response = await postKeepaliveBackend('register', { | 324 | const response = await postKeepaliveBackend('register', { |
| 278 | ...request, | 325 | ...request, |
| 279 | intervalMs: timeoutMs, | 326 | intervalMs: timeoutMs, |
| 280 | delayMs, | 327 | delayMs: remaining, |
| 281 | }); | 328 | }); |
| 282 | if (!response.ok) { | 329 | if (!response.ok) { |
| 283 | throw new Error(`HTTP ${response.status}`); | 330 | throw new Error(`HTTP ${response.status}`); |
| @@ -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.2", | 9 | "version": "1.1.3", |
| 10 | "homePage": "https://github.com/SillyTavern/SillyTavern", | 10 | "homePage": "https://github.com/SillyTavern/SillyTavern", |
| 11 | "hooks": { | 11 | "hooks": { |
| 12 | "activate": "init" | 12 | "activate": "init" |
| @@ -16,7 +16,7 @@ | |||
| 16 | <label for="keepalive_message" data-i18n="ext_keepalive_message">Keepalive message</label> | 16 | <label for="keepalive_message" data-i18n="ext_keepalive_message">Keepalive message</label> |
| 17 | <input id="keepalive_message" class="text_pole" type="text" /> | 17 | <input id="keepalive_message" class="text_pole" type="text" /> |
| 18 | 18 | ||
| 19 | <small data-i18n="ext_keepalive_help">After this many seconds without activity on the active connection profile, the current chat plus this message (sent as a hidden user message) is submitted to that profile to keep its server-side prompt cache warm. The completion is capped to a single token and is never saved to the chat. The server owns the keepalive timer, while this tab renews its lease every 20 seconds; if the tab sleeps or closes, the server continues on schedule for up to five minutes after the last heartbeat. Only the profile you're currently using is pinged. Keepalive stays off until you send a message or start a foreground generation (including regenerate, swipe, or continue); merely opening, switching to, or reloading a chat is not proof its prompt is cached.</small> | 19 | <small data-i18n="ext_keepalive_help">After this many seconds without activity on the active connection profile, the current chat plus this message (sent as a hidden user message) is submitted to that profile to keep its server-side prompt cache warm. The completion is capped to a single token and is never saved to the chat. The server owns the keepalive timer, while this tab renews its lease every 20 seconds; if the tab sleeps or closes, the server continues on schedule for up to five minutes after the last heartbeat. Waking a tab after both its lease and cache window have expired does not restart keepalives until a new message, regenerate, swipe, or continue. Only the profile you're currently using is pinged. Merely opening, switching to, or reloading a chat is not proof its prompt is cached.</small> |
| 20 | 20 | ||
| 21 | <hr> | 21 | <hr> |
| 22 | 22 | ||
| @@ -7,6 +7,8 @@ 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 EXPIRED_JOB_RETENTION_MS = MAX_INTERVAL_MS; | ||
| 11 | const MAX_EXPIRED_JOBS = 1000; | ||
| 10 | const MAX_JOB_ID_LENGTH = 128; | 12 | const MAX_JOB_ID_LENGTH = 128; |
| 11 | const ALLOWED_ENDPOINTS = new Set([ | 13 | const ALLOWED_ENDPOINTS = new Set([ |
| 12 | '/api/backends/chat-completions/generate', | 14 | '/api/backends/chat-completions/generate', |
| @@ -30,6 +32,7 @@ function advanceDeadline(deadline, interval, now) { | |||
| 30 | */ | 32 | */ |
| 31 | export class KeepaliveScheduler { | 33 | export class KeepaliveScheduler { |
| 32 | #jobs = new Map(); | 34 | #jobs = new Map(); |
| 35 | #expiredJobs = new Map(); | ||
| 33 | #timer = null; | 36 | #timer = null; |
| 34 | #now; | 37 | #now; |
| 35 | #dispatch; | 38 | #dispatch; |
| @@ -50,6 +53,8 @@ export class KeepaliveScheduler { | |||
| 50 | 53 | ||
| 51 | register(key, job) { | 54 | register(key, job) { |
| 52 | const now = this.#now(); | 55 | const now = this.#now(); |
| 56 | this.#pruneExpiredJobs(now); | ||
| 57 | this.#expiredJobs.delete(key); | ||
| 53 | const intervalMs = Math.min(MAX_INTERVAL_MS, Math.max(MIN_INTERVAL_MS, Number(job.intervalMs) || MIN_INTERVAL_MS)); | 58 | const intervalMs = Math.min(MAX_INTERVAL_MS, Math.max(MIN_INTERVAL_MS, Number(job.intervalMs) || MIN_INTERVAL_MS)); |
| 54 | const requestedDelay = Number(job.delayMs); | 59 | const requestedDelay = Number(job.delayMs); |
| 55 | const delayMs = Number.isFinite(requestedDelay) | 60 | const delayMs = Number.isFinite(requestedDelay) |
| @@ -61,6 +66,7 @@ export class KeepaliveScheduler { | |||
| 61 | lastSeen: now, | 66 | lastSeen: now, |
| 62 | nextRun: now + delayMs, | 67 | nextRun: now + delayMs, |
| 63 | inFlight: false, | 68 | inFlight: false, |
| 69 | lastRun: null, | ||
| 64 | }); | 70 | }); |
| 65 | this.#schedule(); | 71 | this.#schedule(); |
| 66 | return this.#jobs.get(key); | 72 | return this.#jobs.get(key); |
| @@ -73,7 +79,7 @@ export class KeepaliveScheduler { | |||
| 73 | } | 79 | } |
| 74 | const now = this.#now(); | 80 | const now = this.#now(); |
| 75 | if ((now - job.lastSeen) >= FRONTEND_LEASE_MS) { | 81 | if ((now - job.lastSeen) >= FRONTEND_LEASE_MS) { |
| 76 | this.#jobs.delete(key); | 82 | this.#expire(key, job, now); |
| 77 | this.#schedule(); | 83 | this.#schedule(); |
| 78 | return null; | 84 | return null; |
| 79 | } | 85 | } |
| @@ -90,6 +96,7 @@ export class KeepaliveScheduler { | |||
| 90 | 96 | ||
| 91 | stop(key) { | 97 | stop(key) { |
| 92 | const removed = this.#jobs.delete(key); | 98 | const removed = this.#jobs.delete(key); |
| 99 | this.#expiredJobs.delete(key); | ||
| 93 | this.#schedule(); | 100 | this.#schedule(); |
| 94 | return removed; | 101 | return removed; |
| 95 | } | 102 | } |
| @@ -98,11 +105,16 @@ export class KeepaliveScheduler { | |||
| 98 | return this.#jobs.get(key) ?? null; | 105 | return this.#jobs.get(key) ?? null; |
| 99 | } | 106 | } |
| 100 | 107 | ||
| 108 | getExpired(key) { | ||
| 109 | this.#pruneExpiredJobs(this.#now()); | ||
| 110 | return this.#expiredJobs.get(key) ?? null; | ||
| 111 | } | ||
| 112 | |||
| 101 | async tick(now = this.#now()) { | 113 | async tick(now = this.#now()) { |
| 102 | const pending = []; | 114 | const pending = []; |
| 103 | for (const [key, job] of this.#jobs) { | 115 | for (const [key, job] of this.#jobs) { |
| 104 | if ((now - job.lastSeen) >= FRONTEND_LEASE_MS) { | 116 | if ((now - job.lastSeen) >= FRONTEND_LEASE_MS) { |
| 105 | this.#jobs.delete(key); | 117 | this.#expire(key, job, now); |
| 106 | continue; | 118 | continue; |
| 107 | } | 119 | } |
| 108 | if (job.nextRun > now) { | 120 | if (job.nextRun > now) { |
| @@ -114,7 +126,14 @@ export class KeepaliveScheduler { | |||
| 114 | continue; | 126 | continue; |
| 115 | } | 127 | } |
| 116 | job.inFlight = true; | 128 | job.inFlight = true; |
| 117 | pending.push(Promise.resolve(this.#dispatch(job)).catch(error => { | 129 | pending.push(Promise.resolve(this.#dispatch(job)).then(() => { |
| 130 | const completedAt = this.#now(); | ||
| 131 | job.lastRun = completedAt; | ||
| 132 | const expiredJob = this.#expiredJobs.get(key); | ||
| 133 | if (expiredJob) { | ||
| 134 | expiredJob.lastRun = completedAt; | ||
| 135 | } | ||
| 136 | }).catch(error => { | ||
| 118 | console.debug(`[Keepalive] Backend request failed for ${job.id}:`, error); | 137 | console.debug(`[Keepalive] Backend request failed for ${job.id}:`, error); |
| 119 | }).finally(() => { | 138 | }).finally(() => { |
| 120 | job.inFlight = false; | 139 | job.inFlight = false; |
| @@ -125,6 +144,27 @@ export class KeepaliveScheduler { | |||
| 125 | await Promise.all(pending); | 144 | await Promise.all(pending); |
| 126 | } | 145 | } |
| 127 | 146 | ||
| 147 | #expire(key, job, now) { | ||
| 148 | this.#jobs.delete(key); | ||
| 149 | this.#expiredJobs.set(key, { | ||
| 150 | expiredAt: now, | ||
| 151 | intervalMs: job.intervalMs, | ||
| 152 | lastRun: job.lastRun, | ||
| 153 | }); | ||
| 154 | this.#pruneExpiredJobs(now); | ||
| 155 | } | ||
| 156 | |||
| 157 | #pruneExpiredJobs(now) { | ||
| 158 | for (const [key, job] of this.#expiredJobs) { | ||
| 159 | if ((now - job.expiredAt) >= EXPIRED_JOB_RETENTION_MS) { | ||
| 160 | this.#expiredJobs.delete(key); | ||
| 161 | } | ||
| 162 | } | ||
| 163 | while (this.#expiredJobs.size > MAX_EXPIRED_JOBS) { | ||
| 164 | this.#expiredJobs.delete(this.#expiredJobs.keys().next().value); | ||
| 165 | } | ||
| 166 | } | ||
| 167 | |||
| 128 | #schedule() { | 168 | #schedule() { |
| 129 | if (this.#timer) { | 169 | if (this.#timer) { |
| 130 | this.#clearTimer(this.#timer); | 170 | this.#clearTimer(this.#timer); |
| @@ -246,11 +286,16 @@ router.post('/register', (request, response) => { | |||
| 246 | router.post('/heartbeat', (request, response) => { | 286 | router.post('/heartbeat', (request, response) => { |
| 247 | try { | 287 | try { |
| 248 | const id = getJobId(request); | 288 | const id = getJobId(request); |
| 249 | const job = scheduler.heartbeat(getJobKey(request, id), !!request.body?.activity, getReplayRequest(request)); | 289 | const key = getJobKey(request, id); |
| 290 | const job = scheduler.heartbeat(key, !!request.body?.activity, getReplayRequest(request)); | ||
| 250 | if (!job) { | 291 | if (!job) { |
| 251 | return response.sendStatus(404); | 292 | const expiredJob = scheduler.getExpired(key); |
| 293 | return response.status(404).send({ | ||
| 294 | expired: !!expiredJob, | ||
| 295 | lastRun: expiredJob?.lastRun ?? null, | ||
| 296 | }); | ||
| 252 | } | 297 | } |
| 253 | return response.send({ ok: true, nextRun: job.nextRun, leaseMs: FRONTEND_LEASE_MS }); | 298 | return response.send({ ok: true, nextRun: job.nextRun, lastRun: job.lastRun, leaseMs: FRONTEND_LEASE_MS }); |
| 254 | } catch (error) { | 299 | } catch (error) { |
| 255 | return response.status(400).send({ error: String(error.message || error) }); | 300 | return response.status(400).send({ error: String(error.message || error) }); |
| 256 | } | 301 | } |
| @@ -33,6 +33,11 @@ describe('KeepaliveScheduler', () => { | |||
| 33 | 33 | ||
| 34 | expect(scheduler.heartbeat('user:tab')).toBeNull(); | 34 | expect(scheduler.heartbeat('user:tab')).toBeNull(); |
| 35 | expect(scheduler.get('user:tab')).toBeNull(); | 35 | expect(scheduler.get('user:tab')).toBeNull(); |
| 36 | expect(scheduler.getExpired('user:tab')).toEqual({ | ||
| 37 | expiredAt: FRONTEND_LEASE_MS, | ||
| 38 | intervalMs: 60000, | ||
| 39 | lastRun: null, | ||
| 40 | }); | ||
| 36 | }); | 41 | }); |
| 37 | 42 | ||
| 38 | test('accepts an immediate first deadline', () => { | 43 | test('accepts an immediate first deadline', () => { |
| @@ -55,6 +60,7 @@ describe('KeepaliveScheduler', () => { | |||
| 55 | await scheduler.tick(); | 60 | await scheduler.tick(); |
| 56 | expect(dispatch).toHaveBeenCalledTimes(2); | 61 | expect(dispatch).toHaveBeenCalledTimes(2); |
| 57 | expect(scheduler.get('user:tab').nextRun).toBe(240000); | 62 | expect(scheduler.get('user:tab').nextRun).toBe(240000); |
| 63 | expect(scheduler.get('user:tab').lastRun).toBe(190000); | ||
| 58 | }); | 64 | }); |
| 59 | 65 | ||
| 60 | test('activity resets the next keepalive deadline', () => { | 66 | test('activity resets the next keepalive deadline', () => { |
| @@ -77,4 +83,20 @@ describe('KeepaliveScheduler', () => { | |||
| 77 | expect(scheduler.get('user:tab')).toBeNull(); | 83 | expect(scheduler.get('user:tab')).toBeNull(); |
| 78 | expect(dispatch).not.toHaveBeenCalled(); | 84 | expect(dispatch).not.toHaveBeenCalled(); |
| 79 | }); | 85 | }); |
| 86 | |||
| 87 | test('retains the last successful keepalive after the frontend lease expires', async () => { | ||
| 88 | const { scheduler, setNow } = createScheduler(); | ||
| 89 | scheduler.register('user:tab', { id: 'tab', intervalMs: 60000, delayMs: 60000 }); | ||
| 90 | |||
| 91 | setNow(240000); | ||
| 92 | await scheduler.tick(); | ||
| 93 | setNow(FRONTEND_LEASE_MS); | ||
| 94 | await scheduler.tick(); | ||
| 95 | |||
| 96 | expect(scheduler.getExpired('user:tab')).toEqual({ | ||
| 97 | expiredAt: FRONTEND_LEASE_MS, | ||
| 98 | intervalMs: 60000, | ||
| 99 | lastRun: 240000, | ||
| 100 | }); | ||
| 101 | }); | ||
| 80 | }); | 102 | }); |