feat: per-group queue, SQLite state, graceful shutdown
Add per-group container locking with global concurrency limit to prevent concurrent containers for the same group (#89) and cap total containers. Fix message batching bug where lastAgentTimestamp advanced to trigger message instead of latest in batch, causing redundant re-processing. Move router state, sessions, and registered groups from JSON files to SQLite with automatic one-time migration. Add SIGTERM/SIGINT handlers with graceful shutdown (SIGTERM -> grace period -> SIGKILL). Add startup recovery for messages missed during crash. Remove dead code: utils.ts, Session type, isScheduledTask flag, ContainerConfig.env, getTaskRunLogs, GroupQueue.isActive. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
206
src/db.ts
206
src/db.ts
@@ -4,8 +4,8 @@ import path from 'path';
|
||||
|
||||
import { proto } from '@whiskeysockets/baileys';
|
||||
|
||||
import { STORE_DIR } from './config.js';
|
||||
import { NewMessage, ScheduledTask, TaskRunLog } from './types.js';
|
||||
import { DATA_DIR, STORE_DIR } from './config.js';
|
||||
import { NewMessage, RegisteredGroup, ScheduledTask, TaskRunLog } from './types.js';
|
||||
|
||||
let db: Database.Database;
|
||||
|
||||
@@ -77,6 +77,29 @@ export function initDatabase(): void {
|
||||
} catch {
|
||||
/* column already exists */
|
||||
}
|
||||
|
||||
// State tables (replacing JSON files)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS router_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
group_folder TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS registered_groups (
|
||||
jid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL UNIQUE,
|
||||
trigger_pattern TEXT NOT NULL,
|
||||
added_at TEXT NOT NULL,
|
||||
container_config TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
// Migrate from JSON files if they exist
|
||||
migrateJsonState();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -381,16 +404,171 @@ export function logTaskRun(log: TaskRunLog): void {
|
||||
);
|
||||
}
|
||||
|
||||
export function getTaskRunLogs(taskId: string, limit = 10): TaskRunLog[] {
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT task_id, run_at, duration_ms, status, result, error
|
||||
FROM task_run_logs
|
||||
WHERE task_id = ?
|
||||
ORDER BY run_at DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(taskId, limit) as TaskRunLog[];
|
||||
// --- Router state accessors ---
|
||||
|
||||
export function getRouterState(key: string): string | undefined {
|
||||
const row = db
|
||||
.prepare('SELECT value FROM router_state WHERE key = ?')
|
||||
.get(key) as { value: string } | undefined;
|
||||
return row?.value;
|
||||
}
|
||||
|
||||
export function setRouterState(key: string, value: string): void {
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)',
|
||||
).run(key, value);
|
||||
}
|
||||
|
||||
// --- Session accessors ---
|
||||
|
||||
export function getSession(groupFolder: string): string | undefined {
|
||||
const row = db
|
||||
.prepare('SELECT session_id FROM sessions WHERE group_folder = ?')
|
||||
.get(groupFolder) as { session_id: string } | undefined;
|
||||
return row?.session_id;
|
||||
}
|
||||
|
||||
export function setSession(groupFolder: string, sessionId: string): void {
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO sessions (group_folder, session_id) VALUES (?, ?)',
|
||||
).run(groupFolder, sessionId);
|
||||
}
|
||||
|
||||
export function getAllSessions(): Record<string, string> {
|
||||
const rows = db
|
||||
.prepare('SELECT group_folder, session_id FROM sessions')
|
||||
.all() as Array<{ group_folder: string; session_id: string }>;
|
||||
const result: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
result[row.group_folder] = row.session_id;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Registered group accessors ---
|
||||
|
||||
export function getRegisteredGroup(
|
||||
jid: string,
|
||||
): (RegisteredGroup & { jid: string }) | undefined {
|
||||
const row = db
|
||||
.prepare('SELECT * FROM registered_groups WHERE jid = ?')
|
||||
.get(jid) as
|
||||
| {
|
||||
jid: string;
|
||||
name: string;
|
||||
folder: string;
|
||||
trigger_pattern: string;
|
||||
added_at: string;
|
||||
container_config: string | null;
|
||||
}
|
||||
| undefined;
|
||||
if (!row) return undefined;
|
||||
return {
|
||||
jid: row.jid,
|
||||
name: row.name,
|
||||
folder: row.folder,
|
||||
trigger: row.trigger_pattern,
|
||||
added_at: row.added_at,
|
||||
containerConfig: row.container_config
|
||||
? JSON.parse(row.container_config)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function setRegisteredGroup(
|
||||
jid: string,
|
||||
group: RegisteredGroup,
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
jid,
|
||||
group.name,
|
||||
group.folder,
|
||||
group.trigger,
|
||||
group.added_at,
|
||||
group.containerConfig ? JSON.stringify(group.containerConfig) : null,
|
||||
);
|
||||
}
|
||||
|
||||
export function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
|
||||
const rows = db
|
||||
.prepare('SELECT * FROM registered_groups')
|
||||
.all() as Array<{
|
||||
jid: string;
|
||||
name: string;
|
||||
folder: string;
|
||||
trigger_pattern: string;
|
||||
added_at: string;
|
||||
container_config: string | null;
|
||||
}>;
|
||||
const result: Record<string, RegisteredGroup> = {};
|
||||
for (const row of rows) {
|
||||
result[row.jid] = {
|
||||
name: row.name,
|
||||
folder: row.folder,
|
||||
trigger: row.trigger_pattern,
|
||||
added_at: row.added_at,
|
||||
containerConfig: row.container_config
|
||||
? JSON.parse(row.container_config)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- JSON migration ---
|
||||
|
||||
function migrateJsonState(): void {
|
||||
const migrateFile = (filename: string) => {
|
||||
const filePath = path.join(DATA_DIR, filename);
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
fs.renameSync(filePath, `${filePath}.migrated`);
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Migrate router_state.json
|
||||
const routerState = migrateFile('router_state.json') as {
|
||||
last_timestamp?: string;
|
||||
last_agent_timestamp?: Record<string, string>;
|
||||
} | null;
|
||||
if (routerState) {
|
||||
if (routerState.last_timestamp) {
|
||||
setRouterState('last_timestamp', routerState.last_timestamp);
|
||||
}
|
||||
if (routerState.last_agent_timestamp) {
|
||||
setRouterState(
|
||||
'last_agent_timestamp',
|
||||
JSON.stringify(routerState.last_agent_timestamp),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate sessions.json
|
||||
const sessions = migrateFile('sessions.json') as Record<
|
||||
string,
|
||||
string
|
||||
> | null;
|
||||
if (sessions) {
|
||||
for (const [folder, sessionId] of Object.entries(sessions)) {
|
||||
setSession(folder, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate registered_groups.json
|
||||
const groups = migrateFile('registered_groups.json') as Record<
|
||||
string,
|
||||
RegisteredGroup
|
||||
> | null;
|
||||
if (groups) {
|
||||
for (const [jid, group] of Object.entries(groups)) {
|
||||
setRegisteredGroup(jid, group);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user