فهرست منبع

fix: log cleanup not actually deleting records

- Remove `deletedAt IS NULL` filter from buildWhereConditions: cleanup
  should delete ALL matching records regardless of soft-delete status
- Add `RETURNING 1` to DELETE SQL for driver-agnostic row counting
  (result.length instead of fragile count/rowCount properties)
- Add `FOR UPDATE SKIP LOCKED` to prevent deadlocks with concurrent jobs
- Add purgeSoftDeleted: batched hard-delete of soft-deleted records
  as fallback after main cleanup loop
- Add VACUUM ANALYZE after deletions to reclaim disk space
  (failure is non-fatal)
- Update CleanupResult with softDeletedPurged and vacuumPerformed
- Pass new fields through API route and show in UI toast
- Add i18n keys for 5 languages
- 19 tests covering all new behavior
ding113 1 ماه پیش
والد
کامیت
40cbfa49e6

+ 2 - 0
messages/en/settings/data.json

@@ -29,7 +29,9 @@
     },
     "rangeLabel": "Cleanup Range",
     "statisticsRetained": "✓ Statistics data will be retained (for trend analysis)",
+    "softDeletePurged": "{count} soft-deleted records also purged",
     "successMessage": "Successfully cleaned {count} log records ({batches} batches, took {duration}s)",
+    "vacuumComplete": "Database space reclaimed",
     "willClean": "Will clean all log records from {range}"
   },
   "description": "Manage database backup and recovery with full data import/export and log cleanup.",

+ 2 - 0
messages/ja/settings/data.json

@@ -28,8 +28,10 @@
       "default": "{days}日前"
     },
     "rangeLabel": "クリーンアップ範囲",
+    "softDeletePurged": "{count}件の論理削除レコードも物理削除しました",
     "statisticsRetained": "✓ 統計データは保持されます(トレンド分析用)",
     "successMessage": "{count}件のログレコードをクリーンアップしました({batches}バッチ、所要時間{duration}秒)",
+    "vacuumComplete": "データベース領域を回収しました",
     "willClean": "{range}のすべてのログレコードをクリーンアップします"
   },
   "description": "データベースのバックアップと復元を管理し、完全なインポート/エクスポートとログクリーンアップをサポートします。",

+ 2 - 0
messages/ru/settings/data.json

@@ -28,8 +28,10 @@
       "default": "{days} дней назад"
     },
     "rangeLabel": "Диапазон очистки",
+    "softDeletePurged": "Также удалено {count} мягко удаленных записей",
     "statisticsRetained": "✓ Статистические данные будут сохранены (для анализа трендов)",
     "successMessage": "Успешно очищено {count} записей логов ({batches} пакетов, заняло {duration}с)",
+    "vacuumComplete": "Дисковое пространство БД освобождено",
     "willClean": "Будут очищены все записи логов с {range}"
   },
   "description": "Управление резервной копией и восстановлением БД с полным импортом/экспортом и очисткой логов.",

+ 2 - 0
messages/zh-CN/settings/data.json

@@ -40,7 +40,9 @@
     "cancel": "取消",
     "confirm": "确认清理",
     "cleaning": "正在清理...",
+    "softDeletePurged": "另外清除了 {count} 条软删除记录",
     "successMessage": "成功清理 {count} 条日志记录({batches} 批次,耗时 {duration}s)",
+    "vacuumComplete": "数据库空间已回收",
     "failed": "清理失败",
     "error": "清理日志失败",
     "descriptionWarning": "清理历史日志数据以释放数据库存储空间。注意:统计数据将被保留,但日志详情将被永久删除。"

+ 2 - 0
messages/zh-TW/settings/data.json

@@ -28,8 +28,10 @@
       "default": "約 {days} 天前"
     },
     "rangeLabel": "清理範圍",
+    "softDeletePurged": "另外清除了 {count} 筆軟刪除記錄",
     "statisticsRetained": "✓ 統計資料將被保留(用於趨勢分析)",
     "successMessage": "成功清理 {count} 筆日誌記錄({batches} 批次,耗時 {duration}秒)",
+    "vacuumComplete": "資料庫空間已回收",
     "willClean": "將清理 {range} 的所有日誌記錄"
   },
   "description": "管理資料庫的備份與恢復,支援完整資料匯入匯出和日誌清理。",

+ 10 - 3
src/app/[locale]/settings/data/_components/log-cleanup-panel.tsx

