Keep expired keepalives dormant on wake
| @@ -37,11 +37,6 @@ const LEGACY_DEFAULT_MESSAGE = 'Keep the cache warm.'; | ||
| 37 | 37 | // Idle-tracking key used when no connection profile is active (manual API panel). |
| 38 | 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 | 40 | const defaultSettings = { |
| 46 | 41 | enabled: false, |
| 47 | 42 | timeoutSeconds: 295, |
| @@ -66,6 +61,9 @@ let armed = false; | ||
| 66 | 61 | // Per-profile "time since last message", keyed by connection profile id (or NONE_KEY). |
| 67 | 62 | // In-memory only: on reload every profile is treated as freshly active. |
| 68 | 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 | 68 | // Per-document ids keep duplicated tabs independent. Jobs orphaned by a closed or reloaded |
| 71 | 69 | // document are deliberately cleaned up by the backend lease. |
| @@ -101,6 +99,32 @@ function getTimeoutMs() { | ||
| 101 | 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 | 128 | async function postKeepaliveBackend(path, body) { |
| 105 | 129 | return fetch(`/api/keepalive/${path}`, { |
| 106 | 130 | method: 'POST', |
| @@ -128,13 +152,28 @@ async function sendBackendHeartbeat(activity = false) { | ||
| 128 | 152 | // was missed (or registration failed), the regular heartbeat must self-heal instead of doing |
| 129 | 153 | // nothing forever. |
| 130 | 154 | if (!jobRegistered) { |
| 155 | + if (isWarmPromptExpired()) { | |
| 156 | + disarmExpiredJob(); | |
| 157 | + return; | |
| 158 | + } | |
| 131 | 159 | queueJobRefresh(); |
| 132 | 160 | return; |
| 133 | 161 | } |
| 162 | + const version = lifecycleVersion; | |
| 163 | + const profileKey = getActiveProfileKey(); | |
| 134 | 164 | try { |
| 135 | 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 | 171 | if (response.status === 404) { |
| 137 | 172 | jobRegistered = false; |
| 173 | + if (isWarmPromptExpired(profileKey)) { | |
| 174 | + disarmExpiredJob(); | |
| 175 | + return; | |
| 176 | + } | |
| 138 | 177 | queueJobRefresh(); |
| 139 | 178 | } |
| 140 | 179 | } catch (error) { |
| @@ -261,6 +300,11 @@ async function registerBackendJob() { | ||
| 261 | 300 | await stopBackendJob(); |
| 262 | 301 | return; |
| 263 | 302 | } |
| 303 | + const profileKey = getActiveProfileKey(); | |
| 304 | + if (isWarmPromptExpired(profileKey)) { | |
| 305 | + disarmExpiredJob(); | |
| 306 | + return; | |
| 307 | + } | |
| 264 | 308 | const version = lifecycleVersion; |
| 265 | 309 | try { |
| 266 | 310 | if (isGenerating()) { |
| @@ -271,13 +315,16 @@ async function registerBackendJob() { | ||
| 271 | 315 | return; |
| 272 | 316 | } |
| 273 | 317 | const timeoutMs = getTimeoutMs(); |
| 274 | 318 | const last = lastActivity[getActiveProfileKeygetLastWarmTime(profileKey)] ??|| Date.now(); |
| 275 | 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 | 324 | const response = await postKeepaliveBackend('register', { |
| 278 | 325 | ...request, |
| 279 | 326 | intervalMs: timeoutMs, |
| 280 | 327 | delayMs: remaining, |
| 281 | 328 | }); |
| 282 | 329 | if (!response.ok) { |
| 283 | 330 | throw new Error(`HTTP ${response.status}`); |
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | "js": "index.js", |
| 7 | 7 | "css": "", |
| 8 | 8 | "author": "SillyTavern", |
| 9 | 9 | "version": "1.1.23", |
| 10 | 10 | "homePage": "https://github.com/SillyTavern/SillyTavern", |
| 11 | 11 | "hooks": { |
| 12 | 12 | "activate": "init" |
| @@ -16,7 +16,7 @@ | ||
| 16 | 16 | <label for="keepalive_message" data-i18n="ext_keepalive_message">Keepalive message</label> |
| 17 | 17 | <input id="keepalive_message" class="text_pole" type="text" /> |
| 18 | 18 | |
| 19 | 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 theWaking profilea you'retab currentlyafter usingboth isits pinged.lease Keepaliveand stayscache offwindow untilhave youexpired senddoes anot messagerestart orkeepalives startuntil a foreground generationnew (includingmessage, regenerate, swipe, or continue);. merelyOnly 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 | 21 | <hr> |
| 22 | 22 | |
| @@ -7,6 +7,8 @@ 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 EXPIRED_JOB_RETENTION_MS = MAX_INTERVAL_MS; | |
| 11 | +const MAX_EXPIRED_JOBS = 1000; | |
| 10 | 12 | const MAX_JOB_ID_LENGTH = 128; |
| 11 | 13 | const ALLOWED_ENDPOINTS = new Set([ |
| 12 | 14 | '/api/backends/chat-completions/generate', |
| @@ -30,6 +32,7 @@ function advanceDeadline(deadline, interval, now) { | ||
| 30 | 32 | */ |
| 31 | 33 | export class KeepaliveScheduler { |
| 32 | 34 | #jobs = new Map(); |
| 35 | + #expiredJobs = new Map(); | |
| 33 | 36 | #timer = null; |
| 34 | 37 | #now; |
| 35 | 38 | #dispatch; |
| @@ -50,6 +53,8 @@ export class KeepaliveScheduler { | ||
| 50 | 53 | |
| 51 | 54 | register(key, job) { |
| 52 | 55 | const now = this.#now(); |
| 56 | + this.#pruneExpiredJobs(now); | |
| 57 | + this.#expiredJobs.delete(key); | |
| 53 | 58 | const intervalMs = Math.min(MAX_INTERVAL_MS, Math.max(MIN_INTERVAL_MS, Number(job.intervalMs) || MIN_INTERVAL_MS)); |
| 54 | 59 | const requestedDelay = Number(job.delayMs); |
| 55 | 60 | const delayMs = Number.isFinite(requestedDelay) |
| @@ -61,6 +66,7 @@ export class KeepaliveScheduler { | ||
| 61 | 66 | lastSeen: now, |
| 62 | 67 | nextRun: now + delayMs, |
| 63 | 68 | inFlight: false, |
| 69 | + lastRun: null, | |
| 64 | 70 | }); |
| 65 | 71 | this.#schedule(); |
| 66 | 72 | return this.#jobs.get(key); |
| @@ -73,7 +79,7 @@ export class KeepaliveScheduler { | ||
| 73 | 79 | } |
| 74 | 80 | const now = this.#now(); |
| 75 | 81 | if ((now - job.lastSeen) >= FRONTEND_LEASE_MS) { |
| 76 | 82 | this.#jobs.deleteexpire(key, job, now); |
| 77 | 83 | this.#schedule(); |
| 78 | 84 | return null; |
| 79 | 85 | } |
| @@ -90,6 +96,7 @@ export class KeepaliveScheduler { | ||
| 90 | 96 | |
| 91 | 97 | stop(key) { |
| 92 | 98 | const removed = this.#jobs.delete(key); |
| 99 | + this.#expiredJobs.delete(key); | |
| 93 | 100 | this.#schedule(); |
| 94 | 101 | return removed; |
| 95 | 102 | } |
| @@ -98,11 +105,16 @@ export class KeepaliveScheduler { | ||
| 98 | 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 | 113 | async tick(now = this.#now()) { |
| 102 | 114 | const pending = []; |
| 103 | 115 | for (const [key, job] of this.#jobs) { |
| 104 | 116 | if ((now - job.lastSeen) >= FRONTEND_LEASE_MS) { |
| 105 | 117 | this.#jobs.deleteexpire(key, job, now); |
| 106 | 118 | continue; |
| 107 | 119 | } |
| 108 | 120 | if (job.nextRun > now) { |
| @@ -114,7 +126,14 @@ export class KeepaliveScheduler { | ||
| 114 | 126 | continue; |
| 115 | 127 | } |
| 116 | 128 | job.inFlight = true; |
| 117 | 129 | pending.push(Promise.resolve(this.#dispatch(job)).catchthen(error() => { |
| 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 | 137 | console.debug(`[Keepalive] Backend request failed for ${job.id}:`, error); |
| 119 | 138 | }).finally(() => { |
| 120 | 139 | job.inFlight = false; |
| @@ -125,6 +144,27 @@ export class KeepaliveScheduler { | ||
| 125 | 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 | 168 | #schedule() { |
| 129 | 169 | if (this.#timer) { |
| 130 | 170 | this.#clearTimer(this.#timer); |
| @@ -246,11 +286,16 @@ router.post('/register', (request, response) => { | ||
| 246 | 286 | router.post('/heartbeat', (request, response) => { |
| 247 | 287 | try { |
| 248 | 288 | const id = getJobId(request); |
| 249 | 289 | const jobkey = scheduler.heartbeat(getJobKey(request, id), !!request.body?.activity, getReplayRequest(request)); |
| 290 | + const job = scheduler.heartbeat(key, !!request.body?.activity, getReplayRequest(request)); | |
| 250 | 291 | if (!job) { |
| 251 | 292 | returnconst responseexpiredJob = scheduler.sendStatusgetExpired(404key); |
| 293 | + return response.status(404).send({ | |
| 294 | + expired: !!expiredJob, | |
| 295 | + lastRun: expiredJob?.lastRun ?? null, | |
| 296 | + }); | |
| 252 | 297 | } |
| 253 | 298 | return response.send({ ok: true, nextRun: job.nextRun, lastRun: job.lastRun, leaseMs: FRONTEND_LEASE_MS }); |
| 254 | 299 | } catch (error) { |
| 255 | 300 | return response.status(400).send({ error: String(error.message || error) }); |
| 256 | 301 | } |
| @@ -33,6 +33,11 @@ describe('KeepaliveScheduler', () => { | ||
| 33 | 33 | |
| 34 | 34 | expect(scheduler.heartbeat('user:tab')).toBeNull(); |
| 35 | 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 | 43 | test('accepts an immediate first deadline', () => { |
| @@ -55,6 +60,7 @@ describe('KeepaliveScheduler', () => { | ||
| 55 | 60 | await scheduler.tick(); |
| 56 | 61 | expect(dispatch).toHaveBeenCalledTimes(2); |
| 57 | 62 | expect(scheduler.get('user:tab').nextRun).toBe(240000); |
| 63 | + expect(scheduler.get('user:tab').lastRun).toBe(190000); | |
| 58 | 64 | }); |
| 59 | 65 | |
| 60 | 66 | test('activity resets the next keepalive deadline', () => { |
| @@ -77,4 +83,20 @@ describe('KeepaliveScheduler', () => { | ||
| 77 | 83 | expect(scheduler.get('user:tab')).toBeNull(); |
| 78 | 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 | }); |