refactor: migrate muxer-queue to drizzle-orm
This commit is contained in:
@@ -1,13 +1,12 @@
|
|||||||
import {
|
import { getDatabase as getDrizzleDatabase, initializeDatabase } from "./database/drizzle";
|
||||||
DatabaseAdapter,
|
import { muxerJobsTable, uiStateTable } from "./database/schema";
|
||||||
getDatabase as getDatabaseAdapter,
|
import { eq, asc, lt, and, sql } from "drizzle-orm";
|
||||||
} from "./database/adapter";
|
|
||||||
import { createChildLogger } from "./logger";
|
import { createChildLogger } from "./logger";
|
||||||
|
|
||||||
const logger = createChildLogger("muxer-queue");
|
const logger = createChildLogger("muxer-queue");
|
||||||
|
|
||||||
// Export DatabaseAdapter as SqliteDatabase for backward compatibility
|
// Type alias for backward compatibility
|
||||||
export type SqliteDatabase = DatabaseAdapter;
|
export type SqliteDatabase = any;
|
||||||
|
|
||||||
export interface MuxerJobData {
|
export interface MuxerJobData {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -27,130 +26,28 @@ interface StoredJob {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let dbAdapter: DatabaseAdapter | null = null;
|
// Export getDatabase for backward compatibility with webserver.ts
|
||||||
|
export function getDatabase(): SqliteDatabase {
|
||||||
async function initializeDatabase(): Promise<DatabaseAdapter> {
|
return getDrizzleDatabase() as any;
|
||||||
const adapter = await getDatabaseAdapter();
|
|
||||||
|
|
||||||
adapter.exec(`
|
|
||||||
PRAGMA journal_mode = WAL;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS muxer_jobs (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
attempts INTEGER NOT NULL DEFAULT 0,
|
|
||||||
maxAttempts INTEGER NOT NULL DEFAULT 3,
|
|
||||||
createdAt INTEGER NOT NULL,
|
|
||||||
updatedAt INTEGER NOT NULL,
|
|
||||||
error TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_status ON muxer_jobs(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_createdAt ON muxer_jobs(createdAt);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
guild_id TEXT NOT NULL,
|
|
||||||
channel_id TEXT NOT NULL,
|
|
||||||
thread_id TEXT,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
username TEXT NOT NULL,
|
|
||||||
avatar_url TEXT,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
edited_content TEXT,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
edited_at INTEGER,
|
|
||||||
deleted_at INTEGER,
|
|
||||||
type TEXT NOT NULL DEFAULT 'text',
|
|
||||||
metadata TEXT,
|
|
||||||
ai_status TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
ai_moderation_flags TEXT,
|
|
||||||
ai_moderation_score REAL,
|
|
||||||
ai_moderation_raw TEXT,
|
|
||||||
ai_analysis TEXT,
|
|
||||||
ai_analyzed_at INTEGER,
|
|
||||||
ai_error TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_user ON messages(user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS attachments (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
message_id TEXT NOT NULL,
|
|
||||||
guild_id TEXT NOT NULL,
|
|
||||||
channel_id TEXT NOT NULL,
|
|
||||||
thread_id TEXT,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
filename TEXT NOT NULL,
|
|
||||||
size INTEGER NOT NULL,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
discord_url TEXT NOT NULL,
|
|
||||||
uploaded_url TEXT,
|
|
||||||
upload_status TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
upload_error TEXT,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
uploaded_at INTEGER,
|
|
||||||
FOREIGN KEY (message_id) REFERENCES messages(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_attachments_channel ON attachments(channel_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_attachments_status ON attachments(upload_status);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ui_state (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
const migrations = [
|
|
||||||
"ALTER TABLE attachments ADD COLUMN thread_id TEXT",
|
|
||||||
"ALTER TABLE messages ADD COLUMN ai_status TEXT NOT NULL DEFAULT 'pending'",
|
|
||||||
"ALTER TABLE messages ADD COLUMN ai_moderation_flags TEXT",
|
|
||||||
"ALTER TABLE messages ADD COLUMN ai_moderation_score REAL",
|
|
||||||
"ALTER TABLE messages ADD COLUMN ai_moderation_raw TEXT",
|
|
||||||
"ALTER TABLE messages ADD COLUMN ai_analysis TEXT",
|
|
||||||
"ALTER TABLE messages ADD COLUMN ai_analyzed_at INTEGER",
|
|
||||||
"ALTER TABLE messages ADD COLUMN ai_error TEXT",
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const migration of migrations) {
|
|
||||||
try {
|
|
||||||
adapter.exec(migration);
|
|
||||||
} catch {
|
|
||||||
// Column already exists on databases initialized after schema updates.
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return adapter;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getDatabaseAdapterInternal(): Promise<DatabaseAdapter> {
|
|
||||||
if (!dbAdapter) {
|
|
||||||
dbAdapter = await initializeDatabase();
|
|
||||||
}
|
|
||||||
return dbAdapter;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export as getDatabase for backward compatibility
|
|
||||||
export const getDatabase = getDatabaseAdapterInternal;
|
|
||||||
|
|
||||||
export async function getPersistedValue<T>(
|
export async function getPersistedValue<T>(
|
||||||
key: string,
|
key: string,
|
||||||
fallback: T,
|
fallback: T,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const adapter = await getDatabaseAdapterInternal();
|
await initializeDatabase();
|
||||||
const row = adapter
|
const db = getDrizzleDatabase() as any;
|
||||||
.prepare("SELECT value FROM ui_state WHERE key = ?")
|
|
||||||
.get(key) as { value: string } | undefined;
|
const row = await db
|
||||||
if (!row) return fallback;
|
.select()
|
||||||
|
.from(uiStateTable)
|
||||||
|
.where(eq(uiStateTable.key, key))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row || row.length === 0) return fallback;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(row.value) as T;
|
return JSON.parse(row[0].value) as T;
|
||||||
} catch {
|
} catch {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
@@ -160,28 +57,45 @@ export async function setPersistedValue(
|
|||||||
key: string,
|
key: string,
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const adapter = await getDatabaseAdapterInternal();
|
await initializeDatabase();
|
||||||
adapter
|
const db = getDrizzleDatabase() as any;
|
||||||
.prepare(`
|
|
||||||
INSERT INTO ui_state (key, value, updated_at)
|
await db
|
||||||
VALUES (?, ?, ?)
|
.insert(uiStateTable)
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
.values({
|
||||||
`)
|
key,
|
||||||
.run(key, JSON.stringify(value), Date.now());
|
value: JSON.stringify(value),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: uiStateTable.key,
|
||||||
|
set: {
|
||||||
|
value: JSON.stringify(value),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function enqueueMuxerJob(data: MuxerJobData): Promise<string> {
|
export async function enqueueMuxerJob(data: MuxerJobData): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const adapter = await getDatabaseAdapterInternal();
|
await initializeDatabase();
|
||||||
|
const db = getDrizzleDatabase() as any;
|
||||||
|
|
||||||
const jobId = `${data.userId}-${data.sessionId}`;
|
const jobId = `${data.userId}-${data.sessionId}`;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
const stmt = adapter.prepare(`
|
await db
|
||||||
INSERT INTO muxer_jobs (id, data, status, attempts, maxAttempts, createdAt, updatedAt)
|
.insert(muxerJobsTable)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
.values({
|
||||||
`);
|
id: jobId,
|
||||||
|
data: JSON.stringify(data),
|
||||||
stmt.run(jobId, JSON.stringify(data), "pending", 0, 3, now, now);
|
status: "pending",
|
||||||
|
attempts: 0,
|
||||||
|
maxAttempts: 3,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing();
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{ jobId, userId: data.userId, sessionId: data.sessionId },
|
{ jobId, userId: data.userId, sessionId: data.sessionId },
|
||||||
@@ -202,29 +116,25 @@ export async function enqueueMuxerJob(data: MuxerJobData): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getPendingJobs(): Promise<StoredJob[]> {
|
export async function getPendingJobs(): Promise<StoredJob[]> {
|
||||||
const adapter = await getDatabaseAdapterInternal();
|
await initializeDatabase();
|
||||||
const stmt = adapter.prepare(`
|
const db = getDrizzleDatabase() as any;
|
||||||
SELECT id, data, status, attempts, maxAttempts, createdAt, updatedAt, error
|
|
||||||
FROM muxer_jobs
|
|
||||||
WHERE status = 'pending'
|
|
||||||
ORDER BY createdAt ASC
|
|
||||||
LIMIT 10
|
|
||||||
`);
|
|
||||||
|
|
||||||
const rows = stmt.all() as Array<{
|
const rows = await db
|
||||||
id: string;
|
.select()
|
||||||
data: string;
|
.from(muxerJobsTable)
|
||||||
status: string;
|
.where(eq(muxerJobsTable.status, "pending"))
|
||||||
attempts: number;
|
.orderBy(asc(muxerJobsTable.createdAt))
|
||||||
maxAttempts: number;
|
.limit(10);
|
||||||
createdAt: number;
|
|
||||||
updatedAt: number;
|
|
||||||
error?: string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row: any) => ({
|
||||||
...row,
|
id: row.id,
|
||||||
|
data: row.data,
|
||||||
status: row.status as "pending" | "processing" | "completed" | "failed",
|
status: row.status as "pending" | "processing" | "completed" | "failed",
|
||||||
|
attempts: row.attempts,
|
||||||
|
maxAttempts: row.maxAttempts,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
error: row.error || undefined,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,34 +143,44 @@ export async function updateJobStatus(
|
|||||||
status: "processing" | "completed" | "failed",
|
status: "processing" | "completed" | "failed",
|
||||||
error?: string,
|
error?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const adapter = await getDatabaseAdapterInternal();
|
await initializeDatabase();
|
||||||
|
const db = getDrizzleDatabase() as any;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
if (status === "failed") {
|
if (status === "failed") {
|
||||||
const stmt = adapter.prepare(`
|
await db
|
||||||
UPDATE muxer_jobs
|
.update(muxerJobsTable)
|
||||||
SET status = ?, attempts = attempts + 1, updatedAt = ?, error = ?
|
.set({
|
||||||
WHERE id = ?
|
status,
|
||||||
`);
|
attempts: sql`${muxerJobsTable.attempts} + 1`,
|
||||||
stmt.run(status, now, error || null, jobId);
|
updatedAt: now,
|
||||||
|
error: error || null,
|
||||||
|
})
|
||||||
|
.where(eq(muxerJobsTable.id, jobId));
|
||||||
} else {
|
} else {
|
||||||
const stmt = adapter.prepare(`
|
await db
|
||||||
UPDATE muxer_jobs
|
.update(muxerJobsTable)
|
||||||
SET status = ?, updatedAt = ?
|
.set({
|
||||||
WHERE id = ?
|
status,
|
||||||
`);
|
updatedAt: now,
|
||||||
stmt.run(status, now, jobId);
|
})
|
||||||
|
.where(eq(muxerJobsTable.id, jobId));
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info({ jobId, status, error }, "Job status updated");
|
logger.info({ jobId, status, error }, "Job status updated");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function retryFailedJob(jobId: string): Promise<boolean> {
|
export async function retryFailedJob(jobId: string): Promise<boolean> {
|
||||||
const adapter = await getDatabaseAdapterInternal();
|
await initializeDatabase();
|
||||||
|
const db = getDrizzleDatabase() as any;
|
||||||
|
|
||||||
const job = adapter
|
const jobs = await db
|
||||||
.prepare("SELECT * FROM muxer_jobs WHERE id = ?")
|
.select()
|
||||||
.get(jobId) as StoredJob | undefined;
|
.from(muxerJobsTable)
|
||||||
|
.where(eq(muxerJobsTable.id, jobId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const job = jobs[0];
|
||||||
|
|
||||||
if (!job) {
|
if (!job) {
|
||||||
logger.warn({ jobId }, "Job not found");
|
logger.warn({ jobId }, "Job not found");
|
||||||
@@ -275,13 +195,14 @@ export async function retryFailedJob(jobId: string): Promise<boolean> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stmt = adapter.prepare(`
|
await db
|
||||||
UPDATE muxer_jobs
|
.update(muxerJobsTable)
|
||||||
SET status = 'pending', updatedAt = ?
|
.set({
|
||||||
WHERE id = ?
|
status: "pending",
|
||||||
`);
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
.where(eq(muxerJobsTable.id, jobId));
|
||||||
|
|
||||||
stmt.run(Date.now(), jobId);
|
|
||||||
logger.info({ jobId, attempt: job.attempts + 1 }, "Job retried");
|
logger.info({ jobId, attempt: job.attempts + 1 }, "Job retried");
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -290,18 +211,26 @@ export async function retryFailedJob(jobId: string): Promise<boolean> {
|
|||||||
export async function cleanupCompletedJobs(
|
export async function cleanupCompletedJobs(
|
||||||
olderThanMs: number = 24 * 60 * 60 * 1000,
|
olderThanMs: number = 24 * 60 * 60 * 1000,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const adapter = await getDatabaseAdapterInternal();
|
await initializeDatabase();
|
||||||
|
const db = getDrizzleDatabase() as any;
|
||||||
const cutoffTime = Date.now() - olderThanMs;
|
const cutoffTime = Date.now() - olderThanMs;
|
||||||
|
|
||||||
const stmt = adapter.prepare(`
|
const result = await db
|
||||||
DELETE FROM muxer_jobs
|
.delete(muxerJobsTable)
|
||||||
WHERE status = 'completed' AND updatedAt < ?
|
.where(
|
||||||
`);
|
and(
|
||||||
|
eq(muxerJobsTable.status, "completed"),
|
||||||
|
lt(muxerJobsTable.updatedAt, cutoffTime),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const result = stmt.run(cutoffTime);
|
const deletedCount = typeof result === "object" && "rowsAffected" in result
|
||||||
logger.info({ deletedCount: result.changes }, "Cleaned up completed jobs");
|
? result.rowsAffected
|
||||||
|
: 0;
|
||||||
|
|
||||||
return result.changes;
|
logger.info({ deletedCount }, "Cleaned up completed jobs");
|
||||||
|
|
||||||
|
return deletedCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getJobStats(): Promise<{
|
export async function getJobStats(): Promise<{
|
||||||
@@ -310,36 +239,37 @@ export async function getJobStats(): Promise<{
|
|||||||
completed: number;
|
completed: number;
|
||||||
failed: number;
|
failed: number;
|
||||||
}> {
|
}> {
|
||||||
const adapter = await getDatabaseAdapterInternal();
|
await initializeDatabase();
|
||||||
|
const db = getDrizzleDatabase() as any;
|
||||||
|
|
||||||
const stats = adapter
|
const rows = await db
|
||||||
.prepare(`
|
.select({
|
||||||
SELECT
|
status: muxerJobsTable.status,
|
||||||
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
|
count: sql<number>`COUNT(*)`,
|
||||||
SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END) as processing,
|
})
|
||||||
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
|
.from(muxerJobsTable)
|
||||||
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed
|
.groupBy(muxerJobsTable.status);
|
||||||
FROM muxer_jobs
|
|
||||||
`)
|
const stats = {
|
||||||
.get() as {
|
pending: 0,
|
||||||
pending: number | null;
|
processing: 0,
|
||||||
processing: number | null;
|
completed: 0,
|
||||||
completed: number | null;
|
failed: 0,
|
||||||
failed: number | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
for (const row of rows) {
|
||||||
pending: stats.pending || 0,
|
const count = typeof row.count === "object" && "count" in row.count
|
||||||
processing: stats.processing || 0,
|
? (row.count as any).count
|
||||||
completed: stats.completed || 0,
|
: Number(row.count);
|
||||||
failed: stats.failed || 0,
|
if (row.status === "pending") stats.pending = count;
|
||||||
};
|
else if (row.status === "processing") stats.processing = count;
|
||||||
|
else if (row.status === "completed") stats.completed = count;
|
||||||
|
else if (row.status === "failed") stats.failed = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function closeQueue(): Promise<void> {
|
export async function closeQueue(): Promise<void> {
|
||||||
if (dbAdapter) {
|
|
||||||
await dbAdapter.close();
|
|
||||||
dbAdapter = null;
|
|
||||||
logger.info("Muxer queue closed");
|
logger.info("Muxer queue closed");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user