fix: compatibility with older Redis versions without UNLINK command support #69

perf: add waiting indicator for deleting keys and flushing database
This commit is contained in:
tiny-craft 2023-11-10 15:57:19 +08:00
parent 5a58a57cd5
commit 65e077c0c0
4 changed files with 113 additions and 64 deletions

View File

@ -1522,23 +1522,25 @@ func (b *browserService) DeleteKey(connName string, db int, k any, async bool) (
if strings.HasSuffix(key, "*") { if strings.HasSuffix(key, "*") {
// delete by prefix // delete by prefix
var mutex sync.Mutex var mutex sync.Mutex
supportUnlink := true
del := func(ctx context.Context, cli redis.UniversalClient) error { del := func(ctx context.Context, cli redis.UniversalClient) error {
handleDel := func(ks []string) error { handleDel := func(ks []string) error {
pipe := cli.Pipeline() var delErr error
for _, k2 := range ks { if async && supportUnlink {
if async { supportUnlink = false
cli.Unlink(ctx, k2) if delErr = cli.Unlink(ctx, ks...).Err(); delErr != nil {
} else { // not support unlink? try del command
cli.Del(ctx, k2) delErr = cli.Del(ctx, ks...).Err()
} }
} else {
delErr = cli.Del(ctx, ks...).Err()
} }
pipe.Exec(ctx)
mutex.Lock() mutex.Lock()
deletedKeys = append(deletedKeys, ks...) deletedKeys = append(deletedKeys, ks...)
mutex.Unlock() mutex.Unlock()
return nil return delErr
} }
scanSize := int64(Preferences().GetScanSize()) scanSize := int64(Preferences().GetScanSize())
@ -1546,7 +1548,7 @@ func (b *browserService) DeleteKey(connName string, db int, k any, async bool) (
resultKeys := make([]string, 0, 100) resultKeys := make([]string, 0, 100)
for iter.Next(ctx) { for iter.Next(ctx) {
resultKeys = append(resultKeys, iter.Val()) resultKeys = append(resultKeys, iter.Val())
if len(resultKeys) >= 3 { if len(resultKeys) >= 20 {
handleDel(resultKeys) handleDel(resultKeys)
resultKeys = resultKeys[:0:cap(resultKeys)] resultKeys = resultKeys[:0:cap(resultKeys)]
} }
@ -1574,12 +1576,14 @@ func (b *browserService) DeleteKey(connName string, db int, k any, async bool) (
} else { } else {
// delete key only // delete key only
if async { if async {
if _, err = client.Unlink(ctx, key).Result(); err != nil { if err = client.Unlink(ctx, key).Err(); err != nil {
resp.Msg = err.Error() if err = client.Del(ctx, key).Err(); err != nil {
return resp.Msg = err.Error()
return
}
} }
} else { } else {
if _, err = client.Del(ctx, key).Result(); err != nil { if err = client.Del(ctx, key).Err(); err != nil {
resp.Msg = err.Error() resp.Msg = err.Error()
return return
} }
@ -1603,8 +1607,8 @@ func (b *browserService) FlushDB(connName string, db int, async bool) (resp type
return return
} }
flush := func(ctx context.Context, cli redis.UniversalClient) { flush := func(ctx context.Context, cli redis.UniversalClient, async bool) error {
cli.TxPipelined(ctx, func(pipe redis.Pipeliner) error { _, e := cli.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.Select(ctx, db) pipe.Select(ctx, db)
if async { if async {
pipe.FlushDBAsync(ctx) pipe.FlushDBAsync(ctx)
@ -1613,17 +1617,26 @@ func (b *browserService) FlushDB(connName string, db int, async bool) (resp type
} }
return nil return nil
}) })
return e
} }
client, ctx := item.client, item.ctx client, ctx := item.client, item.ctx
if cluster, ok := client.(*redis.ClusterClient); ok { if cluster, ok := client.(*redis.ClusterClient); ok {
// cluster mode // cluster mode
err = cluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error { err = cluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error {
flush(ctx, cli) return flush(ctx, cli, async)
return nil
}) })
// try sync mode if error cause
if err != nil && async {
err = cluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error {
return flush(ctx, cli, false)
})
}
} else { } else {
flush(ctx, client) if err = flush(ctx, client, async); err != nil && async {
// try sync mode if error cause
err = flush(ctx, client, false)
}
} }
if err != nil { if err != nil {

View File

@ -135,7 +135,7 @@ onMounted(async () => {
paddingLeft: `${logoPaddingLeft}px`, paddingLeft: `${logoPaddingLeft}px`,
}"> }">
<n-space :size="3" :wrap="false" :wrap-item="false" align="center"> <n-space :size="3" :wrap="false" :wrap-item="false" align="center">
<n-avatar :size="35" :src="iconUrl" color="#0000" style="min-width: 35px" /> <n-avatar :size="32" :src="iconUrl" color="#0000" style="min-width: 32px" />
<div style="min-width: 68px; font-weight: 800">Tiny RDM</div> <div style="min-width: 68px; font-weight: 800">Tiny RDM</div>
<transition name="fade"> <transition name="fade">
<n-text v-if="tabStore.nav === 'browser'" class="ellipsis" strong style="font-size: 13px"> <n-text v-if="tabStore.nav === 'browser'" class="ellipsis" strong style="font-size: 13px">

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { reactive, watch } from 'vue' import { reactive, ref, watch } from 'vue'
import useDialog from 'stores/dialog' import useDialog from 'stores/dialog'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { isEmpty, size } from 'lodash' import { isEmpty, size } from 'lodash'
@ -29,18 +29,22 @@ watch(
deleteForm.loadingAffected = false deleteForm.loadingAffected = false
deleteForm.affectedKeys = [] deleteForm.affectedKeys = []
deleteForm.async = true deleteForm.async = true
loading.value = false
} }
}, },
) )
const loading = ref(false)
const scanAffectedKey = async () => { const scanAffectedKey = async () => {
try { try {
loading.value = true
deleteForm.loadingAffected = true deleteForm.loadingAffected = true
const { keys = [] } = await browserStore.scanKeys(deleteForm.server, deleteForm.db, deleteForm.key) const { keys = [] } = await browserStore.scanKeys(deleteForm.server, deleteForm.db, deleteForm.key)
deleteForm.affectedKeys = keys || [] deleteForm.affectedKeys = keys || []
deleteForm.showAffected = true deleteForm.showAffected = true
} finally { } finally {
deleteForm.loadingAffected = false deleteForm.loadingAffected = false
loading.value = false
} }
} }
@ -52,6 +56,7 @@ const resetAffected = () => {
const i18n = useI18n() const i18n = useI18n()
const onConfirmDelete = async () => { const onConfirmDelete = async () => {
try { try {
loading.value = true
const { server, db, key, async } = deleteForm const { server, db, key, async } = deleteForm
const success = await browserStore.deleteKeyPrefix(server, db, key, async) const success = await browserStore.deleteKeyPrefix(server, db, key, async)
if (success) { if (success) {
@ -59,6 +64,9 @@ const onConfirmDelete = async () => {
} }
} catch (e) { } catch (e) {
$message.error(e.message) $message.error(e.message)
return
} finally {
loading.value = false
} }
dialogStore.closeDeleteKeyDialog() dialogStore.closeDeleteKeyDialog()
} }
@ -78,40 +86,53 @@ const onClose = () => {
:title="$t('interface.batch_delete_key')" :title="$t('interface.batch_delete_key')"
preset="dialog" preset="dialog"
transform-origin="center"> transform-origin="center">
<n-form :model="deleteForm" :show-require-mark="false" label-placement="top"> <n-spin :show="loading">
<n-form-item :label="$t('dialogue.key.server')"> <n-form :model="deleteForm" :show-require-mark="false" label-placement="top">
<n-input :value="deleteForm.server" readonly /> <n-form-item :label="$t('dialogue.key.server')">
</n-form-item> <n-input :value="deleteForm.server" readonly />
<n-form-item :label="$t('dialogue.key.db_index')"> </n-form-item>
<n-input :value="deleteForm.db.toString()" readonly /> <n-form-item :label="$t('dialogue.key.db_index')">
</n-form-item> <n-input :value="deleteForm.db.toString()" readonly />
<n-form-item :label="$t('dialogue.key.key_expression')" required> </n-form-item>
<n-input v-model:value="deleteForm.key" placeholder="" @input="resetAffected" /> <n-form-item :label="$t('dialogue.key.key_expression')" required>
</n-form-item> <n-input v-model:value="deleteForm.key" placeholder="" @input="resetAffected" />
<n-form-item :label="$t('dialogue.key.async_delete')" required> </n-form-item>
<n-checkbox v-model:checked="deleteForm.async">{{ $t('dialogue.key.async_delete_title') }}</n-checkbox> <n-form-item :label="$t('dialogue.key.async_delete')" required>
</n-form-item> <n-checkbox v-model:checked="deleteForm.async">
<n-card v-if="deleteForm.showAffected" :title="$t('dialogue.key.affected_key')" size="small"> {{ $t('dialogue.key.async_delete_title') }}
<n-skeleton v-if="deleteForm.loadingAffected" :repeat="10" text /> </n-checkbox>
<n-log </n-form-item>
v-else <n-card
:line-height="1.5" v-if="deleteForm.showAffected"
:lines="deleteForm.affectedKeys" :title="$t('dialogue.key.affected_key') + `(${size(deleteForm.affectedKeys)})`"
:rows="10" size="small">
style="user-select: text; cursor: text" /> <n-skeleton v-if="deleteForm.loadingAffected" :repeat="10" text />
</n-card> <n-log
</n-form> v-else
:line-height="1.5"
:lines="deleteForm.affectedKeys"
:rows="10"
style="user-select: text; cursor: text" />
</n-card>
</n-form>
</n-spin>
<template #action> <template #action>
<div class="flex-item n-dialog__action"> <div class="flex-item n-dialog__action">
<n-button :focusable="false" @click="onClose">{{ $t('common.cancel') }}</n-button> <n-button :disabled="loading" :focusable="false" @click="onClose">{{ $t('common.cancel') }}</n-button>
<n-button v-if="!deleteForm.showAffected" :focusable="false" type="primary" @click="scanAffectedKey"> <n-button
v-if="!deleteForm.showAffected"
:focusable="false"
:loading="loading"
type="primary"
@click="scanAffectedKey">
{{ $t('dialogue.key.show_affected_key') }} {{ $t('dialogue.key.show_affected_key') }}
</n-button> </n-button>
<n-button <n-button
v-else v-else
:disabled="isEmpty(deleteForm.affectedKeys)" :disabled="isEmpty(deleteForm.affectedKeys)"
:focusable="false" :focusable="false"
:loading="loading"
type="error" type="error"
@click="onConfirmDelete"> @click="onConfirmDelete">
{{ $t('dialogue.key.confirm_delete_key', { num: size(deleteForm.affectedKeys) }) }} {{ $t('dialogue.key.confirm_delete_key', { num: size(deleteForm.affectedKeys) }) }}

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { reactive, watch } from 'vue' import { reactive, ref, watch } from 'vue'
import useDialog from 'stores/dialog' import useDialog from 'stores/dialog'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import useBrowserStore from 'stores/browser.js' import useBrowserStore from 'stores/browser.js'
@ -23,13 +23,16 @@ watch(
flushForm.db = db flushForm.db = db
flushForm.async = true flushForm.async = true
flushForm.confirm = false flushForm.confirm = false
loading.value = false
} }
}, },
) )
const loading = ref(false)
const i18n = useI18n() const i18n = useI18n()
const onConfirmFlush = async () => { const onConfirmFlush = async () => {
try { try {
loading.value = true
const { server, db, async } = flushForm const { server, db, async } = flushForm
const success = await browserStore.flushDatabase(server, db, async) const success = await browserStore.flushDatabase(server, db, async)
if (success) { if (success) {
@ -37,6 +40,9 @@ const onConfirmFlush = async () => {
} }
} catch (e) { } catch (e) {
$message.error(e.message) $message.error(e.message)
return
} finally {
loading.value = false
} }
dialogStore.closeFlushDBDialog() dialogStore.closeFlushDBDialog()
} }
@ -56,26 +62,35 @@ const onClose = () => {
:title="$t('interface.flush_db')" :title="$t('interface.flush_db')"
preset="dialog" preset="dialog"
transform-origin="center"> transform-origin="center">
<n-form :model="flushForm" :show-require-mark="false" label-placement="top"> <n-spin :show="loading">
<n-form-item :label="$t('dialogue.key.server')"> <n-form :model="flushForm" :show-require-mark="false" label-placement="top">
<n-input :value="flushForm.server" readonly /> <n-form-item :label="$t('dialogue.key.server')">
</n-form-item> <n-input :value="flushForm.server" readonly />
<n-form-item :label="$t('dialogue.key.db_index')"> </n-form-item>
<n-input :value="flushForm.db.toString()" readonly /> <n-form-item :label="$t('dialogue.key.db_index')">
</n-form-item> <n-input :value="flushForm.db.toString()" readonly />
<n-form-item :label="$t('dialogue.key.async_delete')" required> </n-form-item>
<n-checkbox v-model:checked="flushForm.async">{{ $t('dialogue.key.async_delete_title') }}</n-checkbox> <n-form-item :label="$t('dialogue.key.async_delete')" required>
</n-form-item> <n-checkbox v-model:checked="flushForm.async">
<n-form-item :label="$t('common.warning')" required> {{ $t('dialogue.key.async_delete_title') }}
<n-checkbox v-model:checked="flushForm.confirm"> </n-checkbox>
<span style="color: red; font-weight: bold">{{ $t('dialogue.key.confirm_flush') }}</span> </n-form-item>
</n-checkbox> <n-form-item :label="$t('common.warning')" required>
</n-form-item> <n-checkbox v-model:checked="flushForm.confirm">
</n-form> <span style="color: red; font-weight: bold">{{ $t('dialogue.key.confirm_flush') }}</span>
</n-checkbox>
</n-form-item>
</n-form>
</n-spin>
<template #action> <template #action>
<n-button :focusable="false" @click="onClose">{{ $t('common.cancel') }}</n-button> <n-button :disabled="loading" :focusable="false" @click="onClose">{{ $t('common.cancel') }}</n-button>
<n-button :disabled="!!!flushForm.confirm" :focusable="false" type="primary" @click="onConfirmFlush"> <n-button
:disabled="!!!flushForm.confirm"
:focusable="false"
:loading="loading"
type="primary"
@click="onConfirmFlush">
{{ $t('dialogue.key.confirm_flush_db') }} {{ $t('dialogue.key.confirm_flush_db') }}
</n-button> </n-button>
</template> </template>