From 774f175c3394e15b9247f9ab78437561d9de5e93 Mon Sep 17 00:00:00 2001 From: jearhe <0verme7753@gmail.com> Date: Sat, 12 Sep 2026 09:28:40 +0800 Subject: [PATCH] feat(redis): batch key expiry updates --- .../redis/RedisKeyBrowser.expansion.spec.ts | 4 + .../redis/RedisKeyBrowser.expiry.spec.ts | 201 ++++++++++++++++++ .../RedisKeyBrowser.infiniteScroll.spec.ts | 4 + .../RedisKeyBrowser.searchHistory.spec.ts | 4 + .../redis/RedisKeyBrowser.subtreeFill.spec.ts | 4 + .../redis/RedisKeyBrowser.tabState.spec.ts | 4 + .../src/components/redis/RedisKeyBrowser.vue | 184 +++++++++++++++- apps/desktop/src/i18n/locales/az.ts | 6 + apps/desktop/src/i18n/locales/en.ts | 6 + apps/desktop/src/i18n/locales/es.ts | 6 + apps/desktop/src/i18n/locales/it.ts | 6 + apps/desktop/src/i18n/locales/ja.ts | 6 + apps/desktop/src/i18n/locales/ko.ts | 6 + apps/desktop/src/i18n/locales/pt-BR.ts | 6 + apps/desktop/src/i18n/locales/tr.ts | 6 + apps/desktop/src/i18n/locales/zh-CN.ts | 6 + apps/desktop/src/i18n/locales/zh-TW.ts | 6 + .../__tests__/redis/RedisExpiryWiring.spec.ts | 54 +++++ .../backend/__tests__/redisExpiryApi.spec.ts | 35 +++ apps/desktop/src/lib/backend/api.ts | 3 + apps/desktop/src/lib/backend/http.ts | 9 + apps/desktop/src/lib/backend/tauri.ts | 13 ++ .../desktop/src/lib/redis/redisExpiry.spec.ts | 78 ++++++- apps/desktop/src/lib/redis/redisExpiry.ts | 47 ++++ apps/desktop/src/lib/redis/redisKeyBatch.ts | 6 +- crates/dbx-core/src/db/redis_driver.rs | 178 ++++++++++++++++ crates/dbx-core/src/redis_ops.rs | 66 +++++- .../dbx-core/tests/live_redis_batch_expiry.rs | 145 +++++++++++++ crates/dbx-web/src/main.rs | 2 + crates/dbx-web/src/routes/redis.rs | 53 +++++ docs/content/docs/redis.cn.mdx | 1 + docs/content/docs/redis.mdx | 1 + docs/content/docs/redis.tr.mdx | 1 + packages/app-tests/redisKeyBatch.test.ts | 18 +- src-tauri/src/commands/redis_cmd.rs | 28 ++- src-tauri/src/lib.rs | 2 + 36 files changed, 1185 insertions(+), 20 deletions(-) create mode 100644 crates/dbx-core/tests/live_redis_batch_expiry.rs diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.expansion.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.expansion.spec.ts index 0b31ea8a20..58e5843d57 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.expansion.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.expansion.spec.ts @@ -17,6 +17,8 @@ const mocks = vi.hoisted(() => ({ redisStreamAdd: vi.fn(), redisSetTtl: vi.fn(), redisSetExpireAt: vi.fn(), + redisSetKeysTtl: vi.fn(), + redisSetKeysExpireAt: vi.fn(), redisCheckJsonModule: vi.fn(), redisDeleteKey: vi.fn(), redisDeleteKeys: vi.fn(), @@ -45,6 +47,8 @@ vi.mock("@/lib/backend/api", () => ({ redisStreamAdd: mocks.redisStreamAdd, redisSetTtl: mocks.redisSetTtl, redisSetExpireAt: mocks.redisSetExpireAt, + redisSetKeysTtl: mocks.redisSetKeysTtl, + redisSetKeysExpireAt: mocks.redisSetKeysExpireAt, redisCheckJsonModule: mocks.redisCheckJsonModule, redisDeleteKey: mocks.redisDeleteKey, redisDeleteKeys: mocks.redisDeleteKeys, diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts index 45496576a9..146034eca7 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts @@ -19,6 +19,8 @@ const mocks = vi.hoisted(() => ({ redisStreamAdd: vi.fn(), redisSetTtl: vi.fn(), redisSetExpireAt: vi.fn(), + redisSetKeysTtl: vi.fn(), + redisSetKeysExpireAt: vi.fn(), redisCheckJsonModule: vi.fn(), redisDeleteKey: vi.fn(), redisDeleteKeys: vi.fn(), @@ -58,6 +60,8 @@ vi.mock("@/lib/backend/api", () => ({ redisStreamAdd: mocks.redisStreamAdd, redisSetTtl: mocks.redisSetTtl, redisSetExpireAt: mocks.redisSetExpireAt, + redisSetKeysTtl: mocks.redisSetKeysTtl, + redisSetKeysExpireAt: mocks.redisSetKeysExpireAt, redisCheckJsonModule: mocks.redisCheckJsonModule, redisDeleteKey: mocks.redisDeleteKey, redisDeleteKeys: mocks.redisDeleteKeys, @@ -491,6 +495,8 @@ function resetApiMocks() { mocks.redisStreamAdd.mockResolvedValue(undefined); mocks.redisSetTtl.mockResolvedValue(undefined); mocks.redisSetExpireAt.mockResolvedValue(undefined); + mocks.redisSetKeysTtl.mockResolvedValue({ applied: 0, missing_key_raws: [] }); + mocks.redisSetKeysExpireAt.mockResolvedValue({ applied: 0, missing_key_raws: [] }); mocks.redisCheckJsonModule.mockResolvedValue(true); mocks.redisDeleteKey.mockResolvedValue(undefined); mocks.redisDeleteKeys.mockResolvedValue(0); @@ -518,6 +524,9 @@ function mountBrowser(withDeleteDetails = false) { redis: { deleteGroupDetails: withDeleteDetails ? "{target}\n{count} keys" : "redis.deleteGroupDetails", keys: "{count} keys", + batchExpirySelected: "redis.batchExpirySelected", + batchExpirySuccess: "applied {count}", + batchExpiryPartial: "applied {success} failed {failed}", }, }, }; @@ -1353,6 +1362,198 @@ describe("RedisKeyBrowser expiry creation", () => { }); }); +describe("RedisKeyBrowser batch expiration", () => { + const batchKeys = [ + { key_display: "alpha", key_raw: "YWxwaGE=", key_type: "string", ttl: -1 }, + { key_display: "bravo", key_raw: "YnJhdm8=", key_type: "string", ttl: -1 }, + ]; + + function batchExpiryButton(): HTMLButtonElement | null { + return document.querySelector("[data-redis-batch-expiry]"); + } + + async function openBatchExpiryDialog(rows = batchKeys) { + mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: rows, total_keys: rows.length }); + mountBrowser(); + await settle(); + requiredElement("[data-redis-select-all]").click(); + await settle(); + requiredElement("[data-redis-batch-expiry]").click(); + await settle(); + } + + async function applyBatchExpiry() { + requiredElement("[data-redis-batch-expiry-apply]").click(); + await settle(); + } + + function rowTtlBadge(label: string): HTMLElement | null { + return groupRow(label).querySelector("span[title]"); + } + + it("only offers the action for a non-empty selection and applies one TTL to every key", async () => { + mocks.redisSetKeysTtl.mockResolvedValue({ applied: 2, missing_key_raws: [] }); + await openBatchExpiryDialog(); + + expect(document.body.textContent).toContain("redis.batchExpirySelected"); + await select("ttl"); + await setInput("[data-redis-batch-expiry-ttl]", "3600"); + await applyBatchExpiry(); + + // One request for the whole selection, never one call per key. + expect(mocks.redisSetKeysTtl).toHaveBeenCalledTimes(1); + expect(mocks.redisSetKeysTtl).toHaveBeenCalledWith("connection", 0, ["YWxwaGE=", "YnJhdm8="], 3600); + expect(mocks.redisSetKeysExpireAt).not.toHaveBeenCalled(); + expect(mocks.toast).toHaveBeenCalledWith("applied 2", 3000); + // A fully applied batch releases the selection like a completed batch delete does. + expect(document.querySelector("[data-redis-batch-delete]")).toBeNull(); + expect(document.querySelector("[data-test-dialog]")).toBeNull(); + }); + + it("applies PERSIST to every selected key when no expiry is chosen", async () => { + mocks.redisSetKeysTtl.mockResolvedValue({ applied: 2, missing_key_raws: [] }); + await openBatchExpiryDialog(); + + await applyBatchExpiry(); + + expect(mocks.redisSetKeysTtl).toHaveBeenCalledWith("connection", 0, ["YWxwaGE=", "YnJhdm8="], -1); + expect(mocks.redisSetKeysExpireAt).not.toHaveBeenCalled(); + }); + + it("applies the same absolute expiration time to every selected key", async () => { + mocks.redisSetKeysExpireAt.mockResolvedValue({ applied: 2, missing_key_raws: [] }); + await openBatchExpiryDialog(); + + await select("at"); + requiredElement("[data-test-absolute-date]").click(); + await settle(); + await applyBatchExpiry(); + + const expireAt = calendarDateTimeToUnixSeconds(new CalendarDateTime(2030, 1, 2, 3, 4, 5)); + expect(mocks.redisSetKeysExpireAt).toHaveBeenCalledTimes(1); + expect(mocks.redisSetKeysExpireAt).toHaveBeenCalledWith("connection", 0, ["YWxwaGE=", "YnJhdm8="], expireAt); + expect(mocks.redisSetKeysTtl).not.toHaveBeenCalled(); + }); + + it("rejects an invalid TTL before reaching the backend and keeps the dialog open", async () => { + await openBatchExpiryDialog(); + + await select("ttl"); + await setInput("[data-redis-batch-expiry-ttl]", "0"); + await applyBatchExpiry(); + + expect(mocks.redisSetKeysTtl).not.toHaveBeenCalled(); + expect(mocks.toast).toHaveBeenCalledWith("redis.expiryTtlInvalid", 3000); + expect(document.querySelector("[data-redis-batch-expiry-apply]")).not.toBeNull(); + }); + + it("keeps only the keys the server did not update selected so they can be retried", async () => { + mocks.redisSetKeysTtl.mockResolvedValueOnce({ applied: 1, missing_key_raws: ["YnJhdm8="] }); + mocks.redisSetKeysTtl.mockResolvedValueOnce({ applied: 1, missing_key_raws: [] }); + await openBatchExpiryDialog(); + + await select("ttl"); + await setInput("[data-redis-batch-expiry-ttl]", "3600"); + await applyBatchExpiry(); + + expect(mocks.toast).toHaveBeenCalledWith("applied 1 failed 1", 5000); + expect(requiredElement("[data-redis-batch-delete]").textContent).toContain("1"); + + // Retrying only targets the key the server missed. + requiredElement("[data-redis-batch-expiry]").click(); + await settle(); + await select("ttl"); + await setInput("[data-redis-batch-expiry-ttl]", "120"); + await applyBatchExpiry(); + + expect(mocks.redisSetKeysTtl).toHaveBeenNthCalledWith(2, "connection", 0, ["YnJhdm8="], 120); + expect(document.querySelector("[data-redis-batch-delete]")).toBeNull(); + }); + + it("reports a failed chunk together with the keys an earlier chunk already updated", async () => { + mocks.redisSetKeysTtl.mockResolvedValueOnce({ applied: 1_000, missing_key_raws: [] }); + mocks.redisSetKeysTtl.mockRejectedValueOnce(new Error("connection lost")); + const rows = Array.from({ length: 1_001 }, (_, index) => ({ + key_display: `batch:${String(index).padStart(4, "0")}`, + key_raw: `cmF3LWJhdGNoLS${index}`, + key_type: "string", + ttl: -1, + })); + await openBatchExpiryDialog(rows); + + await select("ttl"); + await setInput("[data-redis-batch-expiry-ttl]", "3600"); + await applyBatchExpiry(); + + // Bounded chunks keep a 1001-key selection to two requests. + expect(mocks.redisSetKeysTtl.mock.calls.map((call) => call[2].length)).toEqual([1_000, 1]); + expect(mocks.toast).toHaveBeenCalledWith("applied 1000 failed 1", 5000); + expect(mocks.toast).toHaveBeenCalledWith("connection lost", 5000); + expect(requiredElement("[data-redis-batch-delete]").textContent).toContain("1"); + }); + + it("does not start a second request while the batch is still running", async () => { + const pending = deferred<{ applied: number; missing_key_raws: string[] }>(); + mocks.redisSetKeysTtl.mockReturnValue(pending.promise); + await openBatchExpiryDialog(); + + await select("ttl"); + await setInput("[data-redis-batch-expiry-ttl]", "3600"); + + const applyButton = requiredElement("[data-redis-batch-expiry-apply]"); + applyButton.click(); + await settle(); + applyButton.click(); + await settle(); + + expect(mocks.redisSetKeysTtl).toHaveBeenCalledTimes(1); + // A conflicting batch delete stays blocked while the expiry request owns the selection. + expect(requiredElement("[data-redis-batch-delete]").disabled).toBe(true); + + pending.resolve({ applied: 2, missing_key_raws: [] }); + await settle(); + expect(mocks.toast).toHaveBeenCalledWith("applied 2", 3000); + }); + + it("updates the loaded TTL badge without rescanning the key list", async () => { + const rows = [{ key_display: "alpha", key_raw: "YWxwaGE=", key_type: "string", ttl: -1 }]; + mocks.redisSetKeysTtl.mockResolvedValue({ applied: 1, missing_key_raws: [] }); + await openBatchExpiryDialog(rows); + + expect(rowTtlBadge("alpha")?.textContent).toContain("redis.noExpiry"); + expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(1); + + await select("ttl"); + await setInput("[data-redis-batch-expiry-ttl]", "3600"); + await applyBatchExpiry(); + + // The applied TTL is reflected locally instead of one refresh request per key. + expect(rowTtlBadge("alpha")?.textContent).toContain("redis.ttlHour"); + expect(rowTtlBadge("alpha")?.textContent).not.toContain("redis.noExpiry"); + expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(1); + + // PERSIST flips the same badge back to the no-expiry label. + requiredElement("[data-redis-select-all]").click(); + await settle(); + requiredElement("[data-redis-batch-expiry]").click(); + await settle(); + await applyBatchExpiry(); + + expect(mocks.redisSetKeysTtl).toHaveBeenLastCalledWith("connection", 0, ["YWxwaGE="], -1); + expect(rowTtlBadge("alpha")?.textContent).toContain("redis.noExpiry"); + expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(1); + }); + + it("hides the action while nothing is selected", async () => { + mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: batchKeys, total_keys: batchKeys.length }); + mountBrowser(); + await settle(); + + expect(batchExpiryButton()).toBeNull(); + expect(document.querySelector("[data-redis-batch-expiry-apply]")).toBeNull(); + }); +}); + describe("RedisKeyBrowser fuzzy key hierarchy", () => { it("deletes a leaf directly from the key list after confirmation", async () => { const key = { key_display: "session:current", key_raw: "c2Vzc2lvbjpjdXJyZW50", key_type: "string", ttl: -1 }; diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.infiniteScroll.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.infiniteScroll.spec.ts index 6fa92edbad..a847bcbb38 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.infiniteScroll.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.infiniteScroll.spec.ts @@ -26,6 +26,8 @@ const mocks = vi.hoisted(() => ({ redisStreamAdd: vi.fn(), redisSetTtl: vi.fn(), redisSetExpireAt: vi.fn(), + redisSetKeysTtl: vi.fn(), + redisSetKeysExpireAt: vi.fn(), redisCheckJsonModule: vi.fn(), redisDeleteKey: vi.fn(), redisDeleteKeys: vi.fn(), @@ -54,6 +56,8 @@ vi.mock("@/lib/backend/api", () => ({ redisStreamAdd: mocks.redisStreamAdd, redisSetTtl: mocks.redisSetTtl, redisSetExpireAt: mocks.redisSetExpireAt, + redisSetKeysTtl: mocks.redisSetKeysTtl, + redisSetKeysExpireAt: mocks.redisSetKeysExpireAt, redisCheckJsonModule: mocks.redisCheckJsonModule, redisDeleteKey: mocks.redisDeleteKey, redisDeleteKeys: mocks.redisDeleteKeys, diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.searchHistory.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.searchHistory.spec.ts index 439c9e1953..f9a15a97ad 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.searchHistory.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.searchHistory.spec.ts @@ -17,6 +17,8 @@ const mocks = vi.hoisted(() => ({ redisStreamAdd: vi.fn(), redisSetTtl: vi.fn(), redisSetExpireAt: vi.fn(), + redisSetKeysTtl: vi.fn(), + redisSetKeysExpireAt: vi.fn(), redisCheckJsonModule: vi.fn(), redisDeleteKey: vi.fn(), redisDeleteKeys: vi.fn(), @@ -44,6 +46,8 @@ vi.mock("@/lib/backend/api", () => ({ redisStreamAdd: mocks.redisStreamAdd, redisSetTtl: mocks.redisSetTtl, redisSetExpireAt: mocks.redisSetExpireAt, + redisSetKeysTtl: mocks.redisSetKeysTtl, + redisSetKeysExpireAt: mocks.redisSetKeysExpireAt, redisCheckJsonModule: mocks.redisCheckJsonModule, redisDeleteKey: mocks.redisDeleteKey, redisDeleteKeys: mocks.redisDeleteKeys, diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.subtreeFill.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.subtreeFill.spec.ts index be92e05ba3..ac68d3591c 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.subtreeFill.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.subtreeFill.spec.ts @@ -17,6 +17,8 @@ const mocks = vi.hoisted(() => ({ redisStreamAdd: vi.fn(), redisSetTtl: vi.fn(), redisSetExpireAt: vi.fn(), + redisSetKeysTtl: vi.fn(), + redisSetKeysExpireAt: vi.fn(), redisCheckJsonModule: vi.fn(), redisDeleteKey: vi.fn(), redisDeleteKeys: vi.fn(), @@ -44,6 +46,8 @@ vi.mock("@/lib/backend/api", () => ({ redisStreamAdd: mocks.redisStreamAdd, redisSetTtl: mocks.redisSetTtl, redisSetExpireAt: mocks.redisSetExpireAt, + redisSetKeysTtl: mocks.redisSetKeysTtl, + redisSetKeysExpireAt: mocks.redisSetKeysExpireAt, redisCheckJsonModule: mocks.redisCheckJsonModule, redisDeleteKey: mocks.redisDeleteKey, redisDeleteKeys: mocks.redisDeleteKeys, diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.tabState.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.tabState.spec.ts index dc789a91b3..69952638ac 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.tabState.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.tabState.spec.ts @@ -17,6 +17,8 @@ const mocks = vi.hoisted(() => ({ redisStreamAdd: vi.fn(), redisSetTtl: vi.fn(), redisSetExpireAt: vi.fn(), + redisSetKeysTtl: vi.fn(), + redisSetKeysExpireAt: vi.fn(), redisCheckJsonModule: vi.fn(), redisDeleteKey: vi.fn(), redisDeleteKeys: vi.fn(), @@ -44,6 +46,8 @@ vi.mock("@/lib/backend/api", () => ({ redisStreamAdd: mocks.redisStreamAdd, redisSetTtl: mocks.redisSetTtl, redisSetExpireAt: mocks.redisSetExpireAt, + redisSetKeysTtl: mocks.redisSetKeysTtl, + redisSetKeysExpireAt: mocks.redisSetKeysExpireAt, redisCheckJsonModule: mocks.redisCheckJsonModule, redisDeleteKey: mocks.redisDeleteKey, redisDeleteKeys: mocks.redisDeleteKeys, diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index 1ad7c4a873..52941f9960 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -65,7 +65,7 @@ import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern"; import { chunkRedisKeyRaws, collectUniqueRedisKeys } from "@/lib/redis/redisKeyBatch"; import { getRedisCreateKeyTypeHelp, redisCreateKeyTypeHelpOptionOnOpen, shouldActivateRedisCreateKeyTypeHelpOnFocus } from "@/lib/redis/redisCreateKeyTypeHelp"; import { optionHelpPanelOffsetTop } from "@/lib/common/optionHelpPanelOffset"; -import { applyRedisExpiryPolicy, type RedisExpiryMode, validateRedisExpiry } from "@/lib/redis/redisExpiry"; +import { applyRedisBatchExpiryPolicy, applyRedisExpiryPolicy, type RedisExpiryMode, type RedisExpiryPolicy, validateRedisExpiry } from "@/lib/redis/redisExpiry"; import { shouldLoadMoreRedisKeys } from "@/lib/redis/redisKeyInfiniteScroll"; import { formatTtl } from "@/lib/common/ttlFormat"; import { computeTtlCountdownValue } from "@/lib/redis/redisAutoRefresh"; @@ -136,6 +136,11 @@ const redisExpiryTransport = { setExpireAt: api.redisSetExpireAt, }; +const redisBatchExpiryTransport = { + setKeysTtl: api.redisSetKeysTtl, + setKeysExpireAt: api.redisSetKeysExpireAt, +}; + const flatKeys = shallowRef([]); const treeKeys = shallowRef([]); let flatKeyByRaw = new Map(); @@ -178,6 +183,13 @@ const noExpiryProjectionEpoch = ref(0); const selectionAnchorRowId = ref(null); const selectedGroupLeafCounts = shallowRef>(new Map()); const deletingKeys = ref(false); +const showBatchExpiryDialog = ref(false); +const savingBatchExpiry = ref(false); +const batchExpiryMode = ref("none"); +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 showDangerConfirm = ref(false); const commandText = ref(""); @@ -293,7 +305,9 @@ const isFuzzyKeySearch = computed(() => searchMode.value === "key" && isSearchMo const fuzzyTreeLimitReached = computed(() => isFuzzyKeySearch.value && !canBuildRedisFuzzyTree(flatKeys.value.length)); const useFlatKeySearchRows = computed(() => showCustomGrouping.value || (searchMode.value === "key" && isSearchMode.value && !fuzzyKeySearch.value) || fuzzyTreeLimitReached.value); const isFuzzyHierarchyView = computed(() => isFuzzyKeySearch.value && !fuzzyTreeLimitReached.value); -const selectionBusy = computed(() => deletingKeys.value || loading.value || loadingMore.value || isFetchingAll.value || searchPending.value); +/** True while a key-list mutation (delete or batch expiry) owns the visible result set. */ +const mutatingKeys = computed(() => deletingKeys.value || savingBatchExpiry.value); +const selectionBusy = computed(() => mutatingKeys.value || loading.value || loadingMore.value || isFetchingAll.value || searchPending.value); // checkedKeys is always a subset of loaded keys, so size equality is enough. const allLoadedKeysSelected = computed(() => flatKeys.value.length > 0 && checkedKeys.value.size === flatKeys.value.length); const allKeysSelected = computed(() => (customGrouping.value.enabled ? filteredFlatKeys.value.length > 0 && checkedKeys.value.size === filteredFlatKeys.value.length : allLoadedKeysSelected.value && !hasMore.value)); @@ -1142,7 +1156,7 @@ function onRedisKeyScroll(event: Event) { const shouldLoad = shouldLoadMoreRedisKeys({ enabled: redisInfiniteScrollEnabled.value, hasMore: hasMore.value, - busy: loading.value || loadingMore.value || searchPending.value || deletingKeys.value || isFetchingAll.value, + busy: loading.value || loadingMore.value || searchPending.value || mutatingKeys.value || isFetchingAll.value, loadedKeys: flatKeys.value.length, maxKeys: redisInfiniteScrollMaxKeys.value, scrollTop: scroller.scrollTop, @@ -1789,6 +1803,116 @@ function scrollCommandTerminalToEnd() { }); } +// --------------------------------------------------------------------------- +// Batch expiration +// --------------------------------------------------------------------------- + +/** Keeps only the given loaded keys checked, so a partial batch failure can be retried. */ +function retainCheckedKeys(keyRaws: Iterable) { + const keep = new Set(keyRaws); + const nextChecked = new Set(); + for (const keyRaw of checkedKeys.value) { + if (keep.has(keyRaw) && loadedKeyRaws.has(keyRaw)) nextChecked.add(keyRaw); + } + checkedKeys.value = nextChecked; + selectedGroupLeafCounts.value = groupLeafCountsFromChecked(nextChecked); + selectionEpoch.value++; +} + +/** + * Writes a server-confirmed TTL straight into one loaded row, and reports + * whether its no-expiry membership changed. + * + * The batch result already carries the applied TTL, so the list badge and its + * countdown update without one refresh request per key. + */ +function applyKeyTtlMetadata(keyRaw: string, ttl: number): boolean { + const keyInfo = flatKeyByRaw.get(keyRaw); + if (!keyInfo) return false; + const previousTtl = keyInfo.ttl ?? -2; + const nextKeyInfo: RedisKeyInfo = { ...keyInfo, ttl }; + if (!updateRedisKeyInfoMetadataByRaw(flatKeyByRaw, nextKeyInfo)) return false; + recordKeyTtlObservedAt(nextKeyInfo); + if (treeIndex) updateRedisKeyTreeLeafMetadata(treeIndex, nextKeyInfo); + return (previousTtl === -1) !== (ttl === -1); +} + +function applyBatchExpiryMetadata(keyRaws: readonly string[], policy: RedisExpiryPolicy) { + const nowSeconds = Math.ceil(Date.now() / 1_000); + let noExpiryMembershipChanged = false; + + for (const keyRaw of keyRaws) { + if (policy.mode === "at") { + const remaining = policy.expireAt - nowSeconds; + // EXPIREAT with an already-past timestamp deletes the key instead of expiring it. + if (remaining <= 0) { + onKeyDeleted(keyRaw); + continue; + } + if (applyKeyTtlMetadata(keyRaw, remaining)) noExpiryMembershipChanged = true; + continue; + } + if (applyKeyTtlMetadata(keyRaw, policy.mode === "ttl" ? policy.ttl : -1)) noExpiryMembershipChanged = true; + } + + // Publish one metadata refresh for the whole batch: a 1000-key selection must + // not schedule 1000 reactive updates or 1000 countdown-timer re-checks. + if (noExpiryMembershipChanged) noExpiryProjectionEpoch.value++; + keyMetadataEpoch.value++; + syncListTtlTimer(); +} + +function openBatchExpiryDialog() { + if (checkedKeys.value.size === 0 || selectionBusy.value) return; + batchExpiryKeyRaws.value = [...checkedKeys.value]; + batchExpiryMode.value = "none"; + batchExpiryTtl.value = ""; + batchExpiryExpireAt.value = null; + showBatchExpiryDialog.value = true; +} + +function onBatchExpiryDialogOpenChange(open: boolean) { + if (!open && savingBatchExpiry.value) return; + showBatchExpiryDialog.value = open; +} + +async function saveBatchExpiry() { + if (savingBatchExpiry.value || batchExpiryKeyRaws.value.length === 0) return; + const validation = validateRedisExpiry(batchExpiryMode.value, batchExpiryTtl.value, batchExpiryExpireAt.value); + if (!validation.valid) { + toast(expiryValidationMessage(validation.reason), 3000); + return; + } + + const keyRaws = batchExpiryKeyRaws.value; + savingBatchExpiry.value = true; + try { + const summary = await applyRedisBatchExpiryPolicy(redisBatchExpiryTransport, props.connectionId, props.db, keyRaws, validation.policy); + const failed = new Set(summary.failedKeyRaws); + applyBatchExpiryMetadata( + keyRaws.filter((keyRaw) => !failed.has(keyRaw)), + validation.policy, + ); + + if (failed.size > 0) { + // Keep only the keys that were not updated so the user can retry them directly. + retainCheckedKeys(failed); + toast(t("redis.batchExpiryPartial", { success: summary.applied, failed: failed.size }), 5000); + } else { + resetCheckedKeys(); + toast(t("redis.batchExpirySuccess", { count: summary.applied }), 3000); + } + // Surface the underlying cause as well; the count toast above keeps the partial result. + if (summary.errors.length > 0) toast(summary.errors[0], 5000); + } catch (error) { + toast(errorMessage(error), 5000); + } finally { + savingBatchExpiry.value = false; + showBatchExpiryDialog.value = false; + batchExpiryKeyRaws.value = []; + } +} + function appendCommandHistory(entry: Omit): number { const id = ++commandHistoryId; commandHistory.value = [...commandHistory.value, { id, ...entry }]; @@ -3044,8 +3168,9 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand }); >{{ customGrouping.enabled ? t("redisGrouping.selectLoaded") : t("redis.selectAllLoaded") }} + - @@ -3204,7 +3329,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });