Keep expired keepalives dormant on wake

0aa62e9004c1517fe3801ca9fef82ecf60d6bc29

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

5 files changed, +130 -16Showing whitespace changes
public/scripts/extensions/keepalive/index.js+55 -8
@@ -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).
38const NONE_KEY = '__none__';38const NONE_KEY = '__none__';
3939
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.
43const EXPIRED_GRACE_MS = 3000;
44
45const defaultSettings = {40const 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.
68const lastActivity = {};63const 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.
66const lastKeepalive = {};
6967
70// Per-document ids keep duplicated tabs independent. Jobs orphaned by a closed or reloaded68// 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}
103101
102function 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
109function getLastWarmTime(profileKey = getActiveProfileKey()) {
110 return Math.max(lastActivity[profileKey] || 0, lastKeepalive[profileKey] || 0);
111}
112
113function isWarmPromptExpired(profileKey = getActiveProfileKey()) {
114 const lastWarmTime = getLastWarmTime(profileKey);
115 return lastWarmTime > 0 && (Date.now() - lastWarmTime) >= getTimeoutMs();
116}
117
118function disarmExpiredJob() {
119 armed = false;
120 jobRegistered = false;
121 refreshRequested = false;
122 lifecycleVersion++;
123 clearTimeout(retryTimer);
124 clearTimeout(refreshTimer);
125 clearTimeout(generationRefreshTimer);
126}
127
104async function postKeepaliveBackend(path, body) {128async 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 doing152 // 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}`);
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.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"
public/scripts/extensions/keepalive/settings.html+1 -1
@@ -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" />
1818
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>
2020
21 <hr>21 <hr>
2222
src/endpoints/keepalive.js+51 -6
@@ -7,6 +7,8 @@ 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 EXPIRED_JOB_RETENTION_MS = MAX_INTERVAL_MS;
11const MAX_EXPIRED_JOBS = 1000;
10const MAX_JOB_ID_LENGTH = 128;12const MAX_JOB_ID_LENGTH = 128;
11const ALLOWED_ENDPOINTS = new Set([13const 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 */
31export class KeepaliveScheduler {33export 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 {
5053
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 {
9096
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 }
100107
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 }
127146
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) => {
246router.post('/heartbeat', (request, response) => {286router.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 }
tests/keepalive-scheduler.test.js+22 -0
@@ -33,6 +33,11 @@ describe('KeepaliveScheduler', () => {
3333
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 });
3742
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 });
5965
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});