feat(redis): view and edit Hash field expiry (HEXPIRE/HTTL/HPERSIST)
This commit is contained in:
parent
f8b2924f42
commit
59187f2e41
|
|
@ -170,6 +170,12 @@ const stringValueView = ref<RedisValueFormat>(readPreferredRedisValueFormat());
|
|||
const memberValueView = ref<RedisValueFormat>(readPreferredRedisValueFormat());
|
||||
const redisJsonWordWrap = ref(readRedisJsonWordWrap());
|
||||
const redisJsonHighlighter = ref<JsonHighlighter>();
|
||||
const showHashFieldTtlDialog = ref(false);
|
||||
const editingHashField = ref<string | null>(null);
|
||||
const savingHashFieldTtl = ref(false);
|
||||
const hashFieldTtlMode = ref<RedisExpiryMode>("none");
|
||||
const hashFieldTtlInput = ref("");
|
||||
const hashFieldExpireAt = shallowRef<CalendarDateTime | null>(null);
|
||||
|
||||
// Decompressed view state. Decompression yields to the event loop so the
|
||||
// loading state paints before the synchronous bounded inflate runs; the result
|
||||
|
|
@ -497,11 +503,15 @@ const hasRetainedMemberDraft = computed(() => {
|
|||
}
|
||||
return memberEditValue.value !== original;
|
||||
});
|
||||
const hasUnsavedRedisDraft = computed(() => hasRetainedStringDraft.value || redisJsonValueChanged.value || hasRetainedMemberDraft.value || editingZsetMemberKey.value !== null);
|
||||
const hasUnsavedRedisDraft = computed(() => hasRetainedStringDraft.value || redisJsonValueChanged.value || hasRetainedMemberDraft.value || editingZsetMemberKey.value !== null || showHashFieldTtlDialog.value);
|
||||
const hasMore = computed(() => scanCursor.value != null && scanCursor.value > 0);
|
||||
const collectionTotal = computed(() => (data.value ? redisValueCollectionTotal(data.value) : null));
|
||||
const hashFieldTtlSupported = computed(() => {
|
||||
if (redisKind.value !== "hash") return false;
|
||||
return (collectionItems.value as RedisHashItem[]).some((item) => item.field_ttl !== undefined);
|
||||
});
|
||||
const hashGridStyle = computed(() => ({
|
||||
gridTemplateColumns: `${hashFieldWidth.value}px minmax(12rem, 1fr) 84px`,
|
||||
gridTemplateColumns: hashFieldTtlSupported.value ? `${hashFieldWidth.value}px minmax(12rem, 1fr) minmax(7rem, 9rem) 84px` : `${hashFieldWidth.value}px minmax(12rem, 1fr) 84px`,
|
||||
}));
|
||||
const zsetGridStyle = computed(() => ({
|
||||
gridTemplateColumns: `60px ${zsetScoreWidth.value}px minmax(0, 1fr) 104px`,
|
||||
|
|
@ -643,7 +653,7 @@ let zsetResizeStartWidth = 0;
|
|||
function shouldPauseAutoValueRefresh(): boolean {
|
||||
const loadedPageSize = data.value ? redisValueCollectionItems(data.value).length : 0;
|
||||
const hasExpandedCollectionPage = collectionItems.value.length > loadedPageSize;
|
||||
return showMemberDetail.value || editingZsetMemberKey.value !== null || valueSearchOpen.value || Boolean(hashSearchQuery.value.trim()) || Boolean(activeHashSearchQuery.value) || searchLoading.value || loadingMore.value || hasExpandedCollectionPage;
|
||||
return showMemberDetail.value || editingZsetMemberKey.value !== null || showHashFieldTtlDialog.value || valueSearchOpen.value || Boolean(hashSearchQuery.value.trim()) || Boolean(activeHashSearchQuery.value) || searchLoading.value || loadingMore.value || hasExpandedCollectionPage;
|
||||
}
|
||||
|
||||
type PendingDelete = { kind: "key" } | { kind: "hash"; field: string } | { kind: "list"; index: number } | { kind: "set"; member: string } | { kind: "zset"; member: string };
|
||||
|
|
@ -1463,13 +1473,20 @@ function generateInsertStatements(): string | null {
|
|||
break;
|
||||
}
|
||||
case "hash": {
|
||||
const pairs = (collectionItems.value as RedisHashItem[]).map((item) => {
|
||||
const hashItems = collectionItems.value as RedisHashItem[];
|
||||
const pairs = hashItems.map((item) => {
|
||||
const field = blobWriteText(item.field);
|
||||
const value = blobWriteText(item.value);
|
||||
return field == null || value == null ? null : `${escapeRedisArg(field)} ${escapeRedisArg(value)}`;
|
||||
});
|
||||
if (pairs.some((item) => item == null)) return null;
|
||||
commands.push(`HSET ${escapeRedisArg(key)} ${(pairs as string[]).join(" ")}`);
|
||||
for (const item of hashItems) {
|
||||
const field = blobWriteText(item.field);
|
||||
if (field != null && item.field_ttl !== undefined && item.field_ttl > 0) {
|
||||
commands.push(`HEXPIRE ${escapeRedisArg(key)} ${item.field_ttl} FIELDS 1 ${escapeRedisArg(field)}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "stream": {
|
||||
|
|
@ -1992,6 +2009,90 @@ function cancelEditTtl() {
|
|||
}
|
||||
|
||||
// Hash
|
||||
function hashFieldItem(field: string | null): RedisHashItem | null {
|
||||
if (!field || redisKind.value !== "hash") return null;
|
||||
return (collectionItems.value as RedisHashItem[]).find((item) => redisBlobText(item.field) === field) ?? null;
|
||||
}
|
||||
|
||||
function hashFieldTtlLabel(item: RedisHashItem): string {
|
||||
if (item.field_ttl === undefined) return "-";
|
||||
if (item.field_ttl === -1) return t("redis.noExpiry");
|
||||
return formatTtl(item.field_ttl, t) ?? "-";
|
||||
}
|
||||
|
||||
function currentHashFieldTtl(): number {
|
||||
return hashFieldItem(editingHashField.value)?.field_ttl ?? -1;
|
||||
}
|
||||
|
||||
function startEditHashFieldTtl(field: string | null) {
|
||||
if (!field || savingHashFieldTtl.value || hashFieldItem(field)?.field_ttl === undefined) return;
|
||||
const ttl = hashFieldItem(field)?.field_ttl ?? -1;
|
||||
editingHashField.value = field;
|
||||
hashFieldTtlMode.value = redisExpiryModeForTtl(ttl);
|
||||
hashFieldTtlInput.value = ttl > 0 ? String(ttl) : "";
|
||||
hashFieldExpireAt.value = null;
|
||||
showHashFieldTtlDialog.value = true;
|
||||
}
|
||||
|
||||
watch(hashFieldTtlMode, (mode, previousMode) => {
|
||||
const ttl = currentHashFieldTtl();
|
||||
if (mode === "at" && previousMode !== "at" && ttl > 0) {
|
||||
hashFieldExpireAt.value = unixSecondsToCalendarDateTime(Math.ceil(Date.now() / 1_000) + ttl);
|
||||
}
|
||||
});
|
||||
|
||||
function cancelEditHashFieldTtl(force = false) {
|
||||
if (savingHashFieldTtl.value && !force) return;
|
||||
showHashFieldTtlDialog.value = false;
|
||||
editingHashField.value = null;
|
||||
hashFieldTtlInput.value = "";
|
||||
hashFieldExpireAt.value = null;
|
||||
}
|
||||
|
||||
function handleHashFieldTtlOpenChange(open: boolean) {
|
||||
if (open) {
|
||||
showHashFieldTtlDialog.value = true;
|
||||
} else {
|
||||
cancelEditHashFieldTtl();
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadHashPreservingSearch() {
|
||||
const query = activeHashSearchQuery.value || hashSearchQuery.value.trim();
|
||||
await load({ selectDefaultMember: false });
|
||||
if (query) {
|
||||
hashSearchQuery.value = query;
|
||||
await onHashSearch();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveHashFieldTtl() {
|
||||
if (savingHashFieldTtl.value || !editingHashField.value) return;
|
||||
const validation = validateRedisExpiry(hashFieldTtlMode.value, hashFieldTtlInput.value, hashFieldExpireAt.value);
|
||||
if (!validation.valid) {
|
||||
toast(expiryValidationMessage(validation.reason), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
savingHashFieldTtl.value = true;
|
||||
const field = editingHashField.value;
|
||||
try {
|
||||
if (validation.policy.mode === "none") {
|
||||
await api.redisHashFieldSetTtl(props.connectionId, props.db, props.keyRaw, field, -1);
|
||||
} else if (validation.policy.mode === "ttl") {
|
||||
await api.redisHashFieldSetTtl(props.connectionId, props.db, props.keyRaw, field, validation.policy.ttl);
|
||||
} else {
|
||||
await api.redisHashFieldSetExpireAt(props.connectionId, props.db, props.keyRaw, field, validation.policy.expireAt);
|
||||
}
|
||||
cancelEditHashFieldTtl(true);
|
||||
await reloadHashPreservingSearch();
|
||||
} catch (error) {
|
||||
toast(errorMessage(error), 3000);
|
||||
} finally {
|
||||
savingHashFieldTtl.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function hashSet() {
|
||||
if (!newField.value.trim()) {
|
||||
toast(t("redis.fieldRequired"), 3000);
|
||||
|
|
@ -2584,6 +2685,7 @@ defineExpose({ focusSearch });
|
|||
<ArrowDown v-else-if="hashSortBy === 'value' && hashSortDir === 'desc'" class="h-3 w-3 shrink-0" />
|
||||
<ArrowUpDown v-else class="h-3 w-3 shrink-0 text-muted-foreground/40" />
|
||||
</div>
|
||||
<div v-if="hashFieldTtlSupported" class="px-3 py-1 text-xs font-medium text-muted-foreground">{{ t("redis.columnTTL") }}</div>
|
||||
<div />
|
||||
</div>
|
||||
<RecycleScroller class="flex-1 overflow-y-auto" :items="hashCollectionRows" :item-size="REDIS_COLLECTION_ROW_HEIGHT" :buffer="600" :skip-hover="true" key-field="id">
|
||||
|
|
@ -2597,6 +2699,19 @@ defineExpose({ focusSearch });
|
|||
>
|
||||
<div class="px-3 py-1.5 text-blue-500 truncate border-r">{{ formatValue(row.value.field) }}</div>
|
||||
<div class="px-3 py-1.5 truncate text-muted-foreground">{{ formatValue(row.value.value) }}</div>
|
||||
<div v-if="hashFieldTtlSupported" class="px-2 py-1 flex items-center min-w-0">
|
||||
<Button
|
||||
v-if="redisBlobText(row.value.field) && row.value.field_ttl !== undefined"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 min-w-0 max-w-full justify-start px-1.5 text-xs font-normal text-muted-foreground hover:text-foreground"
|
||||
:title="t('redis.expiry')"
|
||||
@click.stop="startEditHashFieldTtl(redisBlobText(row.value.field))"
|
||||
>
|
||||
<span class="truncate">{{ hashFieldTtlLabel(row.value) }}</span>
|
||||
</Button>
|
||||
<span v-else class="px-1.5 text-xs text-muted-foreground">-</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -2978,6 +3093,36 @@ defineExpose({ focusSearch });
|
|||
|
||||
<DangerConfirmDialog v-model:open="showDeleteConfirm" :message="t('dangerDialog.deleteMessage')" :details="deleteDetails" :confirm-label="t('dangerDialog.deleteConfirm')" @confirm="confirmDelete" />
|
||||
|
||||
<Dialog :open="showHashFieldTtlDialog" @update:open="handleHashFieldTtlOpenChange">
|
||||
<DialogContent class="w-[calc(100vw-2rem)] sm:max-w-[460px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("redis.expiry") }}: {{ editingHashField ? formatValue(editingHashField) : "" }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="grid gap-3 py-2">
|
||||
<Select v-model="hashFieldTtlMode" :disabled="savingHashFieldTtl">
|
||||
<SelectTrigger :aria-label="t('redis.expiry')">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{{ t("redis.expiryNone") }}</SelectItem>
|
||||
<SelectItem value="ttl">{{ t("redis.expiryTtl") }}</SelectItem>
|
||||
<SelectItem value="at">{{ t("redis.expiryAt") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input v-if="hashFieldTtlMode === 'ttl'" v-model="hashFieldTtlInput" :disabled="savingHashFieldTtl" inputmode="numeric" :placeholder="t('redis.createKeyTtlPlaceholder')" @keydown.enter="saveHashFieldTtl" />
|
||||
<DateTimePicker v-else-if="hashFieldTtlMode === 'at'" v-model="hashFieldExpireAt" :locale="locale" :disabled="savingHashFieldTtl" />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" :disabled="savingHashFieldTtl" @click="cancelEditHashFieldTtl">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button :disabled="savingHashFieldTtl" @click="saveHashFieldTtl">
|
||||
<Loader2 v-if="savingHashFieldTtl" class="h-4 w-4 animate-spin" />
|
||||
<Save v-else class="h-4 w-4" />
|
||||
{{ t("grid.save") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog :open="showMemberDetail" @update:open="handleMemberDetailOpenChange">
|
||||
<DialogContent data-redis-member-detail class="relative flex h-[min(760px,85vh)] w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[960px]" :style="editorFontFamilyStyle" @close-auto-focus="finishMemberDetailClose" @pointer-down-outside.prevent @interact-outside.prevent>
|
||||
<!--
|
||||
|
|
|
|||
|
|
@ -414,6 +414,8 @@ export const redisSetString = forward("redisSetString");
|
|||
export const redisDeleteKey = forward("redisDeleteKey");
|
||||
export const redisHashSet = forward("redisHashSet");
|
||||
export const redisHashDel = forward("redisHashDel");
|
||||
export const redisHashFieldSetTtl = forward("redisHashFieldSetTtl");
|
||||
export const redisHashFieldSetExpireAt = forward("redisHashFieldSetExpireAt");
|
||||
export const redisListPush = forward("redisListPush");
|
||||
export const redisListSet = forward("redisListSet");
|
||||
export const redisListRemove = forward("redisListRemove");
|
||||
|
|
|
|||
|
|
@ -2387,6 +2387,14 @@ export async function redisHashDel(connectionId: string, db: number, keyRaw: str
|
|||
return post("/api/redis/hash-del", { connectionId, db, keyRaw, field });
|
||||
}
|
||||
|
||||
export async function redisHashFieldSetTtl(connectionId: string, db: number, keyRaw: string, field: string, ttl: number): Promise<void> {
|
||||
return post("/api/redis/hash-field-set-ttl", { connectionId, db, keyRaw, field, ttl });
|
||||
}
|
||||
|
||||
export async function redisHashFieldSetExpireAt(connectionId: string, db: number, keyRaw: string, field: string, expireAt: number): Promise<void> {
|
||||
return post("/api/redis/hash-field-set-expire-at", { connectionId, db, keyRaw, field, expireAt });
|
||||
}
|
||||
|
||||
export async function redisListPush(connectionId: string, db: number, keyRaw: string, value: string, ttl?: number): Promise<void> {
|
||||
return post("/api/redis/list-push", { connectionId, db, keyRaw, value, ttl });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2022,6 +2022,7 @@ export interface RedisSetItem {
|
|||
export interface RedisHashItem {
|
||||
field: RedisBlob;
|
||||
value: RedisBlob;
|
||||
field_ttl?: number;
|
||||
}
|
||||
|
||||
export interface RedisZsetItem {
|
||||
|
|
@ -2235,6 +2236,14 @@ export async function redisHashDel(connectionId: string, db: number, keyRaw: str
|
|||
return invoke("redis_hash_del", { connectionId, db, keyRaw, field });
|
||||
}
|
||||
|
||||
export async function redisHashFieldSetTtl(connectionId: string, db: number, keyRaw: string, field: string, ttl: number): Promise<void> {
|
||||
return invoke("redis_hash_field_set_ttl", { connectionId, db, keyRaw, field, ttl });
|
||||
}
|
||||
|
||||
export async function redisHashFieldSetExpireAt(connectionId: string, db: number, keyRaw: string, field: string, expireAt: number): Promise<void> {
|
||||
return invoke("redis_hash_field_set_expire_at", { connectionId, db, keyRaw, field, expireAt });
|
||||
}
|
||||
|
||||
export async function redisListPush(connectionId: string, db: number, keyRaw: string, value: string, ttl?: number): Promise<void> {
|
||||
return invoke("redis_list_push", { connectionId, db, keyRaw, value, ttl });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ const ALLOWED_COMMANDS = new Set([
|
|||
"HRANDFIELD",
|
||||
"HSCAN",
|
||||
"HSTRLEN",
|
||||
"HPTTL",
|
||||
"HTTL",
|
||||
"HVALS",
|
||||
"LINDEX",
|
||||
"LLEN",
|
||||
|
|
@ -148,6 +150,11 @@ const CONFIRM_COMMANDS = new Set([
|
|||
"RENAMENX",
|
||||
"GETDEL",
|
||||
"HDEL",
|
||||
"HEXPIRE",
|
||||
"HEXPIREAT",
|
||||
"HPEXPIRE",
|
||||
"HPEXPIREAT",
|
||||
"HPERSIST",
|
||||
"JSON.ARRPOP",
|
||||
"JSON.ARRTRIM",
|
||||
"JSON.CLEAR",
|
||||
|
|
|
|||
|
|
@ -166,6 +166,8 @@ const RAW_COMMANDS: Record<string, Spec> = {
|
|||
|
||||
// ---- Hash ----
|
||||
HDEL: [-3, "hash", "confirm"],
|
||||
HEXPIRE: [-6, "hash", "confirm"],
|
||||
HEXPIREAT: [-6, "hash", "confirm"],
|
||||
HEXISTS: [3, "hash"],
|
||||
HGET: [3, "hash"],
|
||||
HGETALL: [2, "hash"],
|
||||
|
|
@ -180,6 +182,11 @@ const RAW_COMMANDS: Record<string, Spec> = {
|
|||
HSET: [-4, "hash", "confirm"],
|
||||
HSETNX: [4, "hash", "confirm"],
|
||||
HSTRLEN: [3, "hash"],
|
||||
HPERSIST: [-5, "hash", "confirm"],
|
||||
HPEXPIRE: [-6, "hash", "confirm"],
|
||||
HPEXPIREAT: [-6, "hash", "confirm"],
|
||||
HPTTL: [-5, "hash"],
|
||||
HTTL: [-5, "hash"],
|
||||
HVALS: [2, "hash"],
|
||||
|
||||
// ---- Set ----
|
||||
|
|
|
|||
|
|
@ -104,6 +104,10 @@ pub struct RedisSetItem {
|
|||
pub struct RedisHashItem {
|
||||
pub field: RedisBlob,
|
||||
pub value: RedisBlob,
|
||||
/// Remaining field TTL in seconds. `-1` means the field is persistent;
|
||||
/// `None` means the Redis server does not expose hash-field expiration.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub field_ttl: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -1648,6 +1652,8 @@ pub fn classify_command(command: &str) -> RedisCommandSafety {
|
|||
| "HRANDFIELD"
|
||||
| "HSCAN"
|
||||
| "HSTRLEN"
|
||||
| "HPTTL"
|
||||
| "HTTL"
|
||||
| "HVALS"
|
||||
| "INFO"
|
||||
| "LASTSAVE"
|
||||
|
|
@ -1759,12 +1765,13 @@ pub fn classify_command(command: &str) -> RedisCommandSafety {
|
|||
| "TS.QUERYINDEX"
|
||||
| "TS.RANGE" => RedisCommandSafety::Allowed,
|
||||
"DEL" | "UNLINK" | "EXPIRE" | "EXPIREAT" | "PEXPIRE" | "PEXPIREAT" | "RENAME" | "RENAMENX" | "GETDEL"
|
||||
| "HDEL" | "JSON.ARRPOP" | "JSON.ARRTRIM" | "JSON.CLEAR" | "JSON.DEL" | "JSON.FORGET" | "BLMOVE" | "BLMPOP"
|
||||
| "BLPOP" | "BRPOP" | "BRPOPLPUSH" | "LPOP" | "LMOVE" | "LMPOP" | "RPOP" | "RPOPLPUSH" | "LREM" | "LTRIM"
|
||||
| "SPOP" | "SREM" | "ZREM" | "ZPOPMAX" | "ZPOPMIN" | "ZMPOP" | "BZMPOP" | "BZPOPMAX" | "BZPOPMIN"
|
||||
| "ZREMRANGEBYLEX" | "ZREMRANGEBYRANK" | "ZREMRANGEBYSCORE" | "XDEL" | "XTRIM" | "MOVE" | "SORT"
|
||||
| "SDIFFSTORE" | "SINTERSTORE" | "SUNIONSTORE" | "ZDIFFSTORE" | "ZINTERSTORE" | "ZRANGESTORE"
|
||||
| "ZUNIONSTORE" | "PFMERGE" | "GEOSEARCHSTORE" | "FLUSHDB" => RedisCommandSafety::Confirm,
|
||||
| "HDEL" | "HEXPIRE" | "HEXPIREAT" | "HPEXPIRE" | "HPEXPIREAT" | "HPERSIST" | "JSON.ARRPOP"
|
||||
| "JSON.ARRTRIM" | "JSON.CLEAR" | "JSON.DEL" | "JSON.FORGET" | "BLMOVE" | "BLMPOP" | "BLPOP" | "BRPOP"
|
||||
| "BRPOPLPUSH" | "LPOP" | "LMOVE" | "LMPOP" | "RPOP" | "RPOPLPUSH" | "LREM" | "LTRIM" | "SPOP" | "SREM"
|
||||
| "ZREM" | "ZPOPMAX" | "ZPOPMIN" | "ZMPOP" | "BZMPOP" | "BZPOPMAX" | "BZPOPMIN" | "ZREMRANGEBYLEX"
|
||||
| "ZREMRANGEBYRANK" | "ZREMRANGEBYSCORE" | "XDEL" | "XTRIM" | "MOVE" | "SORT" | "SDIFFSTORE"
|
||||
| "SINTERSTORE" | "SUNIONSTORE" | "ZDIFFSTORE" | "ZINTERSTORE" | "ZRANGESTORE" | "ZUNIONSTORE" | "PFMERGE"
|
||||
| "GEOSEARCHSTORE" | "FLUSHDB" => RedisCommandSafety::Confirm,
|
||||
"APPEND" | "BITFIELD" | "BITOP" | "COPY" | "DECR" | "DECRBY" | "GEOADD" | "GEORADIUS" | "GEORADIUSBYMEMBER"
|
||||
| "GETEX" | "GETSET" | "INCR" | "INCRBY" | "INCRBYFLOAT" | "SET" | "SETEX" | "PSETEX" | "SETNX"
|
||||
| "SETRANGE" | "MSET" | "MSETNX" | "PERSIST" | "HSET" | "HMSET" | "HINCRBY" | "HINCRBYFLOAT" | "HSETNX"
|
||||
|
|
@ -2336,7 +2343,8 @@ where
|
|||
}
|
||||
"hash" => {
|
||||
let len: u64 = redis::cmd("HLEN").arg(key).query_async(con).await.unwrap_or(0);
|
||||
let (cursor, items) = hscan_page_raw(con, key, 0, COLLECTION_PAGE_SIZE, None).await?;
|
||||
let (cursor, mut items) = hscan_page_raw(con, key, 0, COLLECTION_PAGE_SIZE, None).await?;
|
||||
attach_hash_field_ttls(con, key, &mut items).await?;
|
||||
RedisValueData::Hash { items, total: len, scan_cursor: (cursor > 0).then_some(cursor) }
|
||||
}
|
||||
"stream" => {
|
||||
|
|
@ -2864,7 +2872,12 @@ pub async fn hash_set<C>(con: &mut C, key: &[u8], field: &str, value: &str, ttl:
|
|||
where
|
||||
C: ConnectionLike + Send + Sync + Unpin,
|
||||
{
|
||||
let previous_field_ttl = if ttl.is_none() { read_hash_field_ttl(con, key, field.as_bytes()).await? } else { None };
|
||||
redis::cmd("HSET").arg(key).arg(field).arg(value).query_async::<()>(con).await.map_err(|e| e.to_string())?;
|
||||
if let Some(previous_ttl) = previous_field_ttl.filter(|ttl| *ttl > 0) {
|
||||
let remaining_ttl = previous_ttl.max(1);
|
||||
set_hash_field_ttl(con, key, field, remaining_ttl).await?;
|
||||
}
|
||||
apply_expire_if_needed(con, key, ttl).await
|
||||
}
|
||||
|
||||
|
|
@ -3078,6 +3091,66 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn set_hash_field_ttl<C>(con: &mut C, key: &[u8], field: &str, ttl: i64) -> Result<(), String>
|
||||
where
|
||||
C: ConnectionLike + Send + Sync + Unpin,
|
||||
{
|
||||
if ttl <= 0 {
|
||||
let raw: RedisRawValue = redis::cmd("HPERSIST")
|
||||
.arg(key)
|
||||
.arg("FIELDS")
|
||||
.arg(1)
|
||||
.arg(field)
|
||||
.query_async(con)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
return parse_hash_field_expiry_result(raw, "HPERSIST", true);
|
||||
}
|
||||
|
||||
let raw: RedisRawValue = redis::cmd("HEXPIRE")
|
||||
.arg(key)
|
||||
.arg(ttl)
|
||||
.arg("FIELDS")
|
||||
.arg(1)
|
||||
.arg(field)
|
||||
.query_async(con)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
parse_hash_field_expiry_result(raw, "HEXPIRE", false)
|
||||
}
|
||||
|
||||
pub async fn set_hash_field_expire_at<C>(con: &mut C, key: &[u8], field: &str, expire_at: i64) -> Result<(), String>
|
||||
where
|
||||
C: ConnectionLike + Send + Sync + Unpin,
|
||||
{
|
||||
let raw: RedisRawValue = redis::cmd("HEXPIREAT")
|
||||
.arg(key)
|
||||
.arg(expire_at)
|
||||
.arg("FIELDS")
|
||||
.arg(1)
|
||||
.arg(field)
|
||||
.query_async(con)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
parse_hash_field_expiry_result(raw, "HEXPIREAT", false)
|
||||
}
|
||||
|
||||
fn parse_hash_field_expiry_result(raw: RedisRawValue, command: &str, allow_persist_noop: bool) -> Result<(), String> {
|
||||
let RedisRawValue::Array(mut values) = raw else {
|
||||
return Err(format!("Invalid {command} response"));
|
||||
};
|
||||
let Some(result) = values.pop().and_then(redis_value_to_string).and_then(|value| value.parse::<i64>().ok()) else {
|
||||
return Err(format!("Invalid {command} response"));
|
||||
};
|
||||
match result {
|
||||
1 => Ok(()),
|
||||
-1 if allow_persist_noop => Ok(()),
|
||||
2 => Err(format!("{command} removed the hash field because it was already expired")),
|
||||
-2 => Err("Redis hash field no longer exists".to_string()),
|
||||
_ => Err(format!("{command} was not applied to the hash field")),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_keys<C>(con: &mut C, keys: &[Vec<u8>]) -> Result<u64, String>
|
||||
where
|
||||
C: ConnectionLike + Send + Sync + Unpin,
|
||||
|
|
@ -3133,11 +3206,12 @@ where
|
|||
Ok(RedisCollectionPage::Zset { items, scan_cursor: has_more.then_some(next) })
|
||||
}
|
||||
"hash" => {
|
||||
let (next_cursor, items) = if let Some(query) = filter_query.filter(|query| !query.is_empty()) {
|
||||
let (next_cursor, mut items) = if let Some(query) = filter_query.filter(|query| !query.is_empty()) {
|
||||
hscan_filtered_page_raw(con, key, cursor, count, query).await?
|
||||
} else {
|
||||
hscan_page_raw(con, key, cursor, count, None).await?
|
||||
};
|
||||
attach_hash_field_ttls(con, key, &mut items).await?;
|
||||
Ok(RedisCollectionPage::Hash { items, scan_cursor: (next_cursor > 0).then_some(next_cursor) })
|
||||
}
|
||||
_ => Err(format!("Pagination not supported for type: {key_type}")),
|
||||
|
|
@ -3191,6 +3265,76 @@ where
|
|||
Ok((cur, items))
|
||||
}
|
||||
|
||||
async fn attach_hash_field_ttls<C>(con: &mut C, key: &[u8], items: &mut [RedisHashItem]) -> Result<(), String>
|
||||
where
|
||||
C: ConnectionLike + Send + Sync + Unpin,
|
||||
{
|
||||
if items.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut command = redis::cmd("HTTL");
|
||||
command.arg(key).arg("FIELDS").arg(items.len());
|
||||
for item in items.iter() {
|
||||
let field = base64::engine::general_purpose::STANDARD
|
||||
.decode(&item.field.raw_base64)
|
||||
.map_err(|error| format!("Invalid Redis hash field encoding: {error}"))?;
|
||||
command.arg(field);
|
||||
}
|
||||
|
||||
let raw: RedisRawValue = match command.query_async(con).await {
|
||||
Ok(raw) => raw,
|
||||
Err(error) if is_optional_hash_field_expiry_error(&error) => return Ok(()),
|
||||
Err(error) => return Err(error.to_string()),
|
||||
};
|
||||
if matches!(raw, RedisRawValue::Nil) {
|
||||
return Ok(());
|
||||
}
|
||||
let RedisRawValue::Array(values) = raw else {
|
||||
return Ok(());
|
||||
};
|
||||
if values.len() != items.len() {
|
||||
return Ok(());
|
||||
}
|
||||
for (item, value) in items.iter_mut().zip(values) {
|
||||
let Some(ttl) = redis_value_to_string(value).and_then(|value| value.parse::<i64>().ok()) else {
|
||||
return Ok(());
|
||||
};
|
||||
item.field_ttl = (ttl != -2).then_some(ttl);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_hash_field_ttl<C>(con: &mut C, key: &[u8], field: &[u8]) -> Result<Option<i64>, String>
|
||||
where
|
||||
C: ConnectionLike + Send + Sync + Unpin,
|
||||
{
|
||||
let raw: RedisRawValue = match redis::cmd("HTTL").arg(key).arg("FIELDS").arg(1).arg(field).query_async(con).await {
|
||||
Ok(raw) => raw,
|
||||
Err(error) if is_optional_hash_field_expiry_error(&error) => return Ok(None),
|
||||
Err(error) => return Err(error.to_string()),
|
||||
};
|
||||
if matches!(raw, RedisRawValue::Nil) {
|
||||
return Ok(None);
|
||||
}
|
||||
let RedisRawValue::Array(mut values) = raw else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(ttl) = values.pop().and_then(redis_value_to_string).and_then(|value| value.parse::<i64>().ok()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok((ttl >= 0).then_some(ttl))
|
||||
}
|
||||
|
||||
fn is_optional_hash_field_expiry_error(error: &redis::RedisError) -> bool {
|
||||
let detail = error.detail().unwrap_or_default().to_ascii_lowercase();
|
||||
detail.contains("unknown command")
|
||||
|| detail.contains("unsupported")
|
||||
|| detail.contains("syntax error")
|
||||
|| detail.contains("noperm")
|
||||
|| detail.contains("no permission")
|
||||
}
|
||||
|
||||
fn hash_entry_matches_query(item: &RedisHashItem, query: &str) -> bool {
|
||||
let query = query.to_lowercase();
|
||||
if query.is_empty() {
|
||||
|
|
@ -3286,7 +3430,7 @@ fn parse_scan_hash_entries(raw: RedisRawValue) -> Result<(u64, Vec<RedisHashItem
|
|||
let value = redis_value_to_bytes(value.clone())
|
||||
.map(|bytes| redis_blob_from_bytes(&bytes))
|
||||
.ok_or_else(|| "Invalid hash value payload".to_string())?;
|
||||
items.push(RedisHashItem { field, value });
|
||||
items.push(RedisHashItem { field, value, field_ttl: None });
|
||||
}
|
||||
|
||||
Ok((cursor, items))
|
||||
|
|
@ -3484,7 +3628,11 @@ mod tests {
|
|||
RedisValueData::Hash {
|
||||
items: entries
|
||||
.iter()
|
||||
.map(|(field, value)| RedisHashItem { field: text_blob(field), value: text_blob(value) })
|
||||
.map(|(field, value)| RedisHashItem {
|
||||
field: text_blob(field),
|
||||
value: text_blob(value),
|
||||
field_ttl: None,
|
||||
})
|
||||
.collect(),
|
||||
total: entries.len() as u64,
|
||||
scan_cursor: None,
|
||||
|
|
@ -4633,7 +4781,7 @@ mod tests {
|
|||
panic!("expected hash collection page");
|
||||
};
|
||||
assert_eq!(scan_cursor, Some(512));
|
||||
assert_eq!(items, vec![RedisHashItem { field: text_blob("user:1"), value: text_blob("Ada") }]);
|
||||
assert_eq!(items, vec![RedisHashItem { field: text_blob("user:1"), value: text_blob("Ada"), field_ttl: None }]);
|
||||
assert_eq!(con.command_count("HSCAN"), 1);
|
||||
assert!(!con.commands[0].contains("\r\nMATCH\r\n"));
|
||||
}
|
||||
|
|
@ -4650,11 +4798,98 @@ mod tests {
|
|||
panic!("expected hash collection page");
|
||||
};
|
||||
assert_eq!(scan_cursor, None);
|
||||
assert_eq!(items, vec![RedisHashItem { field: text_blob("status"), value: text_blob("Ada Lovelace") }]);
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![RedisHashItem { field: text_blob("status"), value: text_blob("Ada Lovelace"), field_ttl: None }]
|
||||
);
|
||||
assert_eq!(con.command_count("HSCAN"), 1);
|
||||
assert!(!con.commands[0].contains("\r\nMATCH\r\n"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hash_load_more_attaches_field_ttl_when_supported() {
|
||||
let mut con = FakeRedisConnection::new(vec![
|
||||
hscan_response("0", vec![("session", "Ada")]),
|
||||
RedisRawValue::Array(vec![RedisRawValue::Int(42)]),
|
||||
]);
|
||||
|
||||
let result = super::load_more_collection(&mut con, b"hash-key", "hash", 0, 20, None, None).await.unwrap();
|
||||
|
||||
let RedisCollectionPage::Hash { items, scan_cursor } = result else {
|
||||
panic!("expected hash collection page");
|
||||
};
|
||||
assert_eq!(scan_cursor, None);
|
||||
assert_eq!(items[0].field_ttl, Some(42));
|
||||
assert_eq!(con.command_count("HTTL"), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hash_load_more_keeps_persistent_field_ttl() {
|
||||
let mut con = FakeRedisConnection::new(vec![
|
||||
hscan_response("0", vec![("session", "Ada")]),
|
||||
RedisRawValue::Array(vec![RedisRawValue::Int(-1)]),
|
||||
]);
|
||||
|
||||
let result = super::load_more_collection(&mut con, b"hash-key", "hash", 0, 20, None, None).await.unwrap();
|
||||
|
||||
let RedisCollectionPage::Hash { items, .. } = result else {
|
||||
panic!("expected hash collection page");
|
||||
};
|
||||
assert_eq!(items[0].field_ttl, Some(-1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hash_load_more_degrades_when_field_ttl_is_unsupported() {
|
||||
let unsupported = redis::RedisError::from((
|
||||
redis::ErrorKind::ResponseError,
|
||||
"An error was signalled by the server",
|
||||
"unknown command 'HTTL'".to_string(),
|
||||
));
|
||||
let mut con = FakeRedisConnection::with_results(vec![
|
||||
Ok(hscan_response("0", vec![("session", "Ada")])),
|
||||
Err(unsupported),
|
||||
]);
|
||||
|
||||
let result = super::load_more_collection(&mut con, b"hash-key", "hash", 0, 20, None, None).await.unwrap();
|
||||
|
||||
let RedisCollectionPage::Hash { items, .. } = result else {
|
||||
panic!("expected hash collection page");
|
||||
};
|
||||
assert_eq!(items[0].field_ttl, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hash_set_preserves_existing_field_ttl() {
|
||||
let mut con = FakeRedisConnection::new(vec![
|
||||
RedisRawValue::Array(vec![RedisRawValue::Int(42)]),
|
||||
RedisRawValue::Okay,
|
||||
RedisRawValue::Array(vec![RedisRawValue::Int(1)]),
|
||||
]);
|
||||
|
||||
super::hash_set(&mut con, b"hash-key", "session", "Grace", None).await.unwrap();
|
||||
|
||||
assert_eq!(con.command_count("HTTL"), 1);
|
||||
assert_eq!(con.command_count("HSET"), 1);
|
||||
assert_eq!(con.command_count("HEXPIRE"), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hash_field_expiry_commands_accept_expected_results() {
|
||||
let mut con = FakeRedisConnection::new(vec![
|
||||
RedisRawValue::Array(vec![RedisRawValue::Int(1)]),
|
||||
RedisRawValue::Array(vec![RedisRawValue::Int(-1)]),
|
||||
RedisRawValue::Array(vec![RedisRawValue::Int(1)]),
|
||||
]);
|
||||
|
||||
super::set_hash_field_ttl(&mut con, b"hash-key", "session", 60).await.unwrap();
|
||||
super::set_hash_field_ttl(&mut con, b"hash-key", "session", -1).await.unwrap();
|
||||
super::set_hash_field_expire_at(&mut con, b"hash-key", "session", 1_735_689_600).await.unwrap();
|
||||
|
||||
assert_eq!(con.command_count("HEXPIRE"), 1);
|
||||
assert_eq!(con.command_count("HPERSIST"), 1);
|
||||
assert_eq!(con.command_count("HEXPIREAT"), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filtered_hash_load_more_caps_sparse_scan_iterations() {
|
||||
let responses = (1..=super::HASH_FILTER_SCAN_MAX_ITERATIONS + 1)
|
||||
|
|
|
|||
|
|
@ -441,6 +441,66 @@ pub async fn redis_hash_del_in_db_core(
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn redis_hash_field_set_ttl_in_db_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
db: u32,
|
||||
key_raw: &str,
|
||||
field: &str,
|
||||
ttl: i64,
|
||||
) -> Result<(), String> {
|
||||
ensure_redis_pool(state, connection_id).await?;
|
||||
let connections = state.connections.read().await;
|
||||
match connections.get(connection_id).ok_or("Not found")? {
|
||||
PoolKind::Redis(redis) => {
|
||||
let key = redis_driver::redis_key_raw_to_bytes(key_raw)?;
|
||||
match redis {
|
||||
RedisConnection::Direct(con) => {
|
||||
let mut con = con.lock().await;
|
||||
redis_driver::select_db(&mut *con, db).await?;
|
||||
redis_driver::set_hash_field_ttl(&mut *con, &key, field, ttl).await
|
||||
}
|
||||
RedisConnection::Cluster(cluster) => {
|
||||
redis_driver::ensure_cluster_db(db)?;
|
||||
let mut con = redis_driver::cluster_key_connection(cluster, &key).await?;
|
||||
redis_driver::set_hash_field_ttl(&mut con, &key, field, ttl).await
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => Err("Not a Redis connection".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn redis_hash_field_set_expire_at_in_db_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
db: u32,
|
||||
key_raw: &str,
|
||||
field: &str,
|
||||
expire_at: i64,
|
||||
) -> Result<(), String> {
|
||||
ensure_redis_pool(state, connection_id).await?;
|
||||
let connections = state.connections.read().await;
|
||||
match connections.get(connection_id).ok_or("Not found")? {
|
||||
PoolKind::Redis(redis) => {
|
||||
let key = redis_driver::redis_key_raw_to_bytes(key_raw)?;
|
||||
match redis {
|
||||
RedisConnection::Direct(con) => {
|
||||
let mut con = con.lock().await;
|
||||
redis_driver::select_db(&mut *con, db).await?;
|
||||
redis_driver::set_hash_field_expire_at(&mut *con, &key, field, expire_at).await
|
||||
}
|
||||
RedisConnection::Cluster(cluster) => {
|
||||
redis_driver::ensure_cluster_db(db)?;
|
||||
let mut con = redis_driver::cluster_key_connection(cluster, &key).await?;
|
||||
redis_driver::set_hash_field_expire_at(&mut con, &key, field, expire_at).await
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => Err("Not a Redis connection".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn redis_list_push_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
|
|
|
|||
|
|
@ -507,6 +507,8 @@ async fn main() {
|
|||
.route("/redis/delete-key", post(routes::redis::delete_key))
|
||||
.route("/redis/hash-set", post(routes::redis::hash_set))
|
||||
.route("/redis/hash-del", post(routes::redis::hash_del))
|
||||
.route("/redis/hash-field-set-ttl", post(routes::redis::hash_field_set_ttl))
|
||||
.route("/redis/hash-field-set-expire-at", post(routes::redis::hash_field_set_expire_at))
|
||||
.route("/redis/list-push", post(routes::redis::list_push))
|
||||
.route("/redis/list-set", post(routes::redis::list_set))
|
||||
.route("/redis/list-remove", post(routes::redis::list_remove))
|
||||
|
|
|
|||
|
|
@ -139,6 +139,26 @@ pub struct RedisHashRequest {
|
|||
pub ttl: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RedisHashFieldTtlRequest {
|
||||
pub connection_id: String,
|
||||
pub db: u32,
|
||||
pub key_raw: String,
|
||||
pub field: String,
|
||||
pub ttl: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RedisHashFieldExpireAtRequest {
|
||||
pub connection_id: String,
|
||||
pub db: u32,
|
||||
pub key_raw: String,
|
||||
pub field: String,
|
||||
pub expire_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RedisZaddRequest {
|
||||
|
|
@ -495,6 +515,42 @@ pub async fn hash_del(
|
|||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn hash_field_set_ttl(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisHashFieldTtlRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "HEXPIRE").await?;
|
||||
dbx_core::redis_ops::redis_hash_field_set_ttl_in_db_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
req.db,
|
||||
&req.key_raw,
|
||||
&req.field,
|
||||
req.ttl,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn hash_field_set_expire_at(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisHashFieldExpireAtRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
ensure_writable(&state.app, &req.connection_id, "HEXPIREAT").await?;
|
||||
dbx_core::redis_ops::redis_hash_field_set_expire_at_in_db_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
req.db,
|
||||
&req.key_raw,
|
||||
&req.field,
|
||||
req.expire_at,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn list_push(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisListRequest>,
|
||||
|
|
|
|||
|
|
@ -201,6 +201,40 @@ pub async fn redis_hash_del(
|
|||
dbx_core::redis_ops::redis_hash_del_in_db_core(&state, &connection_id, db, &key_raw, &field).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn redis_hash_field_set_ttl(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
db: u32,
|
||||
key_raw: String,
|
||||
field: String,
|
||||
ttl: i64,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "HEXPIRE").await?;
|
||||
dbx_core::redis_ops::redis_hash_field_set_ttl_in_db_core(&state, &connection_id, db, &key_raw, &field, ttl).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn redis_hash_field_set_expire_at(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
db: u32,
|
||||
key_raw: String,
|
||||
field: String,
|
||||
expire_at: i64,
|
||||
) -> Result<(), String> {
|
||||
ensure_connection_writable(&state, &connection_id, "HEXPIREAT").await?;
|
||||
dbx_core::redis_ops::redis_hash_field_set_expire_at_in_db_core(
|
||||
&state,
|
||||
&connection_id,
|
||||
db,
|
||||
&key_raw,
|
||||
&field,
|
||||
expire_at,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn redis_list_push(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
|
|
|
|||
|
|
@ -1740,6 +1740,8 @@ pub fn run() {
|
|||
commands::redis_cmd::redis_delete_key,
|
||||
commands::redis_cmd::redis_hash_set,
|
||||
commands::redis_cmd::redis_hash_del,
|
||||
commands::redis_cmd::redis_hash_field_set_ttl,
|
||||
commands::redis_cmd::redis_hash_field_set_expire_at,
|
||||
commands::redis_cmd::redis_list_push,
|
||||
commands::redis_cmd::redis_list_set,
|
||||
commands::redis_cmd::redis_list_remove,
|
||||
|
|
|
|||
Loading…
Reference in New Issue