fix(redis): preserve full key snapshots during local search

Closes #9120
This commit is contained in:
t8y2 committed 2026-09-16 00:47:22 +08:00
1 parent dffd3025a7
commit 15c38da8ea
4 files changed
+277 -22

No files matched your search

@@ -2107,6 +2107,66 @@ describe("RedisKeyBrowser KeepAlive scan budget (issue #7779)", () => {
});
describe("RedisKeyBrowser interrupted Fetch All", () => {
it("keeps the complete snapshot when a group is opened during preparation", async () => {
const initial = { key_display: "group:first", key_raw: btoa("group:first"), key_type: "string", ttl: -1 };
const buffered = { key_display: "group:second", key_raw: btoa("group:second"), key_type: "string", ttl: -1 };
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => Promise.resolve(cursor === 0 ? { cursor: 1, keys: [initial], total_keys: 2 } : { cursor: 0, keys: [buffered], total_keys: 0 }));
const buildStarted = deferred<void>();
const releaseBuild = deferred<void>();
const buildSnapshot = mocks.buildRedisKeySnapshotCooperatively.getMockImplementation()!;
mocks.buildRedisKeySnapshotCooperatively.mockImplementationOnce(async (...args) => {
buildStarted.resolve();
await releaseBuild.promise;
return buildSnapshot(...args);
});
mountBrowser();
await settle();
clickButtonWithText("redis.fetchAllKeys");
await buildStarted.promise;
groupRow("group").click();
releaseBuild.resolve();
await settle();
await vi.waitFor(() => expect(document.body.textContent).not.toContain("redis.stopFetchAll"));
await submitKeySearch("group:second");
expect(document.querySelector(`[data-redis-leaf="${buffered.key_raw}"]`)).not.toBeNull();
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(2);
expect(document.body.textContent).not.toContain("redis.fetchAllKeys");
});
it("filters a completed Fetch All snapshot while editing a key search", async () => {
const unrelated = { key_display: "session:unrelated", key_raw: "c2Vzc2lvbjp1bnJlbGF0ZWQ=", key_type: "string", ttl: -1 };
const target = { key_display: "prod:login_fail_count", key_raw: "cHJvZDpsb2dpbl9mYWlsX2NvdW50", key_type: "string", ttl: -1 };
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => Promise.resolve(cursor === 0 ? { cursor: 1, keys: [unrelated], total_keys: 2 } : { cursor: 0, keys: [target], total_keys: 0 }));
mountBrowser();
await settle();
clickButtonWithText("redis.fetchAllKeys");
await vi.waitFor(() => expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(2));
await vi.waitFor(() => expect(document.body.textContent).not.toContain("redis.stopFetchAll"));
const scanCalls = mocks.redisScanKeysBatch.mock.calls.length;
await setInput("[data-redis-search-input]", "prod:login_fail_count");
expect(document.body.textContent).toContain(target.key_display);
expect(document.body.textContent).not.toContain(unrelated.key_display);
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(scanCalls);
const input = requiredElement<HTMLInputElement>("[data-redis-search-input]");
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await settle();
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(scanCalls);
expect(document.body.textContent).toContain(target.key_display);
requiredElement<HTMLButtonElement>("[data-redis-select-all]").click();
await settle();
expect(document.querySelector("[data-redis-batch-delete]")?.textContent).toContain("1");
expect(document.querySelector("[data-redis-select-all]")).toBeNull();
await setInput("[data-redis-search-input]", "");
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(scanCalls);
expect(mocks.scrollerItems[mocks.scrollerItems.length - 1]).toHaveLength(2);
});
it("publishes Fetch All rows through one stable Array facade and explicitly refreshes its viewport", async () => {
const initial = { key_display: "initial", key_raw: "aW5pdGlhbA==", key_type: "string", ttl: -1 };
const buffered = { key_display: "buffered", key_raw: "YnVmZmVyZWQ=", key_type: "string", ttl: -1 };
@@ -58,7 +58,7 @@ import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts";
import { copyToClipboard } from "@/lib/common/clipboard";
import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle";
import { useToast } from "@/composables/useToast";
import { redisFuzzySearchScanBudget, redisKeySearchPattern, redisGroupSubtreePattern } from "@/lib/redis/redisKeyPattern";
import { redisFuzzySearchScanBudget, createRedisKeyPatternMatcher, redisKeySearchPattern, redisGroupSubtreePattern } from "@/lib/redis/redisKeyPattern";
import { filterRedisKeyTemplates, resolveRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates";
import { forgetRedisKeySearchHistory, loadRedisKeySearchHistory, rememberRedisKeySearchHistory, type RedisKeySearchHistoryScope } from "@/lib/redis/redisKeySearchHistory";
import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern";
@@ -148,6 +148,7 @@ const loading = ref(false);
const loadingMore = ref(false);
const searchPending = ref(false);
const isFetchingAll = ref(false);
const fetchAllSnapshotComplete = ref(false);
const fetchAllStopRequested = ref(false);
const fetchAllLoadedCount = ref(0);
const rootRef = ref<HTMLElement>();
@@ -258,7 +259,6 @@ const AUTO_LOAD_TOTAL_SCAN_ITERATIONS = 50;
let autoLoadBudget: ScanIterationBudget = { remaining: AUTO_LOAD_TOTAL_SCAN_ITERATIONS };
let redisBrowserIsActive = true;
let reloadKeysOnActivation = false;
let fetchAllPreparingSnapshot = false;
let redisDbFlushedListenerRegistered = false;
let redisInfiniteScrollFrame = 0;
let loadedKeyRaws = new Set<string>();
@@ -299,6 +299,8 @@ const valueQuery = computed(() => searchPattern.value.trim());
const isValueSearchMode = computed(() => searchMode.value === "value" || searchMode.value === "all");
const effectivePattern = computed(() => (searchMode.value === "key" ? redisKeySearchPattern(searchPattern.value, fuzzyKeySearch.value) : "*"));
const isSearchMode = computed(() => (searchMode.value === "key" ? effectivePattern.value !== "*" : valueQuery.value !== ""));
const localKeySearchActive = computed(() => fetchAllSnapshotComplete.value && searchMode.value === "key" && isSearchMode.value);
const localKeyPatternMatcher = computed(() => createRedisKeyPatternMatcher(effectivePattern.value));
// Keep regular glob search on the low-cost flat path. The explicit fuzzy mode
// opts into the namespace hierarchy that users need for group selection.
const isFuzzyKeySearch = computed(() => searchMode.value === "key" && isSearchMode.value && fuzzyKeySearch.value);
@@ -310,7 +312,7 @@ const mutatingKeys = computed(() => deletingKeys.value || savingBatchExpiry.valu
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));
const allKeysSelected = computed(() => (customGrouping.value.enabled || localKeySearchActive.value ? filteredFlatKeys.value.length > 0 && checkedKeys.value.size === filteredFlatKeys.value.length : allLoadedKeysSelected.value && !hasMore.value));
const searchPlaceholder = computed(() => {
if (searchMode.value === "key") return fuzzyKeySearch.value ? t("redis.fuzzyPattern") : t("redis.pattern");
return searchMode.value === "all" ? t("redis.allSearchPlaceholder") : t("redis.valueSearchPlaceholder");
@@ -368,18 +370,29 @@ watch([searchPattern, searchMode, fuzzyKeySearch, noExpiryOnly], persistRedisKey
const fetchAllFilteredKeyCount = ref<number | null>(null);
// 过滤后的平铺 key 列表:未开启过滤时与 flatKeys 完全一致,避免额外开销
const filteredFlatKeys = computed(() => {
if (!noExpiryOnly.value) return flatKeys.value;
const onlyNoExpiry = noExpiryOnly.value;
const matchesPattern = localKeySearchActive.value ? localKeyPatternMatcher.value : undefined;
if (!onlyNoExpiry && !matchesPattern) return flatKeys.value;
void noExpiryProjectionEpoch.value;
return flatKeys.value.filter((key) => key.ttl === -1);
return flatKeys.value.filter((key) => (!onlyNoExpiry || key.ttl === -1) && (!matchesPattern || matchesPattern(key.key_display, key.key_raw)));
});
// 过滤后的树:独立重建而不复用 treeIndex,避免污染后续 SCAN 增量合并的全量树基准;
// 分组 id 只由 db+路径决定,与全量树一致,因此展开状态可直接复用
const filteredTreeKeys = computed(() => {
if (!noExpiryOnly.value) return treeKeys.value;
if (!noExpiryOnly.value && !localKeySearchActive.value) return treeKeys.value;
return buildRedisKeyTree(filteredFlatKeys.value, props.db, redisKeySeparator.value);
});
watch(effectivePattern, () => {
if (!fetchAllSnapshotComplete.value || searchMode.value !== "key") return;
if (localKeySearchActive.value && isFuzzyHierarchyView.value) expandedGroupIds.value = collectExpandedGroupIds(filteredTreeKeys.value);
void nextTick(() => {
redisKeyScroller()?.scrollToItem(0, { align: "start" });
refreshRedisKeyScroller();
});
});
const displayedKeyCount = computed(() => {
if (isFetchingAll.value) return fetchAllLoadedCount.value;
if (localKeySearchActive.value) return filteredFlatKeys.value.length;
// 过滤时展示匹配数量,便于确认“无过期”key 的规模
if (noExpiryOnly.value) return fetchAllFilteredKeyCount.value ?? filteredFlatKeys.value.length;
return flatKeys.value.length;
@@ -493,10 +506,28 @@ async function updateCreateKeyTypeHelpOffset() {
watch(activeCreateKeyTypeHelp, () => {
void updateCreateKeyTypeHelpOffset();
});
const localFlatSearchRows = computed(() => {
const keys = filteredFlatKeys.value;
const database = props.db;
return markRaw(
new Proxy([] as RedisKeyTreeRow[], {
get(target, property, receiver) {
if (property === "length") return keys.length;
const index = facadeArrayIndex(property);
return index >= 0 ? (keys[index] ? redisKeyToFlatTreeRow(keys[index], database) : undefined) : Reflect.get(target, property, receiver);
},
has(target, property) {
const index = facadeArrayIndex(property);
return index >= 0 ? index < keys.length : Reflect.has(target, property);
},
}),
);
});
const regularVisibleRows = computed(() => {
// Prepared snapshots already own their rows. Even a watcher whose callback
// ignores them would otherwise synchronously re-map/flatten the keyspace.
if (showCustomGrouping.value || fetchAllVisibleRowsActive.value) return [];
if (useFlatKeySearchRows.value && localKeySearchActive.value) return localFlatSearchRows.value;
return useFlatKeySearchRows.value ? filteredFlatKeys.value.map((key) => redisKeyToFlatTreeRow(key, props.db)) : flattenVisibleRedisKeyTree(filteredTreeKeys.value, expandedGroupIds.value);
});
let fetchAllVisibleRowsSource: readonly RedisKeyTreeRow[] = [];
@@ -765,7 +796,7 @@ function toggleNodeCheck(node: RedisKeyTreeNode, event: MouseEvent) {
function selectAllLoadedKeys() {
if (selectionBusy.value || loadedKeyRaws.size === 0) return;
focusKeyPane();
setKeysChecked(customGrouping.value.enabled ? filteredFlatKeys.value.map((key) => key.key_raw) : loadedKeyRaws, true);
setKeysChecked(customGrouping.value.enabled || localKeySearchActive.value ? filteredFlatKeys.value.map((key) => key.key_raw) : loadedKeyRaws, true);
selectionAnchorRowId.value = visibleRows.value[0]?.id ?? null;
}
@@ -871,7 +902,6 @@ function invalidateScanRequests(resetAutoLoadBudget = true): number {
isFetchingAll.value = false;
fetchAllStopRequested.value = true;
fetchAllLoadedCount.value = 0;
fetchAllPreparingSnapshot = false;
searchRequestId++;
loadMoreOperationId++;
loadingMore.value = false;
@@ -881,6 +911,7 @@ function invalidateScanRequests(resetAutoLoadBudget = true): number {
}
function invalidateFetchAllForStructuralMutation() {
fetchAllSnapshotComplete.value = false;
if (isFetchingAll.value || fetchAllPublicationRollback) invalidateScanRequests();
}
@@ -1025,6 +1056,7 @@ async function loadKeys() {
searchPending.value = false;
rememberRedisKeySearchHistory(searchHistoryScope.value, searchPattern.value);
const requestId = invalidateScanRequests();
fetchAllSnapshotComplete.value = false;
isFetchingAll.value = false;
fetchAllStopRequested.value = false;
fetchAllLoadedCount.value = 0;
@@ -1288,6 +1320,7 @@ async function publishFetchAllVisibleRows(rows: readonly RedisKeyTreeRow[], requ
async function fetchAll(): Promise<boolean> {
if (!hasMore.value || isFetchingAll.value) return false;
const requestId = searchRequestId;
const fetchAllIsFullKeyspace = searchMode.value === "key" && !isSearchMode.value;
const bufferedKeys: RedisKeyInfo[] = [];
const bufferedKeyRaws = new Set<string>();
const initialFlatKeys = flatKeys.value;
@@ -1347,7 +1380,6 @@ async function fetchAll(): Promise<boolean> {
snapshotConfig.expandedGroupIds = expandedGroupIds.value;
snapshotConfig.noExpiryOnly = noExpiryOnly.value;
activateFetchAllVisibleRows();
fetchAllPreparingSnapshot = true;
const snapshot = await buildRedisKeySnapshotCooperatively([initialFlatKeys, bufferedKeys], snapshotConfig, {
shouldContinue: () => requestId === searchRequestId && redisBrowserIsActive && !fetchAllStopRequested.value,
});
@@ -1364,9 +1396,9 @@ async function fetchAll(): Promise<boolean> {
}
}
} finally {
fetchAllPreparingSnapshot = false;
publicationSucceeded = published;
if (published) {
fetchAllSnapshotComplete.value = fetchAllIsFullKeyspace;
fetchAllPublicationRollback = null;
if (!hasMore.value) refreshExpandedGroupIds.clear();
}
@@ -1504,12 +1536,6 @@ function resumePendingGroupSubtrees(requestId = searchRequestId) {
}
function toggleGroup(groupId: string) {
if (fetchAllPreparingSnapshot) {
invalidateScanRequests();
isFetchingAll.value = false;
fetchAllStopRequested.value = false;
fetchAllLoadedCount.value = 0;
}
deactivateFetchAllVisibleRows();
const next = new Set(expandedGroupIds.value);
const expanding = !next.has(groupId);
@@ -1733,6 +1759,7 @@ function onRedisRowContextMenu(event: MouseEvent, node: RedisKeyTreeNode, openCo
function resetLoadedKeys() {
invalidateScanRequests();
fetchAllSnapshotComplete.value = false;
isFetchingAll.value = false;
fetchAllStopRequested.value = false;
fetchAllLoadedCount.value = 0;
@@ -2566,17 +2593,37 @@ function onSearchInput() {
}, 400);
}
function submitSearch() {
if (!fetchAllSnapshotComplete.value || searchMode.value !== "key") {
void loadKeys();
return;
}
if (searchTimer) clearTimeout(searchTimer);
searchTimer = null;
searchPending.value = false;
rememberRedisKeySearchHistory(searchHistoryScope.value, searchPattern.value);
invalidateScanRequests();
loading.value = false;
isFetchingAll.value = false;
fetchAllStopRequested.value = false;
fetchAllLoadedCount.value = 0;
selectedKeyRaw.value = null;
resetCheckedKeys();
hasMore.value = false;
}
function setSearchMode(mode: RedisSearchMode) {
if (searchMode.value === mode) return;
searchMode.value = mode;
dismissSearchHistoryMenu();
if (mode !== "key") dismissKeyTemplateMenu();
void loadKeys();
if (mode === "key") submitSearch();
else void loadKeys();
}
function toggleFuzzyKeySearch() {
fuzzyKeySearch.value = !fuzzyKeySearch.value;
if (searchMode.value === "key") void loadKeys();
if (searchMode.value === "key") submitSearch();
}
function toggleNoExpiryOnly() {
@@ -2776,7 +2823,7 @@ function onSearchKeydown(event: KeyboardEvent) {
selectSearchHistory(searchHistorySelectedIndex.value);
} else {
dismissSearchHistoryMenu();
void loadKeys();
submitSearch();
}
return;
}
@@ -2809,7 +2856,7 @@ function onSearchKeydown(event: KeyboardEvent) {
}
}
if (event.key === "Enter") {
void loadKeys();
submitSearch();
return;
}
if (!isCancelSearchShortcut(event)) return;
@@ -2817,7 +2864,7 @@ function onSearchKeydown(event: KeyboardEvent) {
searchPattern.value = "";
dismissKeyTemplateMenu();
dismissSearchHistoryMenu();
void loadKeys();
submitSearch();
}
function onRedisDbFlushed(event: Event) {
+41 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { redisGroupSubtreePattern } from "@/lib/redis/redisKeyPattern";
import { createRedisKeyPatternMatcher, redisGroupSubtreePattern, redisKeyMatchesPattern } from "@/lib/redis/redisKeyPattern";
describe("redisGroupSubtreePattern", () => {
it("builds a prefix pattern for single and nested groups", () => {
@@ -18,3 +18,43 @@ describe("redisGroupSubtreePattern", () => {
expect(redisGroupSubtreePattern(["back\\slash"])).toBe("back\\\\slash:*");
});
});
describe("redisKeyMatchesPattern", () => {
it.each([
["[z-a]", "m", true],
["[a", "a", true],
["[]", "]", false],
["?", "中", false],
["???", "中", true],
["????", "😀", true],
["[^x]", "y", true],
["*中*", "key:中文", true],
["literal:\\*", "literal:x", false],
])("matches Redis byte-based glob semantics for %s", (pattern, value, expected) => {
expect(redisKeyMatchesPattern(value, pattern)).toBe(expected);
});
it("uses raw bytes when Redis escapes a display key", () => {
const literalSlash = createRedisKeyPatternMatcher("path\\\\name");
expect(literalSlash("path\\\\name", btoa("path\\name"))).toBe(true);
expect(createRedisKeyPatternMatcher("bin:?")("bin:\\xff", btoa("bin:\xff"))).toBe(true);
expect(createRedisKeyPatternMatcher("bin:?")("bin:\\xff", "invalid!")).toBe(false);
});
it("reuses a compiled matcher without carrying state between keys", () => {
const matches = createRedisKeyPatternMatcher("*login*");
expect(["session:1", "prod:login:1", "session:2", "prod:login:2"].filter((value) => matches(value))).toEqual(["prod:login:1", "prod:login:2"]);
});
it("matches Redis glob literals, wildcards, and classes", () => {
expect(redisKeyMatchesPattern("prod:login_fail_count", "prod:login_fail_count")).toBe(true);
expect(redisKeyMatchesPattern("prod:login_fail_count", "prod:*_fail_????t")).toBe(true);
expect(redisKeyMatchesPattern("prod:login_fail_count", "prod:[a-z]*")).toBe(true);
expect(redisKeyMatchesPattern("prod:LOGIN_FAIL_COUNT", "prod:[a-z]*")).toBe(false);
});
it("treats escaped glob characters as literals", () => {
expect(redisKeyMatchesPattern("literal:*", "literal:\\*")).toBe(true);
expect(redisKeyMatchesPattern("literal:x", "literal:\\*")).toBe(false);
});
});
@@ -11,6 +11,114 @@ export function redisKeySearchPattern(value: string, fuzzy: boolean): string {
return fuzzy ? `*${escapeRedisGlobText(pattern, fuzzy)}*` : pattern;
}
type RedisGlobToken = { kind: "literal"; value: number } | { kind: "star" } | { kind: "any" } | { kind: "class"; negate: boolean; chars: Set<number>; ranges: Array<[number, number]> };
const redisPatternEncoder = new TextEncoder();
const REDIS_NON_ASCII = /[\u0080-\uffff]/;
function parseRedisGlobClass(pattern: Uint8Array, start: number): { token: RedisGlobToken; next: number } {
let index = start + 1;
let negate = false;
if (pattern[index] === 94) {
negate = true;
index++;
}
const chars = new Set<number>();
const ranges: Array<[number, number]> = [];
while (index < pattern.length) {
if (pattern[index] === 93) {
return { token: { kind: "class", negate, chars, ranges }, next: index + 1 };
}
const value = pattern[index]!;
if (value === 92 && index + 1 < pattern.length) {
chars.add(pattern[index + 1]!);
index += 2;
} else if (index + 2 < pattern.length && pattern[index + 1] === 45) {
const startByte = (value << 24) >> 24;
const endByte = (pattern[index + 2]! << 24) >> 24;
ranges.push([Math.min(startByte, endByte), Math.max(startByte, endByte)]);
index += 3;
} else {
chars.add(value);
index++;
}
}
return { token: { kind: "class", negate, chars, ranges }, next: index };
}
function redisGlobTokens(patternText: string): RedisGlobToken[] {
const pattern = redisPatternEncoder.encode(patternText);
const tokens: RedisGlobToken[] = [];
for (let index = 0; index < pattern.length; index++) {
const value = pattern[index]!;
if (value === 92 && index + 1 < pattern.length) {
tokens.push({ kind: "literal", value: pattern[++index]! });
} else if (value === 42) {
if (tokens[tokens.length - 1]?.kind !== "star") tokens.push({ kind: "star" });
} else if (value === 63) {
tokens.push({ kind: "any" });
} else if (value === 91) {
const parsed = parseRedisGlobClass(pattern, index);
tokens.push(parsed.token);
index = parsed.next - 1;
} else {
tokens.push({ kind: "literal", value });
}
}
return tokens;
}
function redisGlobClassMatches(token: Extract<RedisGlobToken, { kind: "class" }>, value: number): boolean {
const signedValue = (value << 24) >> 24;
const matches = token.chars.has(value) || token.ranges.some(([start, end]) => start <= signedValue && signedValue <= end);
return token.negate ? !matches : matches;
}
export function createRedisKeyPatternMatcher(pattern: string): (value: string, keyRaw?: string) => boolean {
const tokens = redisGlobTokens(pattern);
return (value, keyRaw) => {
let bytes: string | Uint8Array;
if (keyRaw && value.includes("\\")) {
try {
bytes = atob(keyRaw);
} catch {
return false;
}
} else {
bytes = REDIS_NON_ASCII.test(value) ? redisPatternEncoder.encode(value) : value;
}
let valueIndex = 0;
let tokenIndex = 0;
let starTokenIndex = -1;
let starValueIndex = -1;
while (valueIndex < bytes.length) {
const token = tokens[tokenIndex];
const byte = typeof bytes === "string" ? bytes.charCodeAt(valueIndex) : bytes[valueIndex]!;
const matches = token?.kind === "literal" ? token.value === byte : token?.kind === "any" ? true : token?.kind === "class" ? redisGlobClassMatches(token, byte) : false;
if (matches) {
tokenIndex++;
valueIndex++;
} else if (token?.kind === "star") {
starTokenIndex = tokenIndex++;
starValueIndex = valueIndex;
} else if (starTokenIndex >= 0) {
tokenIndex = starTokenIndex + 1;
valueIndex = ++starValueIndex;
} else {
return false;
}
}
while (tokens[tokenIndex]?.kind === "star") tokenIndex++;
return tokenIndex === tokens.length;
};
}
export function redisKeyMatchesPattern(value: string, pattern: string): boolean {
return createRedisKeyPatternMatcher(pattern)(value);
}
/**
* SCAN MATCH pattern covering exactly the subtree of a tree group: every
* segment is glob-escaped so keys containing `*`/`?`/`[`/`]` match literally,