diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.expansion.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.expansion.spec.ts index 58e5843d57..d8088a929e 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.expansion.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.expansion.spec.ts @@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => ({ redisCheckJsonModule: vi.fn(), redisDeleteKey: vi.fn(), redisDeleteKeys: vi.fn(), + redisDeleteKeysByPattern: vi.fn(), redisExecuteCommand: vi.fn(), saveHistory: vi.fn(), toast: vi.fn(), @@ -52,6 +53,7 @@ vi.mock("@/lib/backend/api", () => ({ redisCheckJsonModule: mocks.redisCheckJsonModule, redisDeleteKey: mocks.redisDeleteKey, redisDeleteKeys: mocks.redisDeleteKeys, + redisDeleteKeysByPattern: mocks.redisDeleteKeysByPattern, redisExecuteCommand: mocks.redisExecuteCommand, saveHistory: mocks.saveHistory, })); diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts index 3ea2fb8e05..7d657fcc26 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts @@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({ redisCheckJsonModule: vi.fn(), redisDeleteKey: vi.fn(), redisDeleteKeys: vi.fn(), + redisDeleteKeysByPattern: vi.fn(), redisExecuteCommand: vi.fn(), saveHistory: vi.fn(), canBuildRedisFuzzyTree: vi.fn((loadedKeyCount: number) => loadedKeyCount <= 200_000), @@ -65,6 +66,7 @@ vi.mock("@/lib/backend/api", () => ({ redisCheckJsonModule: mocks.redisCheckJsonModule, redisDeleteKey: mocks.redisDeleteKey, redisDeleteKeys: mocks.redisDeleteKeys, + redisDeleteKeysByPattern: mocks.redisDeleteKeysByPattern, redisExecuteCommand: mocks.redisExecuteCommand, saveHistory: mocks.saveHistory, })); @@ -500,6 +502,7 @@ function resetApiMocks() { mocks.redisCheckJsonModule.mockResolvedValue(true); mocks.redisDeleteKey.mockResolvedValue(undefined); mocks.redisDeleteKeys.mockResolvedValue(0); + mocks.redisDeleteKeysByPattern.mockResolvedValue(0); mocks.redisExecuteCommand.mockResolvedValue({ value: "OK" }); mocks.saveHistory.mockResolvedValue(undefined); mocks.listRedisCompletionCommandDocs.mockResolvedValue(completionCommands); @@ -523,6 +526,8 @@ function mountBrowser(withDeleteDetails = false) { en: { redis: { deleteGroupDetails: withDeleteDetails ? "{target}\n{count} keys" : "redis.deleteGroupDetails", + deleteGroupSubtreeDetails: "{target}\nall {count}", + deleteGroupSubtreeSuccess: "deleted {count}", keys: "{count} keys", batchExpirySelected: "redis.batchExpirySelected", batchExpirySuccess: "applied {count}", @@ -635,6 +640,11 @@ function groupRow(label: string): HTMLElement { return row!; } +function groupRowOrUndefined(label: string): HTMLElement | undefined { + const labelElement = Array.from(document.querySelectorAll(".dbx-editor-font-family")).find((element) => element.textContent === label); + return labelElement?.closest(".group") ?? undefined; +} + function redisCheckboxes(): HTMLElement[] { return Array.from(document.querySelectorAll('input[type="checkbox"]')); } @@ -1578,6 +1588,37 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => { expect(document.body.textContent).not.toContain("session:current"); }); + it("deletes every key under a group prefix instead of only the loaded rows (#10164)", async () => { + const loaded = [ + { key_display: "MCP:AAA:1", key_raw: btoa("MCP:AAA:1"), key_type: "string", ttl: -1 }, + { key_display: "MCP:AAA:2", key_raw: btoa("MCP:AAA:2"), key_type: "string", ttl: -1 }, + { key_display: "OTHER:key1", key_raw: btoa("OTHER:key1"), key_type: "string", ttl: -1 }, + ]; + mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: loaded, total_keys: 3002 }); + mocks.redisDeleteKeysByPattern.mockResolvedValue(3001); + mountBrowser(); + await settle(); + + const groupDelete = groupRow("MCP").querySelector('button[title="redis.deleteGroup"]'); + expect(groupDelete, "group delete button").not.toBeNull(); + groupDelete!.click(); + await settle(); + + expect(mocks.redisDeleteKeys).not.toHaveBeenCalled(); + expect(requiredElement("[data-test-danger-details]").textContent).toContain("MCP"); + requiredElement("[data-test-danger-confirm]").click(); + await settle(); + + // 整个前缀在服务端删除(未加载的子键也在内),不再按已加载行逐个删 + expect(mocks.redisDeleteKeysByPattern).toHaveBeenCalledWith("connection", 0, "MCP:*"); + expect(mocks.redisDeleteKeys).not.toHaveBeenCalled(); + // 整个 MCP 分组消失,前缀之外的分组保留 + expect(groupRowOrUndefined("MCP")).toBeUndefined(); + expect(groupRow("OTHER")).toBeDefined(); + expect(mocks.updateRedisDbKeyStats).toHaveBeenCalledWith("connection", 0, { loaded: 1, totalDelta: -3001 }); + expect(mocks.toast).toHaveBeenCalledWith("deleted 3001", 3000); + }); + it("preserves the cursor and finds a sparse fuzzy match after a bounded continuation", async () => { mocks.redisScanPageSize = 1_000; mountBrowser(); diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.infiniteScroll.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.infiniteScroll.spec.ts index 91bdc7a775..928402bee2 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.infiniteScroll.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.infiniteScroll.spec.ts @@ -31,6 +31,7 @@ const mocks = vi.hoisted(() => ({ redisCheckJsonModule: vi.fn(), redisDeleteKey: vi.fn(), redisDeleteKeys: vi.fn(), + redisDeleteKeysByPattern: vi.fn(), redisExecuteCommand: vi.fn(), saveHistory: vi.fn(), toast: vi.fn(), @@ -61,6 +62,7 @@ vi.mock("@/lib/backend/api", () => ({ redisCheckJsonModule: mocks.redisCheckJsonModule, redisDeleteKey: mocks.redisDeleteKey, redisDeleteKeys: mocks.redisDeleteKeys, + redisDeleteKeysByPattern: mocks.redisDeleteKeysByPattern, redisExecuteCommand: mocks.redisExecuteCommand, saveHistory: mocks.saveHistory, })); diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.searchHistory.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.searchHistory.spec.ts index f9a15a97ad..ae8ec596f0 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.searchHistory.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.searchHistory.spec.ts @@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => ({ redisCheckJsonModule: vi.fn(), redisDeleteKey: vi.fn(), redisDeleteKeys: vi.fn(), + redisDeleteKeysByPattern: vi.fn(), redisExecuteCommand: vi.fn(), saveHistory: vi.fn(), toast: vi.fn(), @@ -51,6 +52,7 @@ vi.mock("@/lib/backend/api", () => ({ redisCheckJsonModule: mocks.redisCheckJsonModule, redisDeleteKey: mocks.redisDeleteKey, redisDeleteKeys: mocks.redisDeleteKeys, + redisDeleteKeysByPattern: mocks.redisDeleteKeysByPattern, redisExecuteCommand: mocks.redisExecuteCommand, saveHistory: mocks.saveHistory, })); diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.subtreeFill.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.subtreeFill.spec.ts index ac68d3591c..359af3d6cd 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.subtreeFill.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.subtreeFill.spec.ts @@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => ({ redisCheckJsonModule: vi.fn(), redisDeleteKey: vi.fn(), redisDeleteKeys: vi.fn(), + redisDeleteKeysByPattern: vi.fn(), redisExecuteCommand: vi.fn(), saveHistory: vi.fn(), toast: vi.fn(), @@ -51,6 +52,7 @@ vi.mock("@/lib/backend/api", () => ({ redisCheckJsonModule: mocks.redisCheckJsonModule, redisDeleteKey: mocks.redisDeleteKey, redisDeleteKeys: mocks.redisDeleteKeys, + redisDeleteKeysByPattern: mocks.redisDeleteKeysByPattern, redisExecuteCommand: mocks.redisExecuteCommand, saveHistory: mocks.saveHistory, })); diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.tabState.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.tabState.spec.ts index 69952638ac..edc6977064 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.tabState.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.tabState.spec.ts @@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => ({ redisCheckJsonModule: vi.fn(), redisDeleteKey: vi.fn(), redisDeleteKeys: vi.fn(), + redisDeleteKeysByPattern: vi.fn(), redisExecuteCommand: vi.fn(), saveHistory: vi.fn(), toast: vi.fn(), @@ -51,6 +52,7 @@ vi.mock("@/lib/backend/api", () => ({ redisCheckJsonModule: mocks.redisCheckJsonModule, redisDeleteKey: mocks.redisDeleteKey, redisDeleteKeys: mocks.redisDeleteKeys, + redisDeleteKeysByPattern: mocks.redisDeleteKeysByPattern, redisExecuteCommand: mocks.redisExecuteCommand, saveHistory: mocks.saveHistory, })); diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index c643b1dd44..e689ea9ec0 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -191,7 +191,7 @@ const batchExpiryTtl = ref(""); const batchExpiryExpireAt = shallowRef(null); /** Snapshot taken when the dialog opens so a background refresh cannot retarget the batch. */ const batchExpiryKeyRaws = shallowRef([]); -const pendingDanger = ref<{ kind: "delete-keys"; title: string; keyRaws: string[]; loadedSearchResults: boolean } | { kind: "command"; command: string } | null>(null); +const pendingDanger = ref<{ kind: "delete-keys"; title: string; keyRaws: string[]; loadedSearchResults: boolean } | { kind: "delete-group"; title: string; pattern: string; groupId: string; loadedCount: number } | { kind: "command"; command: string } | null>(null); const showDangerConfirm = ref(false); const commandText = ref(""); const commandRunning = ref(false); @@ -424,6 +424,14 @@ const dangerDetails = computed(() => { count: pendingDanger.value.keyRaws.length, }); } + if (pendingDanger.value.kind === "delete-group") { + // 分组删除按前缀在服务端扫全量,未加载的子键也会一起删除,因此这里 + // 只能把已加载数量作为参考,不能像按 key 删除那样当作删除总数。 + return t("redis.deleteGroupSubtreeDetails", { + target: pendingDanger.value.title, + count: pendingDanger.value.loadedCount, + }); + } return pendingDanger.value.command; }); const dangerConfirmLabel = computed(() => { @@ -1680,13 +1688,15 @@ function requestBatchDelete() { function requestGroupDelete(node: RedisKeyTreeNode, event?: Event) { event?.stopPropagation(); if (node.kind !== "group" || selectionBusy.value) return; - const keyRaws = collectRedisGroupKeyRaws(node); - if (keyRaws.length === 0) return; + // 一个分组是键名前缀:树里只持有已扫描到的那部分子键,按已加载行删除会 + // 留下一部分键(#10164)。这里改为删除该前缀下的全部键。 + const pattern = redisGroupSubtreePattern(node.pathSegments, redisKeySeparator.value); pendingDanger.value = { - kind: "delete-keys", + kind: "delete-group", title: node.pathSegments.join(redisKeySeparator.value), - keyRaws, - loadedSearchResults: false, + pattern, + groupId: node.id, + loadedCount: collectRedisGroupKeyRaws(node).length, }; showDangerConfirm.value = true; } @@ -1777,6 +1787,50 @@ function resetLoadedKeys() { lastTotalKeys.value = 0; } +async function deleteGroupSubtree(pattern: string, groupId: string) { + if (deletingKeys.value) return; + + // Ignore a late SCAN page while an explicit mutation changes this result set. + invalidateScanRequests(); + fetchAllStopRequested.value = true; + deletingKeys.value = true; + try { + const deletedCount = await api.redisDeleteKeysByPattern(props.connectionId, props.db, pattern); + // 删除作用于服务端整个前缀,本地只保留匹配该前缀之外的行;未加载的子键 + // 也已随服务端删除一起消失,因此不需要再补扫。 + const matches = createRedisKeyPatternMatcher(pattern); + const removed = flatKeys.value.filter((key) => matches(key.key_display, key.key_raw)); + for (const key of removed) { + loadedKeyRaws.delete(key.key_raw); + ttlObservedAtByRaw.delete(key.key_raw); + positiveTtlKeyRaws.delete(key.key_raw); + } + replaceFlatKeyRecords(flatKeys.value.filter((key) => !matches(key.key_display, key.key_raw))); + if (selectedKeyRaw.value && removed.some((key) => key.key_raw === selectedKeyRaw.value)) { + selectedKeyRaw.value = null; + } + resetCheckedKeys(); + // 该子树已被整段删除,旧的补扫游标/已扫尽标记对新键不再成立 + subtreePendingGroupCursors.delete(groupId); + subtreeFilledGroupIds.delete(groupId); + rebuildTree(false); + connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, { + loaded: isSearchMode.value ? undefined : flatKeys.value.length, + totalDelta: -deletedCount, + }); + toast(t("redis.deleteGroupSubtreeSuccess", { count: deletedCount }), 3000); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + toast(message, 5000); + // A partial scan may already have deleted keys before the error surfaced. + // Reload instead of leaving a potentially stale result in the tree. + if (redisBrowserIsActive) await loadKeys(); + else reloadKeysOnActivation = true; + } finally { + deletingKeys.value = false; + } +} + async function deleteKeyRaws(keys: string[]) { const uniqueKeys = [...new Set(keys)]; if (uniqueKeys.length === 0 || deletingKeys.value) return; @@ -2395,6 +2449,10 @@ async function applyDangerAction() { await deleteKeyRaws(pending.keyRaws); pendingDanger.value = null; showDangerConfirm.value = false; + } else if (pending.kind === "delete-group") { + await deleteGroupSubtree(pending.pattern, pending.groupId); + pendingDanger.value = null; + showDangerConfirm.value = false; } else { pendingDanger.value = null; showDangerConfirm.value = false; @@ -3640,7 +3698,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand }); - + diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 95081f886b..d8c1880b0b 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -5436,6 +5436,8 @@ export default withEnglishFallback({ deleteKey: "Açarı sil", deleteGroupDetails: "{target}\n{count} açar", deleteLoadedSearchKeysDetails: "{target}\n{count} yüklənmiş uyğun açar", + deleteGroupSubtreeDetails: "{target}\nBu qrupun altındakı bütün açarlar silinəcək ({count} yükləndi)", + deleteGroupSubtreeSuccess: "{count} açar silindi", batchExpiry: "Müddəti təyin et", batchExpiryTitle: "Toplu müddət təyini", batchExpirySelected: "{count} açar seçildi", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 4c4c34c455..7b5f8c1d80 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -6022,6 +6022,8 @@ export default { deleteKey: "Delete key", deleteGroupDetails: "{target}\n{count} keys", deleteLoadedSearchKeysDetails: "{target}\n{count} loaded matching keys", + deleteGroupSubtreeDetails: "{target}\nAll keys under this group will be deleted ({count} loaded)", + deleteGroupSubtreeSuccess: "Deleted {count} keys", batchExpiry: "Set expiry", batchExpiryTitle: "Batch expiration", batchExpirySelected: "Selected {count} keys", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index f15f92f063..2a4d6beef2 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -5550,6 +5550,8 @@ export default withEnglishFallback({ deleteKey: "Eliminar clave", deleteGroupDetails: "{target}\n{count} claves", deleteLoadedSearchKeysDetails: "{target}\n{count} claves coincidentes cargadas", + deleteGroupSubtreeDetails: "{target}\nSe eliminarán todas las claves de este grupo ({count} cargadas)", + deleteGroupSubtreeSuccess: "Se eliminaron {count} claves", batchExpiry: "Establecer caducidad", batchExpiryTitle: "Caducidad por lotes", batchExpirySelected: "{count} claves seleccionadas", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 9b1d813055..accff01436 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -5473,6 +5473,8 @@ export default withEnglishFallback({ deleteKey: "Elimina chiave", deleteGroupDetails: "{target}\n{count} chiavi", deleteLoadedSearchKeysDetails: "{target}\n{count} chiavi corrispondenti caricate", + deleteGroupSubtreeDetails: "{target}\nVerranno eliminate tutte le chiavi di questo gruppo ({count} caricate)", + deleteGroupSubtreeSuccess: "Eliminate {count} chiavi", batchExpiry: "Imposta scadenza", batchExpiryTitle: "Scadenza in blocco", batchExpirySelected: "{count} chiavi selezionate", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 03c8e7d349..368fe21060 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -5500,6 +5500,8 @@ export default withEnglishFallback({ deleteKey: "キーを削除", deleteGroupDetails: "{target}\n{count}個のキー", deleteLoadedSearchKeysDetails: "{target}\n読み込み済みの一致キー {count} 件", + deleteGroupSubtreeDetails: "{target}\nこのグループ配下のキーをすべて削除します(読み込み済み {count} 件)", + deleteGroupSubtreeSuccess: "{count} 件のキーを削除しました", batchExpiry: "有効期限を設定", batchExpiryTitle: "有効期限の一括設定", batchExpirySelected: "{count} 件のキーを選択中", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 072b446433..e28a7d4111 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -5126,6 +5126,8 @@ export default withEnglishFallback({ deleteKey: "키 삭제", deleteGroupDetails: "{target}\n키 {count}개", deleteLoadedSearchKeysDetails: "{target}\n로드된 일치 키 {count}개", + deleteGroupSubtreeDetails: "{target}\n이 그룹 아래의 모든 키를 삭제합니다(로드됨 {count}개)", + deleteGroupSubtreeSuccess: "키 {count}개를 삭제했습니다", batchExpiry: "만료 시간 설정", batchExpiryTitle: "만료 시간 일괄 설정", batchExpirySelected: "키 {count}개 선택됨", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 1e69b1028c..524ea3151a 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -5474,6 +5474,8 @@ export default withEnglishFallback({ deleteKey: "Excluir chave", deleteGroupDetails: "{target}\n{count} chaves", deleteLoadedSearchKeysDetails: "{target}\n{count} chaves correspondentes carregadas", + deleteGroupSubtreeDetails: "{target}\nTodas as chaves deste grupo serão excluídas ({count} carregadas)", + deleteGroupSubtreeSuccess: "{count} chaves excluídas", batchExpiry: "Definir expiração", batchExpiryTitle: "Expiração em lote", batchExpirySelected: "{count} chaves selecionadas", diff --git a/apps/desktop/src/i18n/locales/ru.ts b/apps/desktop/src/i18n/locales/ru.ts index 1145982a3f..972af4072d 100644 --- a/apps/desktop/src/i18n/locales/ru.ts +++ b/apps/desktop/src/i18n/locales/ru.ts @@ -6494,6 +6494,8 @@ export default withEnglishFallback({ deleteKey: "Удалить ключ", deleteGroupDetails: "{target}\n{count} ключей", deleteLoadedSearchKeysDetails: "{target}\n{count} загруженных совпадающих ключей", + deleteGroupSubtreeDetails: "{target}\nБудут удалены все ключи этой группы (загружено {count})", + deleteGroupSubtreeSuccess: "Удалено ключей: {count}", batchExpiry: "Задать срок действия", batchExpiryTitle: "Пакетное истечение", batchExpirySelected: "Выбрано {count} ключей", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index c895b80101..d908e1951c 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -5330,6 +5330,8 @@ export default withEnglishFallback({ deleteKey: "Anahtarı sil", deleteGroupDetails: "{target}\n{count} anahtar", deleteLoadedSearchKeysDetails: "{target}\n{count} yüklenmiş eşleşen anahtar", + deleteGroupSubtreeDetails: "{target}\nBu grup altındaki tüm anahtarlar silinecek ({count} yüklendi)", + deleteGroupSubtreeSuccess: "{count} anahtar silindi", batchExpiry: "Süre belirle", batchExpiryTitle: "Toplu süre belirleme", batchExpirySelected: "{count} anahtar seçildi", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 06b0ecf1b2..90801a84be 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -5995,6 +5995,8 @@ export default withEnglishFallback({ deleteKey: "删除 key", deleteGroupDetails: "{target}\n{count} 个 key", deleteLoadedSearchKeysDetails: "{target}\n已加载的 {count} 个匹配 key", + deleteGroupSubtreeDetails: "{target}\n将删除该分组下的全部 key(已加载 {count} 个)", + deleteGroupSubtreeSuccess: "已删除 {count} 个 key", batchExpiry: "设置过期时间", batchExpiryTitle: "批量设置过期时间", batchExpirySelected: "已选择 {count} 个 key", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index c3b94da486..5b77a1b628 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -4789,6 +4789,8 @@ export default withEnglishFallback({ deleteKey: "刪除 key", deleteGroupDetails: "{target}\n{count} 個 key", deleteLoadedSearchKeysDetails: "{target}\n已載入的 {count} 個相符 key", + deleteGroupSubtreeDetails: "{target}\n將刪除該分組下的全部 key(已載入 {count} 個)", + deleteGroupSubtreeSuccess: "已刪除 {count} 個 key", batchExpiry: "設定過期時間", batchExpiryTitle: "批次設定過期時間", batchExpirySelected: "已選取 {count} 個 key", diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index b0bde108e9..9e367e720b 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -603,6 +603,7 @@ export const redisSetExpireAt = forward("redisSetExpireAt"); export const redisSetKeysTtl = forward("redisSetKeysTtl"); export const redisSetKeysExpireAt = forward("redisSetKeysExpireAt"); export const redisDeleteKeys = forward("redisDeleteKeys"); +export const redisDeleteKeysByPattern = forward("redisDeleteKeysByPattern"); export const redisFlushDb = forward("redisFlushDb"); export const redisExecuteCommand = forward("redisExecuteCommand"); export const redisLoadMore = forward("redisLoadMore"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 19bfdb38bb..4d06b38149 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -3655,6 +3655,10 @@ export async function redisDeleteKeys(connectionId: string, db: number, keyRaws: return post("/api/redis/delete-keys", { connectionId, db, keyRaws }); } +export async function redisDeleteKeysByPattern(connectionId: string, db: number, pattern: string): Promise { + return post("/api/redis/delete-keys-by-pattern", { connectionId, db, pattern }); +} + export async function redisFlushDb(connectionId: string, db: number): Promise { return post("/api/redis/flush-db", { connectionId, db }); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 356a443e4b..e94b4a920a 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -3385,6 +3385,10 @@ export async function redisDeleteKeys(connectionId: string, db: number, keyRaws: return invoke("redis_delete_keys", { connectionId, db, keyRaws }); } +export async function redisDeleteKeysByPattern(connectionId: string, db: number, pattern: string): Promise { + return invoke("redis_delete_keys_by_pattern", { connectionId, db, pattern }); +} + export async function redisFlushDb(connectionId: string, db: number): Promise { return invoke("redis_flush_db", { connectionId, db }); } diff --git a/crates/dbx-core/src/query/redis_ops.rs b/crates/dbx-core/src/query/redis_ops.rs index 9582c74cd2..2f3e179db8 100644 --- a/crates/dbx-core/src/query/redis_ops.rs +++ b/crates/dbx-core/src/query/redis_ops.rs @@ -1075,6 +1075,66 @@ pub async fn redis_delete_keys_in_db_core( } } +/// Deletes every key under a key-browser group. +/// +/// The browser only holds the part of a group's subtree it has scanned, so a +/// delete driven by the loaded rows silently leaves the rest behind. The group +/// is identified by its `SCAN MATCH` pattern instead, and both the direct and +/// the cluster path walk the whole keyspace for that pattern. +pub async fn redis_delete_keys_by_pattern_in_db_core( + state: &AppState, + connection_id: &str, + db: u32, + pattern: &str, +) -> Result { + ensure_redis_pool(state, connection_id).await?; + let pool = state.pool_handle(connection_id).await.ok_or("Not found")?; + match &pool { + PoolKind::Redis(redis) => match redis.as_ref() { + RedisConnection::Direct(con) => { + let mut con = con.lock().await; + redis_driver::select_db(&mut *con, db).await?; + redis_driver::delete_keys_by_pattern( + &mut *con, + pattern, + redis_driver::DELETE_KEYS_BY_PATTERN_SCAN_COUNT, + ) + .await + } + RedisConnection::Cluster(cluster) => { + redis_driver::ensure_cluster_db(db)?; + // One node's SCAN only covers its own slots, so the subtree is + // scanned node-by-node through the shared cluster cursor and + // every key keeps its slot-routed connection, like + // `redis_delete_keys_in_db_core`. + let mut cursor = 0u64; + let mut deleted = 0u64; + loop { + let page = redis_driver::scan_cluster_keys_batch( + cluster, + cursor, + pattern, + redis_driver::DELETE_KEYS_BY_PATTERN_SCAN_COUNT, + 1, + false, + ) + .await?; + for key in &page.keys { + let raw = redis_driver::redis_key_raw_to_bytes(&key.key_raw)?; + let mut con = redis_driver::cluster_key_connection(cluster, &raw).await?; + deleted += redis_driver::delete_keys(&mut con, std::slice::from_ref(&raw)).await?; + } + cursor = page.cursor; + if cursor == 0 { + return Ok(deleted); + } + } + } + }, + _ => Err("Not a Redis connection".to_string()), + } +} + pub async fn redis_flush_db_core(state: &AppState, connection_id: &str, db: u32) -> Result<(), String> { ensure_redis_pool(state, connection_id).await?; let pool = state.pool_handle(connection_id).await.ok_or("Not found")?; diff --git a/crates/dbx-drivers/src/db/redis_driver.rs b/crates/dbx-drivers/src/db/redis_driver.rs index 0dd5e1fcea..1eea06696b 100644 --- a/crates/dbx-drivers/src/db/redis_driver.rs +++ b/crates/dbx-drivers/src/db/redis_driver.rs @@ -3867,6 +3867,40 @@ where cmd.query_async(con).await.map_err(|e| e.to_string()) } +/// `SCAN COUNT` hint used by [`delete_keys_by_pattern`]; large enough to keep +/// the cycle count low, small enough that one cycle stays cheap on the server. +pub const DELETE_KEYS_BY_PATTERN_SCAN_COUNT: usize = 500; +/// Upper bound on the keys carried by a single `DEL` command. +const DELETE_KEYS_BY_PATTERN_DELETE_BATCH: usize = 512; + +/// Deletes every key matching `pattern` with repeated `SCAN` + `DEL` cycles. +/// +/// A group in the key browser is a key *prefix*: the loaded rows are only the +/// part of that subtree the browser has scanned so far, so deleting just those +/// rows leaves the rest of the subtree behind. `KEYS` or one oversized `DEL` +/// would block the server for a large subtree, so keys are discovered with one +/// `SCAN` cycle at a time and deleted in bounded batches. +pub async fn delete_keys_by_pattern(con: &mut C, pattern: &str, scan_count: usize) -> Result +where + C: ConnectionLike + Send + Sync + Unpin, +{ + let count = scan_count.max(1); + let mut cursor = 0u64; + let mut deleted = 0u64; + loop { + let page = scan_keys_batch_inner(con, cursor, pattern, count, 1, false, false).await?; + let keys: Result>, String> = + page.keys.iter().map(|key| redis_key_raw_to_bytes(&key.key_raw)).collect(); + for batch in keys?.chunks(DELETE_KEYS_BY_PATTERN_DELETE_BATCH) { + deleted += delete_keys(con, batch).await?; + } + cursor = page.cursor; + if cursor == 0 { + return Ok(deleted); + } + } +} + pub async fn load_more_collection( con: &mut C, key: &[u8], diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 034f29be15..befe8f356d 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -868,6 +868,7 @@ async fn serve() { .route("/redis/set-keys-ttl", post(routes::redis::set_keys_ttl)) .route("/redis/set-keys-expire-at", post(routes::redis::set_keys_expire_at)) .route("/redis/delete-keys", post(routes::redis::delete_keys)) + .route("/redis/delete-keys-by-pattern", post(routes::redis::delete_keys_by_pattern)) .route("/redis/flush-db", post(routes::redis::flush_db)) .route("/redis/execute-command", post(routes::redis::execute_command)) .route("/redis/pubsub/publish", post(routes::redis::publish_message)) diff --git a/crates/dbx-web/src/routes/redis.rs b/crates/dbx-web/src/routes/redis.rs index 71c0b715ae..6ccaf154e3 100644 --- a/crates/dbx-web/src/routes/redis.rs +++ b/crates/dbx-web/src/routes/redis.rs @@ -289,6 +289,14 @@ pub struct RedisKeysRequest { pub key_raws: Vec, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RedisKeysByPatternRequest { + pub connection_id: String, + pub db: u32, + pub pattern: String, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct RedisDbRequest { @@ -856,6 +864,22 @@ pub async fn delete_keys( Ok(Json(result)) } +pub async fn delete_keys_by_pattern( + State(state): State>, + Json(req): Json, +) -> Result, AppError> { + ensure_writable(&state.app, &req.connection_id, "Delete keys").await?; + let result = dbx_core::redis_ops::redis_delete_keys_by_pattern_in_db_core( + &state.app, + &req.connection_id, + req.db, + &req.pattern, + ) + .await + .map_err(AppError::from)?; + Ok(Json(result)) +} + pub async fn flush_db( State(state): State>, Json(req): Json, diff --git a/src-tauri/src/commands/redis_cmd.rs b/src-tauri/src/commands/redis_cmd.rs index 4e8cfd78f7..d193c676a8 100644 --- a/src-tauri/src/commands/redis_cmd.rs +++ b/src-tauri/src/commands/redis_cmd.rs @@ -479,6 +479,17 @@ pub async fn redis_delete_keys( dbx_core::redis_ops::redis_delete_keys_in_db_core(&state, &connection_id, db, &key_raws).await } +#[tauri::command] +pub async fn redis_delete_keys_by_pattern( + state: State<'_, Arc>, + connection_id: String, + db: u32, + pattern: String, +) -> Result { + ensure_connection_writable(&state, &connection_id, "Delete keys").await?; + dbx_core::redis_ops::redis_delete_keys_by_pattern_in_db_core(&state, &connection_id, db, &pattern).await +} + #[tauri::command] pub async fn redis_flush_db(state: State<'_, Arc>, connection_id: String, db: u32) -> Result<(), String> { ensure_connection_writable(&state, &connection_id, "FLUSHDB").await?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 35ac665a10..2e0262bb28 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2200,6 +2200,7 @@ pub fn run() { commands::redis_cmd::redis_set_keys_ttl, commands::redis_cmd::redis_set_keys_expire_at, commands::redis_cmd::redis_delete_keys, + commands::redis_cmd::redis_delete_keys_by_pattern, commands::redis_cmd::redis_flush_db, commands::redis_cmd::redis_execute_command, commands::redis_cmd::redis_load_more,