feat(redis): batch key expiry updates
This commit is contained in:
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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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内のすべてのキー",
|
||||
|
||||
@@ -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의 모든 키",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user