@@ -97,13 +97,20 @@ export function LogCleanupPanel() {
       }
 
       if (result.success) {
-        toast.success(
+        const parts: string[] = [
           t("successMessage", {
             count: result.totalDeleted.toLocaleString(),
             batches: result.batchCount,
             duration: (result.durationMs / 1000).toFixed(2),
-          })
-        );
+          }),
+        ];
+        if (result.softDeletedPurged > 0) {
+          parts.push(t("softDeletePurged", { count: result.softDeletedPurged }));
+        }
+        if (result.vacuumPerformed) {
+          parts.push(t("vacuumComplete"));
+        }
+        toast.success(parts.join(" | "));
         setIsOpen(false);
       } else {
         toast.error(result.error || t("failed"));

+ 2 - 0
src/app/api/admin/log-cleanup/manual/route.ts

@@ -93,6 +93,8 @@ export async function POST(request: NextRequest) {
       totalDeleted: result.totalDeleted,
       batchCount: result.batchCount,
       durationMs: result.durationMs,
+      softDeletedPurged: result.softDeletedPurged,
+      vacuumPerformed: result.vacuumPerformed,
       error: result.error,
     });
   } catch (error) {

+ 120 - 48
src/lib/log-cleanup/service.ts

@@ -4,63 +4,56 @@ import { messageRequest } from "@/drizzle/schema";
 import { logger } from "@/lib/logger";
 
 /**
- * 日志清理条件
+ * Log cleanup conditions
  */
 export interface CleanupConditions {
-  // 时间范围
+  // Time range
   beforeDate?: Date;
   afterDate?: Date;
 
-  // 用户维度
+  // User dimension
   userIds?: number[];
 
-  // 供应商维度
+  // Provider dimension
   providerIds?: number[];
 
-  // 状态维度
-  statusCodes?: number[]; // 精确匹配状态码
+  // Status dimension
+  statusCodes?: number[];
   statusCodeRange?: {
-    // 状态码范围 (如 400-499)
     min: number;
     max: number;
   };
-  onlyBlocked?: boolean; // 仅被拦截的请求
+  onlyBlocked?: boolean;
 }
 
 /**
- * 清理选项
+ * Cleanup options
  */
 export interface CleanupOptions {
-  batchSize?: number; // 批量删除大小(默认 10000)
-  dryRun?: boolean; // 仅预览,不实际删除
+  batchSize?: number;
+  dryRun?: boolean;
 }
 
 /**
- * 清理结果
+ * Cleanup result
  */
 export interface CleanupResult {
   totalDeleted: number;
   batchCount: number;
   durationMs: number;
+  softDeletedPurged: number;
+  vacuumPerformed: boolean;
   error?: string;
 }
 
 /**
- * 触发信息
+ * Trigger info
  */
 export interface TriggerInfo {
   type: "manual" | "scheduled";
   user?: string;
 }
 
-/**
- * 执行日志清理
- *
- * @param conditions 清理条件
- * @param options 清理选项
- * @param triggerInfo 触发信息
- * @returns 清理结果
- */
 // NOTE: usage_ledger is intentionally immune to log cleanup.
 // Only message_request rows are deleted here.
 export async function cleanupLogs(
@@ -72,9 +65,10 @@ export async function cleanupLogs(
   const batchSize = options.batchSize || 10000;
   let totalDeleted = 0;
   let batchCount = 0;
+  let softDeletedPurged = 0;
+  let vacuumPerformed = false;
 
   try {
-    // 1. 构建 WHERE 条件
     const whereConditions = buildWhereConditions(conditions);
 
     if (whereConditions.length === 0) {
@@ -86,12 +80,13 @@ export async function cleanupLogs(
         totalDeleted: 0,
         batchCount: 0,
         durationMs: Date.now() - startTime,
-        error: "未指定任何清理条件",
+        softDeletedPurged: 0,
+        vacuumPerformed: false,
+        error: "No cleanup conditions specified",
       };
     }
 
     if (options.dryRun) {
-      // 仅统计数量
       const result = await db
         .select({ count: sql<number>`count(*)::int` })
         .from(messageRequest)
@@ -107,10 +102,12 @@ export async function cleanupLogs(
         totalDeleted: result[0]?.count || 0,
         batchCount: 0,
         durationMs: Date.now() - startTime,
+        softDeletedPurged: 0,
+        vacuumPerformed: false,
       };
     }
 
-    // 2. 分批删除
+    // Main delete loop
     while (true) {
       const deleted = await deleteBatch(whereConditions, batchSize);
 
@@ -126,24 +123,33 @@ export async function cleanupLogs(
         totalDeleted,
       });
 
-      // 避免长时间锁表,短暂休息
       if (deleted === batchSize) {
         await sleep(100);
       }
     }
 
+    // Purge soft-deleted records as fallback
+    softDeletedPurged = await purgeSoftDeleted(batchSize);
+
+    // VACUUM ANALYZE to reclaim disk space
+    if (totalDeleted > 0 || softDeletedPurged > 0) {
+      vacuumPerformed = await runVacuum();
+    }
+
     const durationMs = Date.now() - startTime;
 
     logger.info({
       action: "log_cleanup_complete",
       totalDeleted,
       batchCount,
+      softDeletedPurged,
+      vacuumPerformed,
       durationMs,
       triggerType: triggerInfo.type,
       user: triggerInfo.user,
     });
 
-    return { totalDeleted, batchCount, durationMs };
+    return { totalDeleted, batchCount, durationMs, softDeletedPurged, vacuumPerformed };
   } catch (error) {
     const errorMessage = error instanceof Error ? error.message : String(error);
 
@@ -159,21 +165,21 @@ export async function cleanupLogs(
       totalDeleted,
       batchCount,
       durationMs: Date.now() - startTime,
+      softDeletedPurged,
+      vacuumPerformed,
       error: errorMessage,
     };
   }
 }
 
 /**
- * 构建 WHERE 条件
+ * Build WHERE conditions for cleanup query.
+ * No deletedAt filter: cleanup should delete ALL matching records
+ * regardless of soft-delete status to actually reclaim space.
  */
-function buildWhereConditions(conditions: CleanupConditions): SQL[] {
+export function buildWhereConditions(conditions: CleanupConditions): SQL[] {
   const where: SQL[] = [];
 
-  // 排除软删除的记录(已经被软删除的不再处理)
-  where.push(sql`${messageRequest.deletedAt} IS NULL`);
-
-  // 时间范围
   if (conditions.beforeDate) {
     where.push(lte(messageRequest.createdAt, conditions.beforeDate));
   }
@@ -181,17 +187,14 @@ function buildWhereConditions(conditions: CleanupConditions): SQL[] {
     where.push(gte(messageRequest.createdAt, conditions.afterDate));
   }
 
-  // 用户维度
   if (conditions.userIds && conditions.userIds.length > 0) {
     where.push(inArray(messageRequest.userId, conditions.userIds));
   }
 
-  // 供应商维度
   if (conditions.providerIds && conditions.providerIds.length > 0) {
     where.push(inArray(messageRequest.providerId, conditions.providerIds));
   }
 
-  // 状态维度
   if (conditions.statusCodes && conditions.statusCodes.length > 0) {
     where.push(inArray(messageRequest.statusCode, conditions.statusCodes));
   }
@@ -212,40 +215,112 @@ function buildWhereConditions(conditions: CleanupConditions): SQL[] {
 }
 
 /**
- * 批量删除
- *
- * 使用 CTE (Common Table Expression) + DELETE 实现原子删除
- * 避免两步操作的竞态条件,性能更好
+ * Batch delete with CTE + RETURNING 1 for driver-agnostic row counting.
+ * Uses FOR UPDATE SKIP LOCKED to prevent deadlocks with concurrent jobs.
  */
 async function deleteBatch(whereConditions: SQL[], batchSize: number): Promise<number> {
-  // 使用 CTE 实现原子批量删除
   const result = await db.execute(sql`
     WITH ids_to_delete AS (
       SELECT id FROM message_request
       WHERE ${and(...whereConditions)}
       ORDER BY created_at ASC
       LIMIT ${batchSize}
-      FOR UPDATE
+      FOR UPDATE SKIP LOCKED
     )
     DELETE FROM message_request
     WHERE id IN (SELECT id FROM ids_to_delete)
+    RETURNING 1
   `);
 
   return getAffectedRows(result);
 }
 
-function getAffectedRows(result: unknown): number {
+/**
+ * Purge all soft-deleted records (deleted_at IS NOT NULL) in batches.
+ * Runs as fallback after main cleanup to ensure soft-deleted rows
+ * are also physically removed.
+ */
+async function purgeSoftDeleted(batchSize: number): Promise<number> {
+  let totalPurged = 0;
+
+  while (true) {
+    const result = await db.execute(sql`
+      WITH ids_to_delete AS (
+        SELECT id FROM message_request
+        WHERE deleted_at IS NOT NULL
+        ORDER BY created_at ASC
+        LIMIT ${batchSize}
+        FOR UPDATE SKIP LOCKED
+      )
+      DELETE FROM message_request
+      WHERE id IN (SELECT id FROM ids_to_delete)
+      RETURNING 1
+    `);
+
+    const deleted = getAffectedRows(result);
+    if (deleted === 0) break;
+
+    totalPurged += deleted;
+
+    logger.info({
+      action: "log_cleanup_soft_delete_purge",
+      deletedInBatch: deleted,
+      totalPurged,
+    });
+
+    if (deleted === batchSize) {
+      await sleep(100);
+    }
+  }
+
+  return totalPurged;
+}
+
+/**
+ * Run VACUUM ANALYZE to reclaim disk space after deletions.
+ * Failure is non-fatal: logged but does not fail the cleanup result.
+ */
+async function runVacuum(): Promise<boolean> {
+  try {
+    await db.execute(sql`VACUUM ANALYZE message_request`);
+    logger.info({ action: "log_cleanup_vacuum_complete" });
+    return true;
+  } catch (error) {
+    logger.warn({
+      action: "log_cleanup_vacuum_failed",
+      error: error instanceof Error ? error.message : String(error),
+    });
+    return false;
+  }
+}
+
+/**
+ * Extract affected row count from db.execute() result.
+ *
+ * Priority:
+ * 1. Array with length > 0 (RETURNING rows) -> result.length
+ * 2. result.count (postgres.js, may be BigInt)
+ * 3. result.rowCount (node-postgres)
+ * 4. 0
+ */
+export function getAffectedRows(result: unknown): number {
   if (!result || typeof result !== "object") {
     return 0;
   }
 
-  const r = result as { count?: unknown; rowCount?: unknown };
+  // RETURNING rows: postgres.js returns array of rows
+  if (Array.isArray(result) && result.length > 0) {
+    return result.length;
+  }
+
+  const r = result as { count?: unknown; rowCount?: unknown; length?: unknown };
 
-  // postgres.js returns count as BigInt; node-postgres uses rowCount as number
+  // postgres.js count (may be BigInt)
   if (r.count !== undefined) {
     return Number(r.count);
   }
 
+  // node-postgres rowCount
   if (typeof r.rowCount === "number") {
     return r.rowCount;
   }
@@ -253,9 +328,6 @@ function getAffectedRows(result: unknown): number {
   return 0;
 }
 
-/**
- * 休眠函数
- */
 function sleep(ms: number): Promise<void> {
   return new Promise((resolve) => setTimeout(resolve, ms));
 }

+ 181 - 19
tests/unit/lib/log-cleanup/service-count.test.ts

@@ -1,3 +1,5 @@
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
 import { type MockInstance, beforeEach, describe, expect, it, vi } from "vitest";
 
 type ExecuteCountResult = unknown[] & {
@@ -34,28 +36,50 @@ function makeExecuteResult(input: {
   return result;
 }
 
+function makeReturningResult(count: number): unknown[] {
+  return Array.from({ length: count }, () => ({ "?column?": 1 }));
+}
+
 describe("log cleanup delete count", () => {
   beforeEach(async () => {
     const { db } = await import("@/drizzle/db");
     (db.execute as MockInstance).mockReset();
   });
 
+  it("prefers RETURNING array length for row counting", async () => {
+    const { db } = await import("@/drizzle/db");
+    (db.execute as MockInstance)
+      .mockResolvedValueOnce(makeReturningResult(5)) // main delete: 5 rows
+      .mockResolvedValueOnce([]) // main delete: 0 (exit loop)
+      .mockResolvedValueOnce([]) // soft-delete purge: 0 (exit)
+      .mockResolvedValueOnce({}); // VACUUM
+
+    const { cleanupLogs } = await import("@/lib/log-cleanup/service");
+    const result = await cleanupLogs(
+      { beforeDate: new Date() },
+      {},
+      { type: "manual", user: "test" }
+    );
+
+    expect(result.error).toBeUndefined();
+    expect(result.totalDeleted).toBe(5);
+    expect(result.batchCount).toBe(1);
+    expect(result.vacuumPerformed).toBe(true);
+  });
+
   it("reads affected rows from postgres.js count field", async () => {
     const { db } = await import("@/drizzle/db");
     (db.execute as MockInstance)
-      .mockResolvedValueOnce(makeExecuteResult({ count: 3 }))
-      .mockResolvedValueOnce(makeExecuteResult({ count: 0 }));
+      .mockResolvedValueOnce(makeExecuteResult({ count: 3 })) // main delete
+      .mockResolvedValueOnce(makeExecuteResult({ count: 0 })) // main delete exit
+      .mockResolvedValueOnce([]) // soft-delete purge
+      .mockResolvedValueOnce({}); // VACUUM
 
     const { cleanupLogs } = await import("@/lib/log-cleanup/service");
     const result = await cleanupLogs(
-      {
-        beforeDate: new Date(),
-      },
+      { beforeDate: new Date() },
       {},
-      {
-        type: "manual",
-        user: "test",
-      }
+      { type: "manual", user: "test" }
     );
 
     expect(result.error).toBeUndefined();
@@ -65,10 +89,11 @@ describe("log cleanup delete count", () => {
 
   it("reads affected rows from postgres.js BigInt count field", async () => {
     const { db } = await import("@/drizzle/db");
-    // postgres.js returns count as BigInt in some versions
     (db.execute as MockInstance)
       .mockResolvedValueOnce(makeExecuteResult({ count: BigInt(7) }))
-      .mockResolvedValueOnce(makeExecuteResult({ count: BigInt(0) }));
+      .mockResolvedValueOnce(makeExecuteResult({ count: BigInt(0) }))
+      .mockResolvedValueOnce([])
+      .mockResolvedValueOnce({});
 
     const { cleanupLogs } = await import("@/lib/log-cleanup/service");
     const result = await cleanupLogs(
@@ -86,22 +111,159 @@ describe("log cleanup delete count", () => {
     const { db } = await import("@/drizzle/db");
     (db.execute as MockInstance)
       .mockResolvedValueOnce(makeExecuteResult({ rowCount: 2 }))
-      .mockResolvedValueOnce(makeExecuteResult({ rowCount: 0 }));
+      .mockResolvedValueOnce(makeExecuteResult({ rowCount: 0 }))
+      .mockResolvedValueOnce([])
+      .mockResolvedValueOnce({});
 
     const { cleanupLogs } = await import("@/lib/log-cleanup/service");
     const result = await cleanupLogs(
-      {
-        beforeDate: new Date(),
-      },
+      { beforeDate: new Date() },
       {},
-      {
-        type: "manual",
-        user: "test",
-      }
+      { type: "manual", user: "test" }
     );
 
     expect(result.error).toBeUndefined();
     expect(result.totalDeleted).toBe(2);
     expect(result.batchCount).toBe(1);
   });
+
+  it("purgeSoftDeleted runs after main cleanup and count returned in result", async () => {
+    const { db } = await import("@/drizzle/db");
+    (db.execute as MockInstance)
+      .mockResolvedValueOnce(makeReturningResult(2)) // main delete: 2
+      .mockResolvedValueOnce([]) // main delete exit
+      .mockResolvedValueOnce(makeReturningResult(4)) // soft-delete purge: 4
+      .mockResolvedValueOnce([]) // soft-delete purge exit
+      .mockResolvedValueOnce({}); // VACUUM
+
+    const { cleanupLogs } = await import("@/lib/log-cleanup/service");
+    const result = await cleanupLogs(
+      { beforeDate: new Date() },
+      {},
+      { type: "manual", user: "test" }
+    );
+
+    expect(result.error).toBeUndefined();
+    expect(result.totalDeleted).toBe(2);
+    expect(result.softDeletedPurged).toBe(4);
+    expect(result.vacuumPerformed).toBe(true);
+  });
+
+  it("VACUUM runs after deletion, failure doesn't fail cleanup", async () => {
+    const { db } = await import("@/drizzle/db");
+    (db.execute as MockInstance)
+      .mockResolvedValueOnce(makeReturningResult(1)) // main delete: 1
+      .mockResolvedValueOnce([]) // main delete exit
+      .mockResolvedValueOnce([]) // soft-delete purge: 0
+      .mockRejectedValueOnce(new Error("VACUUM failed")); // VACUUM fails
+
+    const { cleanupLogs } = await import("@/lib/log-cleanup/service");
+    const result = await cleanupLogs(
+      { beforeDate: new Date() },
+      {},
+      { type: "manual", user: "test" }
+    );
+
+    expect(result.error).toBeUndefined();
+    expect(result.totalDeleted).toBe(1);
+    expect(result.vacuumPerformed).toBe(false);
+  });
+
+  it("VACUUM skipped when 0 records deleted", async () => {
+    const { db } = await import("@/drizzle/db");
+    (db.execute as MockInstance)
+      .mockResolvedValueOnce([]) // main delete: 0 (exit immediately)
+      .mockResolvedValueOnce([]); // soft-delete purge: 0
+
+    const { cleanupLogs } = await import("@/lib/log-cleanup/service");
+    const result = await cleanupLogs(
+      { beforeDate: new Date() },
+      {},
+      { type: "manual", user: "test" }
+    );
+
+    expect(result.error).toBeUndefined();
+    expect(result.totalDeleted).toBe(0);
+    expect(result.softDeletedPurged).toBe(0);
+    expect(result.vacuumPerformed).toBe(false);
+    // VACUUM should not have been called (only 2 execute calls total)
+    expect(db.execute).toHaveBeenCalledTimes(2);
+  });
+});
+
+describe("getAffectedRows", () => {
+  it("returns array length for RETURNING rows", async () => {
+    const { getAffectedRows } = await import("@/lib/log-cleanup/service");
+    expect(getAffectedRows(makeReturningResult(10))).toBe(10);
+  });
+
+  it("falls through to count for empty array with count property", async () => {
+    const { getAffectedRows } = await import("@/lib/log-cleanup/service");
+    expect(getAffectedRows(makeExecuteResult({ count: 5 }))).toBe(5);
+  });
+
+  it("handles BigInt count", async () => {
+    const { getAffectedRows } = await import("@/lib/log-cleanup/service");
+    expect(getAffectedRows(makeExecuteResult({ count: BigInt(99) }))).toBe(99);
+  });
+
+  it("handles rowCount fallback", async () => {
+    const { getAffectedRows } = await import("@/lib/log-cleanup/service");
+    expect(getAffectedRows(makeExecuteResult({ rowCount: 42 }))).toBe(42);
+  });
+
+  it("returns 0 for null/undefined", async () => {
+    const { getAffectedRows } = await import("@/lib/log-cleanup/service");
+    expect(getAffectedRows(null)).toBe(0);
+    expect(getAffectedRows(undefined)).toBe(0);
+  });
+
+  it("returns 0 for empty result", async () => {
+    const { getAffectedRows } = await import("@/lib/log-cleanup/service");
+    expect(getAffectedRows([])).toBe(0);
+    expect(getAffectedRows({})).toBe(0);
+  });
+});
+
+describe("buildWhereConditions", () => {
+  it("does not filter on deletedAt", async () => {
+    const { buildWhereConditions } = await import("@/lib/log-cleanup/service");
+    const conditions = buildWhereConditions({});
+    expect(conditions).toHaveLength(0);
+  });
+
+  it("returns conditions only for provided filters", async () => {
+    const { buildWhereConditions } = await import("@/lib/log-cleanup/service");
+    const conditions = buildWhereConditions({
+      beforeDate: new Date(),
+      userIds: [1, 2],
+    });
+    // beforeDate + userIds = 2 conditions (no deletedAt)
+    expect(conditions).toHaveLength(2);
+  });
+});
+
+describe("log cleanup SQL patterns", () => {
+  const serviceSource = readFileSync(
+    resolve(process.cwd(), "src/lib/log-cleanup/service.ts"),
+    "utf-8"
+  );
+
+  it("uses SKIP LOCKED in delete SQL", () => {
+    expect(serviceSource).toContain("FOR UPDATE SKIP LOCKED");
+  });
+
+  it("uses RETURNING 1 in delete SQL", () => {
+    expect(serviceSource).toContain("RETURNING 1");
+  });
+
+  it("does not contain deletedAt IS NULL in buildWhereConditions", () => {
+    const buildFnMatch = serviceSource.match(/function buildWhereConditions[\s\S]*?^}/m);
+    expect(buildFnMatch).not.toBeNull();
+    expect(buildFnMatch![0]).not.toContain("deletedAt");
+  });
+
+  it("includes VACUUM ANALYZE", () => {
+    expect(serviceSource).toContain("VACUUM ANALYZE message_request");
+  });
 });