feat(redis): batch key expiry updates

This commit is contained in:
jearhe authored and GitHub committed 2026-09-12 09:28:40 +08:00
1 parent 076c39bc21
commit 774f175c33
36 files changed
+1185 -20

No files matched your search

@@ -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,
@@ -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<HTMLButtonElement>("[data-redis-batch-expiry]");
}
async function openBatchExpiryDialog(rows = batchKeys) {
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: rows, total_keys: rows.length });
mountBrowser();
await settle();
requiredElement<HTMLButtonElement>("[data-redis-select-all]").click();
await settle();
requiredElement<HTMLButtonElement>("[data-redis-batch-expiry]").click();
await settle();
}
async function applyBatchExpiry() {
requiredElement<HTMLButtonElement>("[data-redis-batch-expiry-apply]").click();
await settle();
}
function rowTtlBadge(label: string): HTMLElement | null {
return groupRow(label).querySelector<HTMLElement>("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<HTMLButtonElement>("[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<HTMLElement>("[data-redis-batch-delete]").textContent).toContain("1");
// Retrying only targets the key the server missed.
requiredElement<HTMLButtonElement>("[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<HTMLElement>("[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<HTMLButtonElement>("[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<HTMLButtonElement>("[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<HTMLButtonElement>("[data-redis-select-all]").click();
await settle();
requiredElement<HTMLButtonElement>("[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 };
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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<RedisKeyInfo[]>([]);
const treeKeys = shallowRef<RedisKeyTreeNode[]>([]);
let flatKeyByRaw = new Map<string, RedisKeyInfo>();
@@ -178,6 +183,13 @@ const noExpiryProjectionEpoch = ref(0);
const selectionAnchorRowId = ref<string | null>(null);
const selectedGroupLeafCounts = shallowRef<Map<string, number>>(new Map());
const deletingKeys = ref(false);
const showBatchExpiryDialog = ref(false);
const savingBatchExpiry = ref(false);
const batchExpiryMode = ref<RedisExpiryMode>("none");
const batchExpiryTtl = ref("");
const batchExpiryExpireAt = shallowRef<CalendarDateTime | null>(null);
/** Snapshot taken when the dialog opens so a background refresh cannot retarget the batch. */
const batchExpiryKeyRaws = shallowRef<string[]>([]);
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<string>) {
const keep = new Set(keyRaws);
const nextChecked = new Set<string>();
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<RedisCommandHistoryEntry, "id">): 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") }}</Button
>
<Button v-if="checkedKeys.size > 0" variant="ghost" size="sm" class="h-6 shrink-0 px-1.5 text-xs" :disabled="selectionBusy" data-redis-deselect-all @click="clearAllCheckedKeys">{{ t("redis.deselectAll") }}</Button>
<Button v-if="checkedKeys.size > 0" variant="ghost" size="sm" class="h-6 shrink-0 px-1.5 text-xs" :disabled="selectionBusy" :title="t('redis.batchExpiry')" data-redis-batch-expiry @click="openBatchExpiryDialog"><Clock class="w-3 h-3 mr-1" />{{ t("redis.batchExpiry") }}</Button>
<Button v-if="checkedKeys.size > 0" variant="ghost" size="sm" class="h-6 shrink-0 text-xs text-destructive" :disabled="selectionBusy" data-redis-batch-delete @click="requestBatchDelete"><Trash2 class="w-3 h-3 mr-1" />{{ checkedKeys.size }}</Button>
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" :disabled="deletingKeys || loading || loadingMore || isFetchingAll" @click="loadKeys">
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" :disabled="mutatingKeys || loading || loadingMore || isFetchingAll" @click="loadKeys">
<Loader2 v-if="loading" class="h-3 w-3 animate-spin" />
<RefreshCw v-else class="h-3 w-3" />
</Button>
@@ -3204,7 +3329,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
<div v-else-if="flatKeys.length === 0 && !loading" class="flex-1 flex flex-col items-center justify-center text-muted-foreground text-xs p-4 text-center">
<template v-if="hasMore">
<span class="mb-3">{{ t("redis.noKeysInScanHint") }}</span>
<Button variant="outline" size="sm" class="h-7 text-xs" :disabled="loadingMore || searchPending || deletingKeys" @click="loadMore()">
<Button variant="outline" size="sm" class="h-7 text-xs" :disabled="loadingMore || searchPending || mutatingKeys" @click="loadMore()">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
</Button>
@@ -3303,11 +3428,11 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
{{ t("redis.fuzzyTreeLimit", { count: flatKeys.length }) }}
</div>
<div v-if="hasMore && !isFetchingAll" class="shrink-0 border-t px-2 py-1.5 flex items-center gap-1.5">
<Button variant="outline" size="sm" class="h-7 text-xs flex-1" :disabled="loadingMore || loading || searchPending || deletingKeys" @click="loadMore()">
<Button variant="outline" size="sm" class="h-7 text-xs flex-1" :disabled="loadingMore || loading || searchPending || mutatingKeys" @click="loadMore()">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
</Button>
<Button variant="outline" size="sm" class="h-7 text-xs flex-1" :disabled="loading || searchPending || deletingKeys || !hasMore" @click="fetchAll">
<Button variant="outline" size="sm" class="h-7 text-xs flex-1" :disabled="loading || searchPending || mutatingKeys || !hasMore" @click="fetchAll">
{{ t("redis.fetchAllKeys") }}
</Button>
</div>
@@ -3315,7 +3440,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
<div class="text-xs text-muted-foreground text-center">
{{ fetchAllProgressText }}
</div>
<Button variant="destructive" size="sm" class="h-7 text-xs w-full" :disabled="fetchAllStopRequested || deletingKeys" @click="stopFetchAll">
<Button variant="destructive" size="sm" class="h-7 text-xs w-full" :disabled="fetchAllStopRequested || mutatingKeys" @click="stopFetchAll">
{{ t("redis.stopFetchAll") }}
</Button>
</div>
@@ -3454,6 +3579,49 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
<DangerConfirmDialog v-model:open="showDangerConfirm" :message="dangerMessage" :details="dangerDetails" :confirm-label="dangerConfirmLabel" :loading="deletingKeys" :close-on-confirm="pendingDanger?.kind !== 'delete-keys'" @confirm="applyDangerAction" />
<Dialog :open="showBatchExpiryDialog" @update:open="onBatchExpiryDialogOpenChange">
<DialogContent class="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>{{ t("redis.batchExpiryTitle") }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3">
<p v-if="batchExpiryKeyRaws.length > 0" class="text-xs text-muted-foreground">{{ t("redis.batchExpirySelected", { count: batchExpiryKeyRaws.length }) }}</p>
<div class="grid gap-1.5 text-xs font-medium">
<span>{{ t("redis.expiry") }}</span>
<Select v-model="batchExpiryMode" :disabled="savingBatchExpiry">
<SelectTrigger class="h-8 text-xs" data-redis-batch-expiry-mode>
<SelectValue />
</SelectTrigger>
<SelectContent data-redis-expiry-mode-content>
<SelectItem value="none">{{ t("redis.expiryNone") }}</SelectItem>
<SelectItem value="ttl">{{ t("redis.expiryTtl") }}</SelectItem>
<SelectItem value="at">{{ t("redis.expiryAt") }}</SelectItem>
</SelectContent>
</Select>
<label v-if="batchExpiryMode === 'ttl'" class="grid gap-1.5 text-xs font-medium">
<span>{{ t("redis.createKeyTtl") }}</span>
<Input v-model="batchExpiryTtl" data-redis-batch-expiry-ttl class="dbx-editor-font-family h-8 text-xs" :disabled="savingBatchExpiry" inputmode="numeric" :placeholder="t('redis.createKeyTtlPlaceholder')" @keydown.enter="saveBatchExpiry" />
</label>
<label v-else-if="batchExpiryMode === 'at'" class="grid gap-1.5 text-xs font-medium">
<span>{{ t("redis.expiryAt") }}</span>
<DateTimePicker v-model="batchExpiryExpireAt" full-width :locale="locale" :disabled="savingBatchExpiry" />
</label>
</div>
</div>
<DialogFooter>
<Button variant="ghost" :disabled="savingBatchExpiry" @click="showBatchExpiryDialog = false">
{{ t("dangerDialog.cancel") }}
</Button>
<Button :disabled="savingBatchExpiry || batchExpiryKeyRaws.length === 0" data-redis-batch-expiry-apply @click="saveBatchExpiry">
<Loader2 v-if="savingBatchExpiry" class="h-4 w-4 animate-spin" />
{{ t("redis.batchExpiryApply") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog :open="showCreateKeyDialog" @update:open="onCreateKeyDialogOpenChange">
<DialogContent class="sm:max-w-md" :show-close-button="!creatingKey" :style="editorFontFamilyStyle">
<DialogHeader>
+6
View File
@@ -5147,6 +5147,12 @@ export default withEnglishFallback({
deleteKey: "Açarı sil",
deleteGroupDetails: "{target}\n{count} açar",
deleteLoadedSearchKeysDetails: "{target}\n{count} yüklənmiş uyğun açar",
batchExpiry: "Müddəti təyin et",
batchExpiryTitle: "Toplu müddət təyini",
batchExpirySelected: "{count} açar seçildi",
batchExpiryApply: "Tətbiq et",
batchExpirySuccess: "{count} açar üçün müddət təyin edildi",
batchExpiryPartial: "{success} uğurlu, {failed} uğursuz",
flushDb: "Cari verilənlər bazasını təmizlə",
flushDbMessage: "Bu əməliyyat cari Redis verilənlər bazasındakı bütün açarları siləcək və geri qaytarıla bilməz. Davam edilsin?",
flushDbDetails: "Hədəf: Redis db{db}\nƏhatə dairəsi: cari verilənlər bazasındakı bütün açarlar",
+6
View File
@@ -5145,6 +5145,12 @@ export default {
deleteKey: "Delete key",
deleteGroupDetails: "{target}\n{count} keys",
deleteLoadedSearchKeysDetails: "{target}\n{count} loaded matching keys",
batchExpiry: "Set expiry",
batchExpiryTitle: "Batch expiration",
batchExpirySelected: "Selected {count} keys",
batchExpiryApply: "Apply",
batchExpirySuccess: "Expiration applied to {count} keys",
batchExpiryPartial: "{success} applied, {failed} not applied",
flushDb: "Clear current DB",
flushDbMessage: "This will delete every key in the current Redis database and cannot be undone. Continue?",
flushDbDetails: "Target: Redis db{db}\nScope: all keys in the current DB",
+6
View File
@@ -4857,6 +4857,12 @@ export default withEnglishFallback({
deleteKey: "Eliminar clave",
deleteGroupDetails: "{target}\n{count} claves",
deleteLoadedSearchKeysDetails: "{target}\n{count} claves coincidentes cargadas",
batchExpiry: "Establecer caducidad",
batchExpiryTitle: "Caducidad por lotes",
batchExpirySelected: "{count} claves seleccionadas",
batchExpiryApply: "Aplicar",
batchExpirySuccess: "Caducidad aplicada a {count} claves",
batchExpiryPartial: "{success} aplicadas, {failed} sin aplicar",
flushDb: "Limpiar DB actual",
flushDbMessage: "Esto eliminará todas las claves de la base de datos Redis actual y no se puede deshacer. ¿Continuar?",
flushDbDetails: "Destino: Redis db{db}\nAlcance: todas las claves de la DB actual",
+6
View File
@@ -4855,6 +4855,12 @@ export default withEnglishFallback({
deleteKey: "Elimina chiave",
deleteGroupDetails: "{target}\n{count} chiavi",
deleteLoadedSearchKeysDetails: "{target}\n{count} chiavi corrispondenti caricate",
batchExpiry: "Imposta scadenza",
batchExpiryTitle: "Scadenza in blocco",
batchExpirySelected: "{count} chiavi selezionate",
batchExpiryApply: "Applica",
batchExpirySuccess: "Scadenza applicata a {count} chiavi",
batchExpiryPartial: "{success} applicate, {failed} non applicate",
flushDb: "Pulisci DB corrente",
flushDbMessage: "Questo eliminerà ogni chiave nel database Redis corrente e non può essere annullato. Continuare?",
flushDbDetails: "Destinazione: Redis db{db}\nAmbito: tutte le chiavi nel DB corrente",
+6
View File
@@ -4884,6 +4884,12 @@ export default withEnglishFallback({
deleteKey: "キーを削除",
deleteGroupDetails: "{target}\n{count}個のキー",
deleteLoadedSearchKeysDetails: "{target}\n読み込み済みの一致キー {count} 件",
batchExpiry: "有効期限を設定",
batchExpiryTitle: "有効期限の一括設定",
batchExpirySelected: "{count} 件のキーを選択中",
batchExpiryApply: "適用",
batchExpirySuccess: "{count} 件のキーに有効期限を設定しました",
batchExpiryPartial: "成功 {success} 件、失敗 {failed} 件",
flushDb: "現在のDBをクリア",
flushDbMessage: "現在のRedisデータベース内のすべてのキーが削除されます。この操作は取り消せません。続行しますか?",
flushDbDetails: "対象: Redis db{db}\n範囲: 現在のDB内のすべてのキー",
+6
View File
@@ -4478,6 +4478,12 @@ export default withEnglishFallback({
deleteKey: "키 삭제",
deleteGroupDetails: "{target}\n키 {count}개",
deleteLoadedSearchKeysDetails: "{target}\n로드된 일치 키 {count}개",
batchExpiry: "만료 시간 설정",
batchExpiryTitle: "만료 시간 일괄 설정",
batchExpirySelected: "키 {count}개 선택됨",
batchExpiryApply: "적용",
batchExpirySuccess: "키 {count}개에 만료 시간을 설정했습니다",
batchExpiryPartial: "성공 {success}개, 실패 {failed}개",
flushDb: "현재 DB 비우기",
flushDbMessage: "현재 Redis 데이터베이스의 모든 키를 삭제하며 되돌릴 수 없습니다. 계속하시겠습니까?",
flushDbDetails: "대상: Redis db{db}\n범위: 현재 DB의 모든 키",
+6
View File
@@ -4857,6 +4857,12 @@ export default withEnglishFallback({
deleteKey: "Excluir chave",
deleteGroupDetails: "{target}\n{count} chaves",
deleteLoadedSearchKeysDetails: "{target}\n{count} chaves correspondentes carregadas",
batchExpiry: "Definir expiração",
batchExpiryTitle: "Expiração em lote",
batchExpirySelected: "{count} chaves selecionadas",
batchExpiryApply: "Aplicar",
batchExpirySuccess: "Expiração aplicada a {count} chaves",
batchExpiryPartial: "{success} aplicadas, {failed} não aplicadas",
flushDb: "Limpar DB atual",
flushDbMessage: "Isto excluirá todas as chaves do banco de dados Redis atual e não pode ser desfeito. Continuar?",
flushDbDetails: "Alvo: Redis db{db}\nEscopo: todas as chaves do DB atual",
+6
View File
@@ -5041,6 +5041,12 @@ export default withEnglishFallback({
deleteKey: "Anahtarı sil",
deleteGroupDetails: "{target}\n{count} anahtar",
deleteLoadedSearchKeysDetails: "{target}\n{count} yüklenmiş eşleşen anahtar",
batchExpiry: "Süre belirle",
batchExpiryTitle: "Toplu süre belirleme",
batchExpirySelected: "{count} anahtar seçildi",
batchExpiryApply: "Uygula",
batchExpirySuccess: "{count} anahtara süre uygulandı",
batchExpiryPartial: "{success} başarılı, {failed} başarısız",
flushDb: "Geçerli VT'yi temizle",
flushDbMessage: "Bu işlem geçerli Redis veritabanındaki her anahtarı siler ve geri alınamaz. Devam edilsin mi?",
flushDbDetails: "Hedef: Redis db{db}\nKapsam: geçerli VT'deki tüm anahtarlar",
+6
View File
@@ -5129,6 +5129,12 @@ export default withEnglishFallback({
deleteKey: "删除 key",
deleteGroupDetails: "{target}\n{count} 个 key",
deleteLoadedSearchKeysDetails: "{target}\n已加载的 {count} 个匹配 key",
batchExpiry: "设置过期时间",
batchExpiryTitle: "批量设置过期时间",
batchExpirySelected: "已选择 {count} 个 key",
batchExpiryApply: "应用",
batchExpirySuccess: "已为 {count} 个 key 设置过期时间",
batchExpiryPartial: "成功 {success} 个,失败 {failed} 个",
flushDb: "清空当前 DB",
flushDbMessage: "这会删除当前 Redis 数据库中的所有 key,且不可撤销。确认继续?",
flushDbDetails: "目标: Redis db{db}\n范围: 当前 DB 的所有 key",
+6
View File
@@ -4177,6 +4177,12 @@ export default withEnglishFallback({
deleteKey: "刪除 key",
deleteGroupDetails: "{target}\n{count} 個 key",
deleteLoadedSearchKeysDetails: "{target}\n已載入的 {count} 個相符 key",
batchExpiry: "設定過期時間",
batchExpiryTitle: "批次設定過期時間",
batchExpirySelected: "已選取 {count} 個 key",
batchExpiryApply: "套用",
batchExpirySuccess: "已為 {count} 個 key 設定過期時間",
batchExpiryPartial: "成功 {success} 個,失敗 {failed} 個",
flushDb: "清空目前 DB",
flushDbMessage: "這會刪除目前 Redis 資料庫中的所有 key,且無法復原。確認繼續?",
flushDbDetails: "目標: Redis db{db}\n範圍: 目前 DB 的所有 key",
@@ -6,11 +6,14 @@ import { describe, expect, it } from "vitest";
const browserSource = readFileSync(new URL("../../../components/redis/RedisKeyBrowser.vue", import.meta.url), "utf8");
const viewerSource = readFileSync(new URL("../../../components/redis/RedisValueViewer.vue", import.meta.url), "utf8");
const localeSources = {
az: readFileSync(new URL("../../../i18n/locales/az.ts", import.meta.url), "utf8"),
en: readFileSync(new URL("../../../i18n/locales/en.ts", import.meta.url), "utf8"),
es: readFileSync(new URL("../../../i18n/locales/es.ts", import.meta.url), "utf8"),
it: readFileSync(new URL("../../../i18n/locales/it.ts", import.meta.url), "utf8"),
ja: readFileSync(new URL("../../../i18n/locales/ja.ts", import.meta.url), "utf8"),
ko: readFileSync(new URL("../../../i18n/locales/ko.ts", import.meta.url), "utf8"),
"pt-BR": readFileSync(new URL("../../../i18n/locales/pt-BR.ts", import.meta.url), "utf8"),
tr: readFileSync(new URL("../../../i18n/locales/tr.ts", import.meta.url), "utf8"),
"zh-CN": readFileSync(new URL("../../../i18n/locales/zh-CN.ts", import.meta.url), "utf8"),
"zh-TW": readFileSync(new URL("../../../i18n/locales/zh-TW.ts", import.meta.url), "utf8"),
};
@@ -182,4 +185,55 @@ describe("Redis expiry mode wiring", () => {
expect(deleted).toContain("removeKnownKey(keyRaw)");
expect(loaded).toContain('if (value.redis_type === "none")');
});
it("reuses the shared expiry validator and the batch transport for one bulk policy", () => {
const open = findFunction(browserSource, "openBatchExpiryDialog").getText();
const save = findFunction(browserSource, "saveBatchExpiry").getText();
const calls = callsIn(findFunction(browserSource, "saveBatchExpiry"));
expect(open).toContain("if (checkedKeys.value.size === 0 || selectionBusy.value) return;");
expect(open).toContain("batchExpiryKeyRaws.value = [...checkedKeys.value];");
expect(save).toContain("validateRedisExpiry");
expect(save).toContain("expiryValidationMessage(validation.reason)");
expect(calls.map(callName)).toContain("applyRedisBatchExpiryPolicy");
expect(calls.map(callName)).toContain("applyBatchExpiryMetadata");
// The single-key API must never be looped over a multi-selection.
expect(calls.map(callName)).not.toContain("api.redisSetTtl");
expect(calls.map(callName)).not.toContain("api.redisSetExpireAt");
expect(calls.map(callName)).not.toContain("applyRedisExpiryPolicy");
expect(save).toContain("if (savingBatchExpiry.value || batchExpiryKeyRaws.value.length === 0) return;");
expect(browserSource).toContain("setKeysTtl: api.redisSetKeysTtl");
expect(browserSource).toContain("setKeysExpireAt: api.redisSetKeysExpireAt");
// A batch mutation owns the selection like a batch delete does.
expect(browserSource).toContain("const mutatingKeys = computed(() => deletingKeys.value || savingBatchExpiry.value);");
expect(findFunction(browserSource, "retainCheckedKeys").getText()).toContain("loadedKeyRaws.has(keyRaw)");
});
it("updates the loaded TTL badge instead of rescanning every applied key", () => {
const applyMetadata = findFunction(browserSource, "applyBatchExpiryMetadata").getText();
const applyTtl = findFunction(browserSource, "applyKeyTtlMetadata").getText();
expect(applyTtl).toContain("updateRedisKeyInfoMetadataByRaw(flatKeyByRaw, nextKeyInfo)");
expect(applyTtl).toContain("recordKeyTtlObservedAt(nextKeyInfo)");
expect(applyTtl).toContain("updateRedisKeyTreeLeafMetadata(treeIndex, nextKeyInfo)");
expect(applyMetadata).toContain('policy.mode === "ttl" ? policy.ttl : -1');
// EXPIREAT with a past timestamp deletes the key, so the row must not keep a stale TTL.
expect(applyMetadata).toContain("onKeyDeleted(keyRaw)");
// One reactive refresh per batch, not one per selected key.
expect(applyMetadata.match(/noExpiryProjectionEpoch\.value\+\+/g) ?? []).toHaveLength(1);
expect(applyMetadata.match(/keyMetadataEpoch\.value\+\+/g) ?? []).toHaveLength(1);
expect(applyMetadata.match(/syncListTtlTimer\(\)/g) ?? []).toHaveLength(1);
});
it("ships the batch expiration copy in every supported locale", () => {
for (const [locale, source] of Object.entries(localeSources)) {
for (const key of ["batchExpiry:", "batchExpiryTitle:", "batchExpirySelected:", "batchExpiryApply:", "batchExpirySuccess:", "batchExpiryPartial:"]) {
expect(source, `${locale} is missing redis.${key}`).toContain(` ${key}`);
}
}
// The dialog must resolve its copy through i18n instead of hard-coded text.
expect(browserSource).toContain('t("redis.batchExpiryTitle")');
expect(browserSource).toContain('t("redis.batchExpirySelected", { count: batchExpiryKeyRaws.length })');
expect(browserSource).toContain('t("redis.batchExpiryPartial", { success: summary.applied, failed: failed.size })');
});
});
@@ -52,6 +52,23 @@ describe("Redis expiry Tauri API", () => {
ttl: -1,
});
});
it("invokes the batch expiry commands with every selected key in one call", async () => {
mocks.invoke.mockResolvedValue({ applied: 2, missing_key_raws: ["bWlzc2luZw=="] });
const { redisSetKeysExpireAt, redisSetKeysTtl } = await import("@/lib/backend/tauri");
const keyRaws = ["c2Vzc2lvbjpht", "c2Vzc2lvbjpi", "bWlzc2luZw=="];
await expect(redisSetKeysTtl("redis-1", 2, keyRaws, 90)).resolves.toEqual({ applied: 2, missing_key_raws: ["bWlzc2luZw=="] });
expect(mocks.invoke).toHaveBeenNthCalledWith(1, "redis_set_keys_ttl", { connectionId: "redis-1", db: 2, keyRaws, ttl: 90 });
await redisSetKeysExpireAt("redis-1", 2, keyRaws, 1_735_689_600);
expect(mocks.invoke).toHaveBeenNthCalledWith(2, "redis_set_keys_expire_at", {
connectionId: "redis-1",
db: 2,
keyRaws,
expireAt: 1_735_689_600,
});
});
});
describe("Redis expiry HTTP API", () => {
@@ -96,4 +113,22 @@ describe("Redis expiry HTTP API", () => {
body: { connectionId: "redis-1", db: 2, keyRaw: "c2Vzc2lvbg==", expireAt: 1_735_689_600 },
});
});
it("posts one batch expiry request per bounded chunk of selected keys", async () => {
const fetchMock = stubFetch();
const { redisSetKeysExpireAt, redisSetKeysTtl } = await import("@/lib/backend/http");
const keyRaws = ["c2Vzc2lvbjpht", "c2Vzc2lvbjpi"];
await redisSetKeysTtl("redis-1", 2, keyRaws, -1);
expect(lastCall(fetchMock)).toEqual({
url: "/api/redis/set-keys-ttl",
body: { connectionId: "redis-1", db: 2, keyRaws, ttl: -1 },
});
await redisSetKeysExpireAt("redis-1", 2, keyRaws, 1_735_689_600);
expect(lastCall(fetchMock)).toEqual({
url: "/api/redis/set-keys-expire-at",
body: { connectionId: "redis-1", db: 2, keyRaws, expireAt: 1_735_689_600 },
});
});
});
+3
View File
@@ -509,6 +509,8 @@ export const redisJsonSet = forward("redisJsonSet");
export const redisCheckJsonModule = forward("redisCheckJsonModule");
export const redisSetTtl = forward("redisSetTtl");
export const redisSetExpireAt = forward("redisSetExpireAt");
export const redisSetKeysTtl = forward("redisSetKeysTtl");
export const redisSetKeysExpireAt = forward("redisSetKeysExpireAt");
export const redisDeleteKeys = forward("redisDeleteKeys");
export const redisFlushDb = forward("redisFlushDb");
export const redisExecuteCommand = forward("redisExecuteCommand");
@@ -900,6 +902,7 @@ export type {
RedisDatabaseInfo,
RedisHashItem,
RedisKeyInfo,
RedisKeysExpiryResult,
RedisListItem,
RedisSetItem,
RedisStreamConsumer,
+9
View File
@@ -86,6 +86,7 @@ import type {
RedisStreamPage,
RedisStreamPendingPage,
RedisValue,
RedisKeysExpiryResult,
RedisScanResult,
RedisCommandResult,
RedisSlowlogEntry,
@@ -2955,6 +2956,14 @@ export async function redisSetExpireAt(connectionId: string, db: number, keyRaw:
});
}
export async function redisSetKeysTtl(connectionId: string, db: number, keyRaws: string[], ttl: number): Promise<RedisKeysExpiryResult> {
return post("/api/redis/set-keys-ttl", { connectionId, db, keyRaws, ttl });
}
export async function redisSetKeysExpireAt(connectionId: string, db: number, keyRaws: string[], expireAt: number): Promise<RedisKeysExpiryResult> {
return post("/api/redis/set-keys-expire-at", { connectionId, db, keyRaws, expireAt });
}
export async function redisDeleteKeys(connectionId: string, db: number, keyRaws: string[]): Promise<number> {
return post("/api/redis/delete-keys", { connectionId, db, keyRaws });
}
+13
View File
@@ -2604,6 +2604,11 @@ export interface RedisZsetItem {
member: RedisBlob;
}
export interface RedisKeysExpiryResult {
applied: number;
missing_key_raws: string[];
}
export interface RedisStreamField {
field: string;
value: string;
@@ -2892,6 +2897,14 @@ export async function redisSetExpireAt(connectionId: string, db: number, keyRaw:
return invoke("redis_set_expire_at", { connectionId, db, keyRaw, expireAt });
}
export async function redisSetKeysTtl(connectionId: string, db: number, keyRaws: string[], ttl: number): Promise<RedisKeysExpiryResult> {
return invoke("redis_set_keys_ttl", { connectionId, db, keyRaws, ttl });
}
export async function redisSetKeysExpireAt(connectionId: string, db: number, keyRaws: string[], expireAt: number): Promise<RedisKeysExpiryResult> {
return invoke("redis_set_keys_expire_at", { connectionId, db, keyRaws, expireAt });
}
export async function redisDeleteKeys(connectionId: string, db: number, keyRaws: string[]): Promise<number> {
return invoke("redis_delete_keys", { connectionId, db, keyRaws });
}
+77 -1
View File
@@ -1,7 +1,7 @@
import { CalendarDateTime, resetLocalTimeZone, setLocalTimeZone } from "@internationalized/date";
import { afterEach, describe, expect, it, vi } from "vitest";
import { calendarDateTimeToUnixSeconds, formatLocalDateTime, parseLocalDateTime, unixSecondsToCalendarDateTime } from "@/components/ui/date-time-picker/dateTimePicker";
import { applyRedisExpiryPolicy, parseRedisTtl, redisExpiryModeForTtl, validateRedisExpiry } from "./redisExpiry";
import { applyRedisBatchExpiryPolicy, applyRedisExpiryPolicy, parseRedisTtl, redisExpiryModeForTtl, validateRedisExpiry } from "./redisExpiry";
afterEach(() => {
resetLocalTimeZone();
@@ -71,3 +71,79 @@ describe("Redis expiry helpers", () => {
expect(transport.setExpireAt).toHaveBeenCalledWith("connection", 3, "key", 1_735_689_600);
});
});
function batchTransport(results: { applied: number; missing_key_raws: string[] }[] = []) {
const queue = [...results];
const next = () => Promise.resolve(queue.shift() ?? { applied: 0, missing_key_raws: [] });
return {
setKeysTtl: vi.fn().mockImplementation(next),
setKeysExpireAt: vi.fn().mockImplementation(next),
};
}
describe("Redis batch expiry helpers", () => {
it("applies one PERSIST for an empty policy, a TTL for a relative policy, and one EXPIREAT for an absolute policy", async () => {
const none = batchTransport([{ applied: 1, missing_key_raws: [] }]);
await applyRedisBatchExpiryPolicy(none, "connection", 3, ["key:a"], { mode: "none" });
expect(none.setKeysTtl).toHaveBeenCalledWith("connection", 3, ["key:a"], -1);
expect(none.setKeysExpireAt).not.toHaveBeenCalled();
const ttl = batchTransport([{ applied: 2, missing_key_raws: [] }]);
await applyRedisBatchExpiryPolicy(ttl, "connection", 3, ["key:a", "key:b"], { mode: "ttl", ttl: 3_600 });
expect(ttl.setKeysTtl).toHaveBeenCalledWith("connection", 3, ["key:a", "key:b"], 3_600);
expect(ttl.setKeysExpireAt).not.toHaveBeenCalled();
const at = batchTransport([{ applied: 2, missing_key_raws: [] }]);
await applyRedisBatchExpiryPolicy(at, "connection", 3, ["key:a", "key:b"], { mode: "at", expireAt: 1_735_689_600 });
expect(at.setKeysExpireAt).toHaveBeenCalledWith("connection", 3, ["key:a", "key:b"], 1_735_689_600);
expect(at.setKeysTtl).not.toHaveBeenCalled();
});
it("sums per-key results and reports the keys the server did not update", async () => {
const transport = batchTransport([
{ applied: 1, missing_key_raws: ["key:b"] },
{ applied: 1, missing_key_raws: [] },
]);
const summary = await applyRedisBatchExpiryPolicy(transport, "connection", 3, ["key:a", "key:b", "key:c"], { mode: "ttl", ttl: 60 }, 2);
expect(transport.setKeysTtl).toHaveBeenCalledTimes(2);
expect(transport.setKeysTtl).toHaveBeenNthCalledWith(1, "connection", 3, ["key:a", "key:b"], 60);
expect(transport.setKeysTtl).toHaveBeenNthCalledWith(2, "connection", 3, ["key:c"], 60);
expect(summary).toEqual({ applied: 2, failedKeyRaws: ["key:b"], errors: [] });
});
it("sends a large selection as bounded chunks instead of one request per key", async () => {
const keyRaws = Array.from({ length: 2_501 }, (_, index) => `key:${index}`);
const transport = batchTransport();
const summary = await applyRedisBatchExpiryPolicy(transport, "connection", 3, keyRaws, { mode: "none" });
expect(transport.setKeysTtl.mock.calls.map((call) => call[2].length)).toEqual([1_000, 1_000, 501]);
expect(summary.failedKeyRaws).toEqual([]);
});
it("keeps the keys an earlier chunk already updated when a later chunk fails", async () => {
const transport = batchTransport([{ applied: 2, missing_key_raws: [] }]);
transport.setKeysTtl.mockImplementationOnce(() => Promise.resolve({ applied: 2, missing_key_raws: [] }));
transport.setKeysTtl.mockImplementationOnce(() => Promise.reject(new Error("connection lost")));
const summary = await applyRedisBatchExpiryPolicy(transport, "connection", 3, ["key:a", "key:b", "key:c"], { mode: "ttl", ttl: 60 }, 2);
expect(summary.applied).toBe(2);
expect(summary.failedKeyRaws).toEqual(["key:c"]);
expect(summary.errors).toEqual(["connection lost"]);
});
it("deduplicates the selection and sends nothing for an empty one", async () => {
const transport = batchTransport([{ applied: 1, missing_key_raws: [] }]);
const empty = await applyRedisBatchExpiryPolicy(transport, "connection", 3, [], { mode: "none" });
expect(empty).toEqual({ applied: 0, failedKeyRaws: [], errors: [] });
expect(transport.setKeysTtl).not.toHaveBeenCalled();
const summary = await applyRedisBatchExpiryPolicy(transport, "connection", 3, ["key:a", "key:a"], { mode: "none" });
expect(transport.setKeysTtl).toHaveBeenCalledWith("connection", 3, ["key:a"], -1);
expect(summary.applied).toBe(1);
});
});
+47
View File
@@ -1,5 +1,7 @@
import type { CalendarDateTime } from "@internationalized/date";
import { calendarDateTimeToUnixSeconds } from "@/components/ui/date-time-picker/dateTimePicker";
import type { RedisKeysExpiryResult } from "@/lib/backend/api";
import { REDIS_KEY_MUTATION_BATCH_SIZE, chunkRedisKeyRaws } from "@/lib/redis/redisKeyBatch";
export type RedisExpiryMode = "none" | "ttl" | "at";
@@ -12,6 +14,25 @@ export interface RedisExpiryTransport {
setExpireAt: (connectionId: string, db: number, keyRaw: string, expireAt: number) => Promise<void>;
}
/**
* Batch counterpart of {@link RedisExpiryTransport}. One call covers a bounded
* chunk of keys, so a large selection never becomes one request per key.
*/
export interface RedisBatchExpiryTransport {
setKeysTtl: (connectionId: string, db: number, keyRaws: string[], ttl: number) => Promise<RedisKeysExpiryResult>;
setKeysExpireAt: (connectionId: string, db: number, keyRaws: string[], expireAt: number) => Promise<RedisKeysExpiryResult>;
}
/** Per-key outcome of a batch expiration, aggregated across every chunk. */
export interface RedisBatchExpirySummary {
/** Keys the server confirmed as updated. */
applied: number;
/** Keys that were not updated, so the caller can keep them selected for a retry. */
failedKeyRaws: string[];
/** Transport failures, one per chunk that never returned per-key results. */
errors: string[];
}
/** Parse the EXPIRE argument without accepting partial, negative, or unsafe values. */
export function parseRedisTtl(value: string): number | null {
const trimmed = value.trim();
@@ -55,3 +76,29 @@ export async function applyRedisExpiryPolicy(transport: RedisExpiryTransport, co
}
await transport.setExpireAt(connectionId, db, keyRaw, policy.expireAt);
}
/**
* Applies one validated policy to every selected key through the batch transport.
*
* Chunking keeps a 1000-key selection to a bounded number of requests, and a
* chunk that fails outright keeps the keys an earlier chunk already updated
* instead of discarding the whole result. Each chunk uses the same PERSIST /
* EXPIRE / EXPIREAT shape as {@link applyRedisExpiryPolicy}.
*/
export async function applyRedisBatchExpiryPolicy(transport: RedisBatchExpiryTransport, connectionId: string, db: number, keyRaws: readonly string[], policy: RedisExpiryPolicy, batchSize = REDIS_KEY_MUTATION_BATCH_SIZE): Promise<RedisBatchExpirySummary> {
const summary: RedisBatchExpirySummary = { applied: 0, failedKeyRaws: [], errors: [] };
const uniqueKeyRaws = [...new Set(keyRaws)];
if (uniqueKeyRaws.length === 0) return summary;
for (const batch of chunkRedisKeyRaws(uniqueKeyRaws, batchSize)) {
try {
const result = policy.mode === "at" ? await transport.setKeysExpireAt(connectionId, db, batch, policy.expireAt) : await transport.setKeysTtl(connectionId, db, batch, policy.mode === "ttl" ? policy.ttl : -1);
summary.applied += result.applied;
summary.failedKeyRaws.push(...result.missing_key_raws);
} catch (error) {
summary.failedKeyRaws.push(...batch);
summary.errors.push(error instanceof Error ? error.message : String(error));
}
}
return summary;
}
+3 -3
View File
@@ -1,7 +1,7 @@
import type { RedisKeyInfo } from "@/lib/backend/api";
// Keep desktop IPC and Redis command payloads bounded for large group deletes.
export const REDIS_DELETE_KEY_BATCH_SIZE = 1_000;
// Keep desktop IPC and Redis command payloads bounded for large key mutations.
export const REDIS_KEY_MUTATION_BATCH_SIZE = 1_000;
export function collectUniqueRedisKeys(keys: RedisKeyInfo[], loadedKeyRaws: Set<string>): RedisKeyInfo[] {
const uniqueKeys: RedisKeyInfo[] = [];
@@ -15,7 +15,7 @@ export function collectUniqueRedisKeys(keys: RedisKeyInfo[], loadedKeyRaws: Set<
return uniqueKeys;
}
export function* chunkRedisKeyRaws(keyRaws: readonly string[], batchSize = REDIS_DELETE_KEY_BATCH_SIZE): Generator<string[]> {
export function* chunkRedisKeyRaws(keyRaws: readonly string[], batchSize = REDIS_KEY_MUTATION_BATCH_SIZE): Generator<string[]> {
for (let start = 0; start < keyRaws.length; start += batchSize) {
yield keyRaws.slice(start, start + batchSize);
}