feat(session): add TTL/auto-cleanup for idle sessions (#3250)
- Add session.ttl config option (number or {idle, maxAge} object)
- Add session.cleanup.intervalSeconds for cleanup service interval
- Implement session-ttl.ts with TTL normalization and expiration checks
- Implement session-cleanup.ts with periodic cleanup service
- Add comprehensive unit tests (19 tests)
Closes #3250
This commit is contained in:
parent
d93f8ffc13
commit
1ebb792854
@ -76,6 +76,20 @@ export type SessionResetByTypeConfig = {
|
|||||||
thread?: SessionResetConfig;
|
thread?: SessionResetConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** TTL configuration for automatic session cleanup. */
|
||||||
|
export type SessionTtlConfig = {
|
||||||
|
/** Idle timeout in seconds. Sessions inactive for this duration are eligible for cleanup. */
|
||||||
|
idle?: number;
|
||||||
|
/** Maximum age in seconds. Sessions older than this are eligible for cleanup regardless of activity. */
|
||||||
|
maxAge?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Cleanup service configuration. */
|
||||||
|
export type SessionCleanupConfig = {
|
||||||
|
/** Interval between cleanup runs in seconds. Default: 300 (5 minutes). */
|
||||||
|
intervalSeconds?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type SessionConfig = {
|
export type SessionConfig = {
|
||||||
scope?: SessionScope;
|
scope?: SessionScope;
|
||||||
/** DM session scoping (default: "main"). */
|
/** DM session scoping (default: "main"). */
|
||||||
@ -97,6 +111,10 @@ export type SessionConfig = {
|
|||||||
/** Max ping-pong turns between requester/target (0–5). Default: 5. */
|
/** Max ping-pong turns between requester/target (0–5). Default: 5. */
|
||||||
maxPingPongTurns?: number;
|
maxPingPongTurns?: number;
|
||||||
};
|
};
|
||||||
|
/** TTL for automatic session cleanup. Can be a number (idle seconds) or an object with idle/maxAge. */
|
||||||
|
ttl?: number | SessionTtlConfig;
|
||||||
|
/** Configuration for the automatic cleanup service. */
|
||||||
|
cleanup?: SessionCleanupConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LoggingConfig = {
|
export type LoggingConfig = {
|
||||||
|
|||||||
@ -16,6 +16,22 @@ const SessionResetConfigSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
const SessionTtlSchema = z.union([
|
||||||
|
z.number().int().positive(), // Simple form: just seconds
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
idle: z.number().int().positive().optional(),
|
||||||
|
maxAge: z.number().int().positive().optional(),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SessionCleanupSchema = z
|
||||||
|
.object({
|
||||||
|
intervalSeconds: z.number().int().positive().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
export const SessionSchema = z
|
export const SessionSchema = z
|
||||||
.object({
|
.object({
|
||||||
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
|
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
|
||||||
@ -82,6 +98,8 @@ export const SessionSchema = z
|
|||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
ttl: SessionTtlSchema.optional(),
|
||||||
|
cleanup: SessionCleanupSchema.optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional();
|
.optional();
|
||||||
|
|||||||
129
src/gateway/session-cleanup.ts
Normal file
129
src/gateway/session-cleanup.ts
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Session cleanup service for automatic TTL-based session removal.
|
||||||
|
* @module session-cleanup
|
||||||
|
* @see https://github.com/moltbot/moltbot/issues/3250
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { loadConfig } from "../config/index.js";
|
||||||
|
import type { MoltbotConfig } from "../config/types.js";
|
||||||
|
import { log } from "../logging.js";
|
||||||
|
import { loadCombinedSessionStoreForGateway } from "./session-utils.js";
|
||||||
|
import {
|
||||||
|
normalizeSessionTtl,
|
||||||
|
getExpiredSessionKeys,
|
||||||
|
getCleanupConfig,
|
||||||
|
DEFAULT_CLEANUP_INTERVAL_SECONDS,
|
||||||
|
} from "./session-ttl.js";
|
||||||
|
|
||||||
|
let cleanupIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a single cleanup pass.
|
||||||
|
* @param cfg - Moltbot configuration
|
||||||
|
* @returns Number of sessions cleaned up
|
||||||
|
*/
|
||||||
|
export async function runSessionCleanup(cfg: MoltbotConfig): Promise<number> {
|
||||||
|
const sessionConfig = cfg.session;
|
||||||
|
if (!sessionConfig?.ttl) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttl = normalizeSessionTtl(sessionConfig.ttl);
|
||||||
|
if (!ttl) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { store } = loadCombinedSessionStoreForGateway(cfg);
|
||||||
|
const expiredKeys = getExpiredSessionKeys(store, ttl);
|
||||||
|
|
||||||
|
if (expiredKeys.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info({ count: expiredKeys.length }, "[session-cleanup] Found expired sessions");
|
||||||
|
|
||||||
|
// Delete expired sessions
|
||||||
|
// Note: We import dynamically to avoid circular dependencies
|
||||||
|
const { sessionsHandlers } = await import("./server-methods/sessions.js");
|
||||||
|
|
||||||
|
let cleaned = 0;
|
||||||
|
for (const key of expiredKeys) {
|
||||||
|
try {
|
||||||
|
// Create a mock respond function to track success
|
||||||
|
let success = false;
|
||||||
|
const respond = (ok: boolean) => {
|
||||||
|
success = ok;
|
||||||
|
};
|
||||||
|
|
||||||
|
await sessionsHandlers["sessions.delete"]({
|
||||||
|
params: { key },
|
||||||
|
respond: respond as never,
|
||||||
|
context: {} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
cleaned++;
|
||||||
|
log.debug({ key }, "[session-cleanup] Deleted expired session");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn({ key, err }, "[session-cleanup] Failed to delete session");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info({ cleaned, total: expiredKeys.length }, "[session-cleanup] Cleanup complete");
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the session cleanup service.
|
||||||
|
* @param cfg - Moltbot configuration
|
||||||
|
*/
|
||||||
|
export function startSessionCleanupService(cfg: MoltbotConfig): void {
|
||||||
|
if (cleanupIntervalId) {
|
||||||
|
log.debug("[session-cleanup] Service already running");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionConfig = cfg.session;
|
||||||
|
if (!sessionConfig?.ttl) {
|
||||||
|
log.debug("[session-cleanup] No TTL configured, skipping cleanup service");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanupConfig = getCleanupConfig(sessionConfig.cleanup);
|
||||||
|
const intervalMs = cleanupConfig.intervalSeconds * 1000;
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{ intervalSeconds: cleanupConfig.intervalSeconds },
|
||||||
|
"[session-cleanup] Starting cleanup service",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Run initial cleanup after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
runSessionCleanup(cfg).catch((err) => {
|
||||||
|
log.error({ err }, "[session-cleanup] Initial cleanup failed");
|
||||||
|
});
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// Schedule periodic cleanup
|
||||||
|
cleanupIntervalId = setInterval(() => {
|
||||||
|
// Reload config to pick up any changes
|
||||||
|
const currentCfg = loadConfig();
|
||||||
|
runSessionCleanup(currentCfg).catch((err) => {
|
||||||
|
log.error({ err }, "[session-cleanup] Periodic cleanup failed");
|
||||||
|
});
|
||||||
|
}, intervalMs);
|
||||||
|
|
||||||
|
log.info("[session-cleanup] Cleanup service started");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the session cleanup service.
|
||||||
|
*/
|
||||||
|
export function stopSessionCleanupService(): void {
|
||||||
|
if (cleanupIntervalId) {
|
||||||
|
clearInterval(cleanupIntervalId);
|
||||||
|
cleanupIntervalId = null;
|
||||||
|
log.info("[session-cleanup] Cleanup service stopped");
|
||||||
|
}
|
||||||
|
}
|
||||||
151
src/gateway/session-ttl.test.ts
Normal file
151
src/gateway/session-ttl.test.ts
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for session TTL utilities.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
normalizeSessionTtl,
|
||||||
|
isSessionExpired,
|
||||||
|
getExpiredSessionKeys,
|
||||||
|
getCleanupConfig,
|
||||||
|
DEFAULT_CLEANUP_INTERVAL_SECONDS,
|
||||||
|
} from "./session-ttl.js";
|
||||||
|
import type { SessionEntry } from "../config/sessions.js";
|
||||||
|
|
||||||
|
describe("session-ttl", () => {
|
||||||
|
describe("normalizeSessionTtl", () => {
|
||||||
|
it("returns undefined for undefined input", () => {
|
||||||
|
expect(normalizeSessionTtl(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts number to idle config", () => {
|
||||||
|
expect(normalizeSessionTtl(3600)).toEqual({ idle: 3600 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes through object form", () => {
|
||||||
|
const ttl = { idle: 3600, maxAge: 86400 };
|
||||||
|
expect(normalizeSessionTtl(ttl)).toEqual(ttl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for empty object", () => {
|
||||||
|
expect(normalizeSessionTtl({})).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles object with only idle", () => {
|
||||||
|
expect(normalizeSessionTtl({ idle: 1800 })).toEqual({ idle: 1800 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles object with only maxAge", () => {
|
||||||
|
expect(normalizeSessionTtl({ maxAge: 43200 })).toEqual({ maxAge: 43200 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isSessionExpired", () => {
|
||||||
|
const now = 1706400000000; // Fixed timestamp for testing
|
||||||
|
|
||||||
|
it("returns false when session is active within idle timeout", () => {
|
||||||
|
const entry: SessionEntry = {
|
||||||
|
sessionId: "test",
|
||||||
|
updatedAt: now - 1000 * 60 * 5, // 5 minutes ago
|
||||||
|
};
|
||||||
|
expect(isSessionExpired(entry, { idle: 3600 }, now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when session is idle beyond timeout", () => {
|
||||||
|
const entry: SessionEntry = {
|
||||||
|
sessionId: "test",
|
||||||
|
updatedAt: now - 1000 * 60 * 70, // 70 minutes ago
|
||||||
|
};
|
||||||
|
expect(isSessionExpired(entry, { idle: 3600 }, now)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when session is within maxAge", () => {
|
||||||
|
const entry: SessionEntry = {
|
||||||
|
sessionId: "test",
|
||||||
|
createdAt: now - 1000 * 60 * 60 * 12, // 12 hours ago
|
||||||
|
};
|
||||||
|
expect(isSessionExpired(entry, { maxAge: 86400 }, now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when session exceeds maxAge", () => {
|
||||||
|
const entry: SessionEntry = {
|
||||||
|
sessionId: "test",
|
||||||
|
createdAt: now - 1000 * 60 * 60 * 25, // 25 hours ago
|
||||||
|
};
|
||||||
|
expect(isSessionExpired(entry, { maxAge: 86400 }, now)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks both idle and maxAge", () => {
|
||||||
|
const entry: SessionEntry = {
|
||||||
|
sessionId: "test",
|
||||||
|
createdAt: now - 1000 * 60 * 60 * 20, // 20 hours ago
|
||||||
|
updatedAt: now - 1000 * 60 * 30, // 30 minutes ago (active)
|
||||||
|
};
|
||||||
|
// Active within idle but check maxAge too
|
||||||
|
expect(isSessionExpired(entry, { idle: 3600, maxAge: 86400 }, now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expires on either condition when both set", () => {
|
||||||
|
const entry: SessionEntry = {
|
||||||
|
sessionId: "test",
|
||||||
|
createdAt: now - 1000 * 60 * 60 * 25, // 25 hours ago (exceeds maxAge)
|
||||||
|
updatedAt: now - 1000 * 60 * 30, // 30 minutes ago (within idle)
|
||||||
|
};
|
||||||
|
expect(isSessionExpired(entry, { idle: 3600, maxAge: 86400 }, now)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when no timestamps available", () => {
|
||||||
|
const entry: SessionEntry = { sessionId: "test" };
|
||||||
|
expect(isSessionExpired(entry, { idle: 3600, maxAge: 86400 }, now)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getExpiredSessionKeys", () => {
|
||||||
|
const now = 1706400000000;
|
||||||
|
|
||||||
|
it("returns empty array for empty store", () => {
|
||||||
|
expect(getExpiredSessionKeys({}, { idle: 3600 }, now)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when no sessions expired", () => {
|
||||||
|
const store: Record<string, SessionEntry> = {
|
||||||
|
"session:a": { sessionId: "a", updatedAt: now - 1000 * 60 * 5 },
|
||||||
|
"session:b": { sessionId: "b", updatedAt: now - 1000 * 60 * 10 },
|
||||||
|
};
|
||||||
|
expect(getExpiredSessionKeys(store, { idle: 3600 }, now)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns only expired session keys", () => {
|
||||||
|
const store: Record<string, SessionEntry> = {
|
||||||
|
"session:active": { sessionId: "active", updatedAt: now - 1000 * 60 * 5 },
|
||||||
|
"session:expired": { sessionId: "expired", updatedAt: now - 1000 * 60 * 70 },
|
||||||
|
"session:old": { sessionId: "old", updatedAt: now - 1000 * 60 * 120 },
|
||||||
|
};
|
||||||
|
const expired = getExpiredSessionKeys(store, { idle: 3600 }, now);
|
||||||
|
expect(expired).toContain("session:expired");
|
||||||
|
expect(expired).toContain("session:old");
|
||||||
|
expect(expired).not.toContain("session:active");
|
||||||
|
expect(expired).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getCleanupConfig", () => {
|
||||||
|
it("returns default interval when undefined", () => {
|
||||||
|
expect(getCleanupConfig()).toEqual({
|
||||||
|
intervalSeconds: DEFAULT_CLEANUP_INTERVAL_SECONDS,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns custom interval when set", () => {
|
||||||
|
expect(getCleanupConfig({ intervalSeconds: 600 })).toEqual({
|
||||||
|
intervalSeconds: 600,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns default when empty object", () => {
|
||||||
|
expect(getCleanupConfig({})).toEqual({
|
||||||
|
intervalSeconds: DEFAULT_CLEANUP_INTERVAL_SECONDS,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
98
src/gateway/session-ttl.ts
Normal file
98
src/gateway/session-ttl.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Session TTL (Time-To-Live) utilities for automatic session cleanup.
|
||||||
|
* @module session-ttl
|
||||||
|
* @see https://github.com/moltbot/moltbot/issues/3250
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SessionEntry } from "../config/sessions.js";
|
||||||
|
import type { SessionTtlConfig, SessionCleanupConfig } from "../config/types.base.js";
|
||||||
|
|
||||||
|
/** Default cleanup interval in seconds (5 minutes). */
|
||||||
|
export const DEFAULT_CLEANUP_INTERVAL_SECONDS = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize TTL config from number or object form to object form.
|
||||||
|
* @param ttl - TTL config (number for idle seconds, or object with idle/maxAge)
|
||||||
|
* @returns Normalized TTL config object, or undefined if no TTL configured
|
||||||
|
*/
|
||||||
|
export function normalizeSessionTtl(
|
||||||
|
ttl: number | SessionTtlConfig | undefined,
|
||||||
|
): SessionTtlConfig | undefined {
|
||||||
|
if (ttl === undefined) return undefined;
|
||||||
|
if (typeof ttl === "number") {
|
||||||
|
return { idle: ttl };
|
||||||
|
}
|
||||||
|
// Validate at least one TTL option is set
|
||||||
|
if (ttl.idle === undefined && ttl.maxAge === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return ttl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a session is expired based on TTL configuration.
|
||||||
|
* @param entry - Session entry to check
|
||||||
|
* @param ttl - TTL configuration
|
||||||
|
* @param now - Current timestamp in ms (default: Date.now())
|
||||||
|
* @returns true if session is expired
|
||||||
|
*/
|
||||||
|
export function isSessionExpired(
|
||||||
|
entry: SessionEntry,
|
||||||
|
ttl: SessionTtlConfig,
|
||||||
|
now: number = Date.now(),
|
||||||
|
): boolean {
|
||||||
|
const updatedAt = entry.updatedAt;
|
||||||
|
const createdAt = entry.createdAt;
|
||||||
|
|
||||||
|
// Check idle timeout
|
||||||
|
if (ttl.idle !== undefined && updatedAt) {
|
||||||
|
const idleMs = ttl.idle * 1000;
|
||||||
|
if (now - updatedAt > idleMs) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check max age
|
||||||
|
if (ttl.maxAge !== undefined && createdAt) {
|
||||||
|
const maxAgeMs = ttl.maxAge * 1000;
|
||||||
|
if (now - createdAt > maxAgeMs) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of session keys that are expired.
|
||||||
|
* @param store - Session store (key -> entry map)
|
||||||
|
* @param ttl - TTL configuration
|
||||||
|
* @param now - Current timestamp in ms (default: Date.now())
|
||||||
|
* @returns Array of expired session keys
|
||||||
|
*/
|
||||||
|
export function getExpiredSessionKeys(
|
||||||
|
store: Record<string, SessionEntry>,
|
||||||
|
ttl: SessionTtlConfig,
|
||||||
|
now: number = Date.now(),
|
||||||
|
): string[] {
|
||||||
|
const expired: string[] = [];
|
||||||
|
|
||||||
|
for (const [key, entry] of Object.entries(store)) {
|
||||||
|
if (isSessionExpired(entry, ttl, now)) {
|
||||||
|
expired.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return expired;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cleanup configuration with defaults.
|
||||||
|
* @param config - Cleanup config from user
|
||||||
|
* @returns Cleanup config with defaults applied
|
||||||
|
*/
|
||||||
|
export function getCleanupConfig(config?: SessionCleanupConfig): Required<SessionCleanupConfig> {
|
||||||
|
return {
|
||||||
|
intervalSeconds: config?.intervalSeconds ?? DEFAULT_CLEANUP_INTERVAL_SECONDS,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user