feat(redis): view and edit Hash field expiry (HEXPIRE/HTTL/HPERSIST)

This commit is contained in:
二丫讲梵 2026-08-07 02:41:04 +08:00 committed by GitHub
parent f8b2924f42
commit 59187f2e41
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 583 additions and 16 deletions

View File

@ -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>
<!--

View File

@ -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");

View File

@ -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 });
}

View File

@ -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 });
}

View File

@ -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",

View File

@ -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 ----

View File

@ -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)

View File

@ -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,

View File

@ -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))

View File

@ -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>,

View File

@ -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>>,

View File

@ -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,