diff --git a/README.zh-CN.md b/README.zh-CN.md index 4d26483bf8..d53a6cecb9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -416,6 +416,7 @@ DBX 是免费开源项目,但持续维护、数据库兼容性测试、基础 可以离线使用吗? + 可以。桌面端完全支持离线使用。内网环境安装驱动时,可在有网机器打开[离线驱动下载页](https://dbxio.com/cn/drivers)下载离线驱动包,传输到内网机器后,在 DBX 的「设置 > 驱动管理」中导入。AI 功能需要网络访问模型端点(或通过 Ollama 使用本地模型)。 diff --git a/agents/common/src/main/java/com/dbx/agent/AgentProtocol.java b/agents/common/src/main/java/com/dbx/agent/AgentProtocol.java index 3bba50c452..9b7ce35fe2 100644 --- a/agents/common/src/main/java/com/dbx/agent/AgentProtocol.java +++ b/agents/common/src/main/java/com/dbx/agent/AgentProtocol.java @@ -76,6 +76,7 @@ public final class AgentProtocol { public static final String CAPABILITY_TRANSACTION = "transaction"; public static final String CAPABILITY_DDL = "ddl"; public static final String CAPABILITY_KV = "kv"; + public static final String CAPABILITY_KV_TTL = "kv_ttl"; public static final String CAPABILITY_MULTI_SESSION = "multi_session"; public static final List CAPABILITIES = Collections.unmodifiableList(Arrays.asList( @@ -96,7 +97,8 @@ public final class AgentProtocol { CAPABILITY_PAGED_QUERY, CAPABILITY_TRANSACTION, CAPABILITY_DDL, - CAPABILITY_KV + CAPABILITY_KV, + CAPABILITY_KV_TTL )); public static final List COMMON_METHODS = Collections.unmodifiableList(Arrays.asList( diff --git a/agents/common/src/main/resources/agent-protocol-v1.json b/agents/common/src/main/resources/agent-protocol-v1.json index cec73f1784..63cd20bf50 100644 --- a/agents/common/src/main/resources/agent-protocol-v1.json +++ b/agents/common/src/main/resources/agent-protocol-v1.json @@ -14,7 +14,8 @@ "paged_query", "transaction", "ddl", - "kv" + "kv", + "kv_ttl" ], "capabilities": [ "connect", diff --git a/agents/common/src/main/resources/agent-protocol-v2.json b/agents/common/src/main/resources/agent-protocol-v2.json index 96798e8e6a..a00b78a1c6 100644 --- a/agents/common/src/main/resources/agent-protocol-v2.json +++ b/agents/common/src/main/resources/agent-protocol-v2.json @@ -15,6 +15,7 @@ "transaction", "ddl", "kv", + "kv_ttl", "multi_session" ], "capabilities": [ diff --git a/agents/drivers/etcd/src/main/java/com/dbx/agent/etcd/EtcdAgent.java b/agents/drivers/etcd/src/main/java/com/dbx/agent/etcd/EtcdAgent.java index c29286f047..9c33cede80 100644 --- a/agents/drivers/etcd/src/main/java/com/dbx/agent/etcd/EtcdAgent.java +++ b/agents/drivers/etcd/src/main/java/com/dbx/agent/etcd/EtcdAgent.java @@ -14,7 +14,14 @@ import io.etcd.jetcd.KeyValue; import io.etcd.jetcd.kv.DeleteResponse; import io.etcd.jetcd.kv.GetResponse; import io.etcd.jetcd.kv.PutResponse; +import io.etcd.jetcd.kv.TxnResponse; +import io.etcd.jetcd.lease.LeaseGrantResponse; +import io.etcd.jetcd.lease.LeaseTimeToLiveResponse; +import io.etcd.jetcd.op.Cmp; +import io.etcd.jetcd.op.CmpTarget; +import io.etcd.jetcd.op.Op; import io.etcd.jetcd.options.GetOption; +import io.etcd.jetcd.options.LeaseOption; import io.etcd.jetcd.options.PutOption; import io.netty.handler.ssl.SslContext; import io.netty.handler.ssl.SslContextBuilder; @@ -38,10 +45,12 @@ public final class EtcdAgent { private static final Gson GSON = new Gson(); private static final int DEFAULT_LIMIT = 100; private static final int RPC_TIMEOUT_SECONDS = 30; + private static final int PRESERVE_LEASE_MAX_ATTEMPTS = 3; private static final List CAPABILITIES = Collections.unmodifiableList(Arrays.asList( AgentProtocol.CAPABILITY_CONNECT, AgentProtocol.CAPABILITY_TEST_CONNECTION, - AgentProtocol.CAPABILITY_KV + AgentProtocol.CAPABILITY_KV, + AgentProtocol.CAPABILITY_KV_TTL )); private static Client client; private static KV kv; @@ -162,7 +171,11 @@ public final class EtcdAgent { private static Object get(JsonObject params) throws Exception { KV active = requireKv(); String key = params.get("key").getAsString(); - GetResponse response = active.get(byteSequence(key)).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); + boolean metadataOnly = boolOrDefault(params, "metadataOnly", false); + GetOption option = metadataOnly + ? GetOption.builder().withKeysOnly(true).build() + : GetOption.DEFAULT; + GetResponse response = active.get(byteSequence(key), option).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); Map result = new LinkedHashMap<>(); if (response.getKvs().isEmpty()) { result.put("found", false); @@ -172,26 +185,101 @@ public final class EtcdAgent { return result; } KeyValue item = response.getKvs().get(0); + Map metadata = metadata(item); + if (item.getLease() > 0) { + LeaseTimeToLiveResponse lease = + requireClient().getLeaseClient().timeToLive(item.getLease(), LeaseOption.DEFAULT) + .get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); + metadata.put("ttl", lease.getTTL()); + } result.put("found", true); result.put("key", displayBytes(item.getKey().getBytes())); - result.put("value", valueObject(item.getValue().getBytes())); - result.put("metadata", metadata(item)); + result.put("value", metadataOnly ? null : valueObject(item.getValue().getBytes())); + result.put("metadata", metadata); return result; } private static Object put(JsonObject params) throws Exception { KV active = requireKv(); + Client activeClient = requireClient(); String key = params.get("key").getAsString(); byte[] value = parseValue(params.getAsJsonObject("value")); + ByteSequence keyBytes = byteSequence(key); + ByteSequence valueBytes = ByteSequence.from(value); JsonElement leaseElement = params.get("lease"); - PutResponse response; - if (leaseElement != null && !leaseElement.isJsonNull()) { - PutOption option = PutOption.newBuilder().withLeaseId(leaseElement.getAsLong()).build(); - response = active.put(byteSequence(key), ByteSequence.from(value), option).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - } else { - response = active.put(byteSequence(key), ByteSequence.from(value)).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); + JsonElement ttlElement = params.get("ttl"); + boolean hasLease = leaseElement != null && !leaseElement.isJsonNull(); + boolean hasTtl = ttlElement != null && !ttlElement.isJsonNull(); + boolean preserveLease = boolOrDefault(params, "preserveLease", false); + if ((hasLease && hasTtl) || (preserveLease && (hasLease || hasTtl))) { + throw new IllegalArgumentException("lease, ttl, and preserveLease cannot be specified together"); } - return Collections.singletonMap("revision", response.getHeader().getRevision()); + + if (preserveLease) { + long revision = putPreservingLease(active, keyBytes, valueBytes); + return Collections.singletonMap("revision", revision); + } + + Long leaseId = null; + long grantedLeaseId = 0; + if (hasTtl) { + long ttl = ttlElement.getAsLong(); + if (ttl <= 0) { + throw new IllegalArgumentException("ttl must be a positive integer"); + } + LeaseGrantResponse grant = + activeClient.getLeaseClient().grant(ttl).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); + grantedLeaseId = grant.getID(); + leaseId = grantedLeaseId; + } else if (hasLease) { + leaseId = leaseElement.getAsLong(); + } + + try { + PutResponse response; + if (leaseId != null) { + PutOption option = PutOption.newBuilder().withLeaseId(leaseId).build(); + response = active.put(keyBytes, valueBytes, option) + .get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } else { + response = active.put(keyBytes, valueBytes) + .get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + return Collections.singletonMap("revision", response.getHeader().getRevision()); + } catch (Exception error) { + if (grantedLeaseId != 0) { + try { + activeClient.getLeaseClient().revoke(grantedLeaseId) + .get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (Exception revokeError) { + error.addSuppressed(revokeError); + } + } + throw error; + } + } + + static long putPreservingLease(KV active, ByteSequence key, ByteSequence value) throws Exception { + for (int attempt = 0; attempt < PRESERVE_LEASE_MAX_ATTEMPTS; attempt++) { + GetResponse existing = + active.get(key, GetOption.DEFAULT).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (existing.getKvs().isEmpty() || existing.getKvs().get(0).getLease() <= 0) { + throw new IllegalStateException("Cannot preserve lease: key does not exist or has no lease"); + } + + KeyValue current = existing.getKvs().get(0); + PutOption option = PutOption.builder().withLeaseId(current.getLease()).build(); + TxnResponse response = active.txn() + .If(new Cmp(key, Cmp.Op.EQUAL, CmpTarget.modRevision(current.getModRevision()))) + .Then(Op.put(key, value, option)) + .commit() + .get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (response.isSucceeded()) { + return response.getHeader().getRevision(); + } + } + + throw new IllegalStateException("Cannot preserve lease: key changed concurrently; retry the save"); } private static Object delete(JsonObject params) throws Exception { @@ -262,6 +350,13 @@ public final class EtcdAgent { return kv; } + private static Client requireClient() { + if (client == null) { + throw new IllegalStateException("Not connected"); + } + return client; + } + private static void closeClient() { if (kv != null) { kv.close(); diff --git a/agents/drivers/etcd/src/test/java/com/dbx/agent/etcd/EtcdAgentTest.java b/agents/drivers/etcd/src/test/java/com/dbx/agent/etcd/EtcdAgentTest.java index 65c205df8a..b6de4f8cd7 100644 --- a/agents/drivers/etcd/src/test/java/com/dbx/agent/etcd/EtcdAgentTest.java +++ b/agents/drivers/etcd/src/test/java/com/dbx/agent/etcd/EtcdAgentTest.java @@ -1,8 +1,24 @@ package com.dbx.agent.etcd; +import com.google.protobuf.ByteString; import com.google.gson.JsonObject; import com.google.gson.JsonParser; +import io.etcd.jetcd.ByteSequence; +import io.etcd.jetcd.KV; +import io.etcd.jetcd.Txn; +import io.etcd.jetcd.api.KeyValue; +import io.etcd.jetcd.api.RangeResponse; +import io.etcd.jetcd.api.ResponseHeader; +import io.etcd.jetcd.kv.GetResponse; +import io.etcd.jetcd.kv.TxnResponse; +import java.lang.reflect.Proxy; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.Deque; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -17,6 +33,7 @@ final class EtcdAgentTest { Assertions.assertEquals(1, result.get("protocolVersion").getAsInt()); Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"kv\""))); + Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"kv_ttl\""))); Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"connect\""))); } @@ -52,4 +69,155 @@ final class EtcdAgentTest { Assertions.assertEquals(-1, error.get("code").getAsInt()); Assertions.assertEquals("Not connected", error.get("message").getAsString()); } + + @Test + void preserveLeaseRetriesWithTheLatestLeaseAfterConcurrentChange() throws Exception { + AtomicInteger getCalls = new AtomicInteger(); + AtomicInteger txnCalls = new AtomicInteger(); + KV active = scriptedKv( + getCalls, + txnCalls, + List.of(getResponse("/app/name", 10, 101), getResponse("/app/name", 11, 202)), + List.of(txnResponse(false, 10), txnResponse(true, 12)) + ); + + long revision = EtcdAgent.putPreservingLease(active, bytes("/app/name"), bytes("updated")); + + Assertions.assertEquals(12, revision); + Assertions.assertEquals(2, getCalls.get()); + Assertions.assertEquals(2, txnCalls.get()); + } + + @Test + void preserveLeaseRejectsMissingOrUnleasedKeys() { + KV missing = scriptedKv( + new AtomicInteger(), + new AtomicInteger(), + List.of(emptyGetResponse()), + List.of() + ); + KV unleased = scriptedKv( + new AtomicInteger(), + new AtomicInteger(), + List.of(getResponse("/app/name", 10, 0)), + List.of() + ); + + IllegalStateException missingError = Assertions.assertThrows( + IllegalStateException.class, + () -> EtcdAgent.putPreservingLease(missing, bytes("/app/name"), bytes("updated")) + ); + IllegalStateException unleasedError = Assertions.assertThrows( + IllegalStateException.class, + () -> EtcdAgent.putPreservingLease(unleased, bytes("/app/name"), bytes("updated")) + ); + + Assertions.assertEquals("Cannot preserve lease: key does not exist or has no lease", missingError.getMessage()); + Assertions.assertEquals("Cannot preserve lease: key does not exist or has no lease", unleasedError.getMessage()); + } + + @Test + void preserveLeaseFailsClearlyAfterBoundedConcurrentChanges() { + AtomicInteger getCalls = new AtomicInteger(); + AtomicInteger txnCalls = new AtomicInteger(); + KV active = scriptedKv( + getCalls, + txnCalls, + List.of( + getResponse("/app/name", 10, 101), + getResponse("/app/name", 11, 202), + getResponse("/app/name", 12, 303) + ), + List.of(txnResponse(false, 10), txnResponse(false, 11), txnResponse(false, 12)) + ); + + IllegalStateException error = Assertions.assertThrows( + IllegalStateException.class, + () -> EtcdAgent.putPreservingLease(active, bytes("/app/name"), bytes("updated")) + ); + + Assertions.assertEquals("Cannot preserve lease: key changed concurrently; retry the save", error.getMessage()); + Assertions.assertEquals(3, getCalls.get()); + Assertions.assertEquals(3, txnCalls.get()); + } + + private static KV scriptedKv( + AtomicInteger getCalls, + AtomicInteger txnCalls, + List getResponses, + List txnResponses + ) { + Deque gets = new ArrayDeque<>(getResponses); + Deque txns = new ArrayDeque<>(txnResponses); + return (KV) Proxy.newProxyInstance( + KV.class.getClassLoader(), + new Class>[] { KV.class }, + (proxy, method, args) -> { + if (method.getName().equals("get")) { + getCalls.incrementAndGet(); + return CompletableFuture.completedFuture(gets.removeFirst()); + } + if (method.getName().equals("txn")) { + return scriptedTxn(txnCalls, txns); + } + if (method.getName().equals("close")) { + return null; + } + throw new UnsupportedOperationException(method.getName()); + } + ); + } + + private static Txn scriptedTxn(AtomicInteger txnCalls, Deque responses) { + return (Txn) Proxy.newProxyInstance( + Txn.class.getClassLoader(), + new Class>[] { Txn.class }, + (proxy, method, args) -> { + if (Arrays.asList("If", "Then", "Else").contains(method.getName())) { + return proxy; + } + if (method.getName().equals("commit")) { + txnCalls.incrementAndGet(); + return CompletableFuture.completedFuture(responses.removeFirst()); + } + throw new UnsupportedOperationException(method.getName()); + } + ); + } + + private static ByteSequence bytes(String value) { + return ByteSequence.from(value, StandardCharsets.UTF_8); + } + + private static GetResponse getResponse(String key, long modRevision, long lease) { + KeyValue keyValue = KeyValue.newBuilder() + .setKey(ByteString.copyFromUtf8(key)) + .setModRevision(modRevision) + .setLease(lease) + .build(); + RangeResponse response = RangeResponse.newBuilder() + .setHeader(header(modRevision)) + .addKvs(keyValue) + .build(); + return new GetResponse(response, ByteSequence.EMPTY); + } + + private static GetResponse emptyGetResponse() { + return new GetResponse( + RangeResponse.newBuilder().setHeader(header(1)).build(), + ByteSequence.EMPTY + ); + } + + private static TxnResponse txnResponse(boolean succeeded, long revision) { + io.etcd.jetcd.api.TxnResponse response = io.etcd.jetcd.api.TxnResponse.newBuilder() + .setHeader(header(revision)) + .setSucceeded(succeeded) + .build(); + return new TxnResponse(response, ByteSequence.EMPTY); + } + + private static ResponseHeader header(long revision) { + return ResponseHeader.newBuilder().setRevision(revision).build(); + } } diff --git a/apps/desktop/src/components/etcd/EtcdKeyBrowser.vue b/apps/desktop/src/components/etcd/EtcdKeyBrowser.vue index 29249dea52..da92670d89 100644 --- a/apps/desktop/src/components/etcd/EtcdKeyBrowser.vue +++ b/apps/desktop/src/components/etcd/EtcdKeyBrowser.vue @@ -1,5 +1,5 @@ - + diff --git a/apps/desktop/src/components/etcd/__tests__/EtcdKeyBrowser.spec.ts b/apps/desktop/src/components/etcd/__tests__/EtcdKeyBrowser.spec.ts new file mode 100644 index 0000000000..fb2da1aa56 --- /dev/null +++ b/apps/desktop/src/components/etcd/__tests__/EtcdKeyBrowser.spec.ts @@ -0,0 +1,131 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createApp, defineComponent, h, nextTick, type App } from "vue"; + +const backend = vi.hoisted(() => ({ + etcdSupportsTtl: vi.fn(), +})); + +vi.mock("vue-i18n", () => ({ + useI18n: () => ({ t: (key: string) => key }), +})); + +vi.mock("@/lib/backend/api", () => ({ + etcdListPrefix: vi.fn(), + etcdGet: vi.fn(), + etcdPut: vi.fn(), + etcdDelete: vi.fn(), + etcdSupportsTtl: backend.etcdSupportsTtl, +})); + +vi.mock("@/components/kv/KvKeyBrowser.vue", () => ({ + default: defineComponent({ + name: "KvKeyBrowserStub", + props: { + supportsTtl: { type: Boolean, default: false }, + ttlCapabilityKnown: { type: Boolean, default: true }, + }, + emits: ["refreshRequested"], + setup(props, { emit, expose }) { + expose({ + focusSearch: () => true, + refresh: () => { + emit("refreshRequested"); + return true; + }, + }); + return () => + h( + "button", + { + id: "kv-browser-stub", + "data-supports-ttl": String(props.supportsTtl), + "data-capability-known": String(props.ttlCapabilityKnown), + onClick: () => emit("refreshRequested"), + }, + "refresh", + ); + }, + }), +})); + +import EtcdKeyBrowser from "@/components/etcd/EtcdKeyBrowser.vue"; + +let app: App | null = null; +let root: HTMLDivElement | null = null; + +async function flushUi() { + await Promise.resolve(); + await Promise.resolve(); + await nextTick(); +} + +async function mountBrowser() { + root = document.createElement("div"); + document.body.appendChild(root); + app = createApp(EtcdKeyBrowser, { connectionId: "etcd-1" }); + app.mount(root); + await flushUi(); + return root.querySelector("#kv-browser-stub")!; +} + +beforeEach(() => { + vi.useFakeTimers(); + backend.etcdSupportsTtl.mockReset(); +}); + +afterEach(() => { + app?.unmount(); + app = null; + root?.remove(); + root = null; + vi.useRealTimers(); +}); + +describe("EtcdKeyBrowser TTL capability recovery", () => { + it("keeps a transient probe failure unknown and retries automatically", async () => { + backend.etcdSupportsTtl.mockRejectedValueOnce(new Error("Agent is reconnecting")).mockResolvedValue(true); + + const browser = await mountBrowser(); + expect(browser.dataset.supportsTtl).toBe("false"); + expect(browser.dataset.capabilityKnown).toBe("false"); + + await vi.advanceTimersByTimeAsync(5000); + await flushUi(); + + expect(backend.etcdSupportsTtl).toHaveBeenCalledTimes(2); + expect(browser.dataset.supportsTtl).toBe("true"); + expect(browser.dataset.capabilityKnown).toBe("true"); + }); + + it("rechecks a confirmed unsupported Agent when the user refreshes", async () => { + backend.etcdSupportsTtl.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + + const browser = await mountBrowser(); + expect(browser.dataset.supportsTtl).toBe("false"); + expect(browser.dataset.capabilityKnown).toBe("true"); + + browser.click(); + await flushUi(); + + expect(backend.etcdSupportsTtl).toHaveBeenCalledTimes(2); + expect(browser.dataset.supportsTtl).toBe("true"); + expect(browser.dataset.capabilityKnown).toBe("true"); + }); + + it("keeps the last confirmed capability through a transient later failure", async () => { + backend.etcdSupportsTtl.mockResolvedValueOnce(true).mockRejectedValueOnce(new Error("temporary failure")); + + const browser = await mountBrowser(); + expect(browser.dataset.supportsTtl).toBe("true"); + expect(browser.dataset.capabilityKnown).toBe("true"); + + await vi.advanceTimersByTimeAsync(5000); + await flushUi(); + + expect(backend.etcdSupportsTtl).toHaveBeenCalledTimes(2); + expect(browser.dataset.supportsTtl).toBe("true"); + expect(browser.dataset.capabilityKnown).toBe("true"); + }); +}); diff --git a/apps/desktop/src/components/kv/KvKeyBrowser.vue b/apps/desktop/src/components/kv/KvKeyBrowser.vue index cbef150748..8158241a42 100644 --- a/apps/desktop/src/components/kv/KvKeyBrowser.vue +++ b/apps/desktop/src/components/kv/KvKeyBrowser.vue @@ -1,5 +1,5 @@ @@ -598,7 +846,7 @@ defineExpose({ focusSearch, refresh }); - + {{ t("grid.refresh") }} @@ -678,6 +926,7 @@ defineExpose({ focusSearch, refresh }); rev {{ metadataLabel(selectedMetadata?.modRevision) }} ver {{ metadataLabel(selectedMetadata?.version) }} lease {{ metadataLabel(selectedMetadata?.lease) }} + TTL {{ selectedTtlLabel }} {{ metadataLabel(selectedMetadata?.valueSize) }} B @@ -744,6 +993,13 @@ defineExpose({ focusSearch, refresh }); + + {{ labels.ttl || "TTL (seconds)" }} + + + + {{ labels.ttlUnavailable }} + diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 8203eeab53..6b573355cd 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2634,6 +2634,10 @@ export default { deleteTitle: "Delete etcd key", keyPlaceholder: "/path/to/key", keyRequired: "Key is required", + ttl: "TTL (seconds)", + ttlPlaceholder: "Optional; leave blank for no expiry", + ttlInvalid: "TTL must be a positive integer", + ttlUnavailable: "Update the etcd Agent and reconnect to configure TTL.", saved: "Key saved", deleted: "Key deleted", base64Readonly: "Base64 values are read-only in this version.", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 43c3a42c55..3076df52b5 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2486,6 +2486,10 @@ export default withEnglishFallback({ deleteTitle: "Eliminar clave de etcd", keyPlaceholder: "/ruta/a/clave", keyRequired: "La clave es obligatoria", + ttl: "TTL (segundos)", + ttlPlaceholder: "Opcional; vacío significa sin caducidad", + ttlInvalid: "El TTL debe ser un entero positivo", + ttlUnavailable: "Actualiza el Agent de etcd y vuelve a conectarte para configurar el TTL.", saved: "Clave guardada", deleted: "Clave eliminada", base64Readonly: "Los valores Base64 son de solo lectura en esta versión.", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 2f5c016030..3758c1bbf1 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2484,6 +2484,10 @@ export default withEnglishFallback({ deleteTitle: "Elimina chiave etcd", keyPlaceholder: "/percorso/per/la/chiave", keyRequired: "La chiave e obbligatoria", + ttl: "TTL (secondi)", + ttlPlaceholder: "Facoltativo; vuoto significa nessuna scadenza", + ttlInvalid: "Il TTL deve essere un numero intero positivo", + ttlUnavailable: "Aggiorna l'Agent etcd e riconnettiti per configurare il TTL.", saved: "Chiave salvata", deleted: "Chiave eliminata", base64Readonly: "I valori Base64 sono in sola lettura in questa versione.", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 59e87fcd9e..1b361f8e6a 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2519,6 +2519,10 @@ export default withEnglishFallback({ deleteTitle: "etcdキーを削除", keyPlaceholder: "/path/to/key", keyRequired: "キーは必須です", + ttl: "TTL(秒)", + ttlPlaceholder: "任意。空欄の場合は有効期限なし", + ttlInvalid: "TTLには正の整数を指定してください", + ttlUnavailable: "TTLを設定するにはetcd Agentを更新して再接続してください。", saved: "キーを保存しました", deleted: "キーを削除しました", base64Readonly: "Base64値はこのバージョンでは読み取り専用です。", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 29f678af33..766cd098c3 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2486,6 +2486,10 @@ export default withEnglishFallback({ deleteTitle: "Excluir chave etcd", keyPlaceholder: "/caminho/para/chave", keyRequired: "Chave é obrigatória", + ttl: "TTL (segundos)", + ttlPlaceholder: "Opcional; vazio significa sem expiração", + ttlInvalid: "O TTL deve ser um número inteiro positivo", + ttlUnavailable: "Atualize o Agent do etcd e reconecte para configurar o TTL.", saved: "Chave salva", deleted: "Chave excluída", base64Readonly: "Valores Base64 são somente leitura nesta versão.", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 9dacd86c33..81a2e7cac1 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2634,6 +2634,10 @@ export default withEnglishFallback({ deleteTitle: "删除 etcd Key", keyPlaceholder: "/path/to/key", keyRequired: "Key 不能为空", + ttl: "TTL(秒)", + ttlPlaceholder: "可选,留空表示永不过期", + ttlInvalid: "TTL 必须是正整数", + ttlUnavailable: "请更新 etcd Agent 并重新连接后再配置 TTL。", saved: "Key 已保存", deleted: "Key 已删除", base64Readonly: "Base64 值当前版本只读。", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index ee05c6b297..6ba0152a17 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -4415,6 +4415,10 @@ export default withEnglishFallback({ deleteTitle: "刪除 etcd key", keyPlaceholder: "/path/to/key", keyRequired: "Key 不能為空", + ttl: "TTL(秒)", + ttlPlaceholder: "選填,留空表示永不過期", + ttlInvalid: "TTL 必須是正整數", + ttlUnavailable: "請更新 etcd Agent 並重新連線後再設定 TTL。", saved: "Key 已儲存", deleted: "Key 已刪除", base64Readonly: "Base64 值在此版本中為唯讀。", diff --git a/apps/desktop/src/lib/__tests__/etcd/etcdApiContract.spec.ts b/apps/desktop/src/lib/__tests__/etcd/etcdApiContract.spec.ts new file mode 100644 index 0000000000..a17dc1bbb2 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/etcd/etcdApiContract.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { etcdDelete, etcdGet, etcdListPrefix, etcdPut, etcdSupportsTtl, type KvPutOptions } from "@/lib/backend/api"; + +describe("etcd frontend API contract", () => { + it("exposes TTL capability detection and TTL-aware put options", () => { + const expiring: KvPutOptions = { ttl: 30 }; + const preserving: KvPutOptions = { preserveLease: true }; + + expect(expiring.ttl).toBe(30); + expect(preserving.preserveLease).toBe(true); + expect(typeof etcdSupportsTtl).toBe("function"); + expect(typeof etcdListPrefix).toBe("function"); + expect(typeof etcdGet).toBe("function"); + expect(typeof etcdPut).toBe("function"); + expect(typeof etcdDelete).toBe("function"); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/kv/kvMetadataRefresh.spec.ts b/apps/desktop/src/lib/__tests__/kv/kvMetadataRefresh.spec.ts new file mode 100644 index 0000000000..420bd3737f --- /dev/null +++ b/apps/desktop/src/lib/__tests__/kv/kvMetadataRefresh.spec.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import type { KvGetResponse } from "@/lib/backend/api"; +import { decideKvMetadataRefresh, knownKvLeaseKeys, KvListRequestGuard, mergeKvKeyMetadata, mergeKvValueRefresh, nextKvLeaseRefreshDelay, removeMissingKvKey, updateKvResponseTtl } from "@/lib/kv/kvMetadataRefresh"; + +function found(metadata: NonNullable): KvGetResponse { + return { + found: true, + key: "/service", + value: { encoding: "utf8", data: "value" }, + metadata, + }; +} + +describe("decideKvMetadataRefresh", () => { + it("keeps only the latest foreground request current", () => { + const guard = new KvListRequestGuard(); + const firstPageRequest = guard.beginForegroundRequest(); + expect(guard.isForegroundRequestCurrent(firstPageRequest)).toBe(true); + + const loadMoreRequest = guard.beginForegroundRequest(); + expect(guard.isForegroundRequestCurrent(firstPageRequest)).toBe(false); + expect(guard.isForegroundRequestCurrent(loadMoreRequest)).toBe(true); + }); + + it("updates only the TTL when the selected key metadata is unchanged", () => { + const current = found({ modRevision: 10, lease: 20, ttl: 8, valueSize: 5 }); + const incoming = found({ modRevision: 10, lease: 20, ttl: 7 }); + + expect(decideKvMetadataRefresh(current, incoming)).toEqual({ type: "update", ttl: 7 }); + }); + + it("reloads the full value when the revision or lease changes", () => { + const current = found({ modRevision: 10, lease: 20, ttl: 8 }); + + expect(decideKvMetadataRefresh(current, found({ modRevision: 11, lease: 20, ttl: 7 }))).toEqual({ type: "reload" }); + expect(decideKvMetadataRefresh(current, found({ modRevision: 10, lease: 21, ttl: 7 }))).toEqual({ type: "reload" }); + }); + + it("keeps polling without a full reload while etcd finalizes an expired lease", () => { + const current = found({ modRevision: 10, lease: 20, ttl: 1 }); + + expect(decideKvMetadataRefresh(current, found({ modRevision: 10, lease: 20, ttl: 0 }))).toEqual({ type: "update", ttl: 0 }); + expect(decideKvMetadataRefresh(current, found({ modRevision: 10, lease: 20 }))).toEqual({ type: "reload" }); + }); + + it("updates TTL in place without replacing the value response", () => { + const current = found({ modRevision: 10, lease: 20, ttl: 8 }); + const value = current.value; + + expect(updateKvResponseTtl(current, 7)).toBe(true); + expect(current.metadata?.ttl).toBe(7); + expect(current.value).toBe(value); + }); + + it("keeps the existing response during a silent full refresh when only TTL changed", () => { + const current = found({ modRevision: 10, lease: 20, ttl: 8 }); + const incoming = found({ modRevision: 10, lease: 20, ttl: 7 }); + + expect(mergeKvValueRefresh(current, incoming)).toBe(current); + expect(current.metadata?.ttl).toBe(7); + }); + + it("replaces the response when a silent refresh finds a new value revision", () => { + const current = found({ modRevision: 10, lease: 20, ttl: 8 }); + const incoming = found({ modRevision: 11, lease: 20, ttl: 7 }); + incoming.value = { encoding: "utf8", data: "updated" }; + + expect(mergeKvValueRefresh(current, incoming)).toBe(incoming); + }); + + it("stops and exposes a missing key without reloading", () => { + expect(decideKvMetadataRefresh(found({ modRevision: 10, lease: 20, ttl: 1 }), { found: false })).toEqual({ type: "notFound" }); + }); + + it("stops when a metadata refresh cannot be reconciled with the current value", () => { + expect(decideKvMetadataRefresh(null, found({ modRevision: 10, lease: 20, ttl: 1 }))).toEqual({ type: "stop" }); + }); + + it("removes an expired key from the cached key list", () => { + const keys = [{ key: "/dbx/a" }, { key: "/dbx/aaaaa" }, { key: "/test/a" }]; + + expect(removeMissingKvKey(keys, "/dbx/aaaaa")).toEqual([{ key: "/dbx/a" }, { key: "/test/a" }]); + }); + + it("tracks only known leased keys and excludes the selected key", () => { + const keys = [{ key: "/no-lease", lease: 0 }, { key: "/leased", lease: 10 }, { key: "/selected", lease: 20 }, { key: "/unknown" }]; + + expect(knownKvLeaseKeys(keys, "/selected")).toEqual(["/leased"]); + expect(knownKvLeaseKeys(keys, null)).toEqual(["/leased", "/selected"]); + }); + + it("merges refreshed metadata without replacing the known value size and removes expired keys", () => { + const keys = [ + { key: "/leased", lease: 10, ttl: 4, valueSize: 12 }, + { key: "/expired", lease: 20, ttl: 1 }, + ]; + + expect(mergeKvKeyMetadata(keys, "/leased", found({ lease: 11, ttl: 3, modRevision: 5, valueSize: 0 }))).toEqual([ + { key: "/leased", lease: 11, ttl: 3, modRevision: 5, valueSize: 12 }, + { key: "/expired", lease: 20, ttl: 1 }, + ]); + expect(mergeKvKeyMetadata(keys, "/expired", { found: false })).toEqual([{ key: "/leased", lease: 10, ttl: 4, valueSize: 12 }]); + }); + + it("backs off after failures and resets after a successful cycle", () => { + expect(nextKvLeaseRefreshDelay(2000, true)).toBe(4000); + expect(nextKvLeaseRefreshDelay(4000, true)).toBe(8000); + expect(nextKvLeaseRefreshDelay(16000, true)).toBe(30000); + expect(nextKvLeaseRefreshDelay(30000, true)).toBe(30000); + expect(nextKvLeaseRefreshDelay(16000, false)).toBe(2000); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/kv/kvTtl.spec.ts b/apps/desktop/src/lib/__tests__/kv/kvTtl.spec.ts new file mode 100644 index 0000000000..7f6ab62601 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/kv/kvTtl.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { parseOptionalTtl } from "@/lib/kv/kvTtl"; + +describe("parseOptionalTtl", () => { + it("treats an empty TTL as a permanent key", () => { + expect(parseOptionalTtl("")).toEqual({ ok: true, ttl: null }); + expect(parseOptionalTtl(" ")).toEqual({ ok: true, ttl: null }); + expect(parseOptionalTtl(undefined)).toEqual({ ok: true, ttl: null }); + expect(parseOptionalTtl(null)).toEqual({ ok: true, ttl: null }); + }); + + it("accepts positive integer seconds from text and number inputs", () => { + expect(parseOptionalTtl("1")).toEqual({ ok: true, ttl: 1 }); + expect(parseOptionalTtl(" 3600 ")).toEqual({ ok: true, ttl: 3600 }); + expect(parseOptionalTtl(10)).toEqual({ ok: true, ttl: 10 }); + }); + + it("rejects zero, negative, decimal, and unsafe TTL values", () => { + expect(parseOptionalTtl("0")).toEqual({ ok: false }); + expect(parseOptionalTtl("-1")).toEqual({ ok: false }); + expect(parseOptionalTtl("1.5")).toEqual({ ok: false }); + expect(parseOptionalTtl(1.5)).toEqual({ ok: false }); + expect(parseOptionalTtl("9007199254740992")).toEqual({ ok: false }); + }); +}); diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 83e7dc25c0..9cbfc5c039 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -406,6 +406,7 @@ export const redisClusterMasterNodes = forward("redisClusterMasterNodes"); // etcd export const etcdListPrefix = forward("etcdListPrefix"); +export const etcdSupportsTtl = forward("etcdSupportsTtl"); export const etcdGet = forward("etcdGet"); export const etcdPut = forward("etcdPut"); export const etcdDelete = forward("etcdDelete"); @@ -623,6 +624,7 @@ export type { KvListPrefixResponse, KvListPrefixOptions, KvGetResponse, + KvGetOptions, KvWriteMode, KvCreateMode, KvPutOptions, diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 7bff515d64..7d41bc7cd6 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -76,6 +76,7 @@ import type { KvListPrefixResponse, KvListPrefixOptions, KvGetResponse, + KvGetOptions, KvPutOptions, KvPutResponse, KvDeleteResponse, @@ -2051,12 +2052,25 @@ export async function etcdListPrefix(connectionId: string, prefix: string, limit return post("/api/etcd/list-prefix", { connectionId, prefix, limit, continuation }); } -export async function etcdGet(connectionId: string, key: string): Promise { - return post("/api/etcd/get", { connectionId, key }); +export async function etcdSupportsTtl(connectionId: string): Promise { + return post("/api/etcd/supports-ttl", { connectionId }); } -export async function etcdPut(connectionId: string, key: string, value: KvValue, lease?: number | null): Promise { - return post("/api/etcd/put", { connectionId, key, value, lease }); +export async function etcdGet(connectionId: string, key: string, options?: KvGetOptions | null): Promise { + return post("/api/etcd/get", { connectionId, key, metadataOnly: options?.metadataOnly ?? null }); +} + +export async function etcdPut(connectionId: string, key: string, value: KvValue, options?: KvPutOptions | number | null): Promise { + const legacyLease = typeof options === "number" ? options : null; + const putOptions = typeof options === "object" ? options : null; + return post("/api/etcd/put", { + connectionId, + key, + value, + lease: legacyLease ?? putOptions?.lease ?? null, + ttl: putOptions?.ttl ?? null, + preserveLease: putOptions?.preserveLease ?? null, + }); } export async function etcdDelete(connectionId: string, key: string): Promise { diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 2aba5ab47c..f01fc2a524 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -1777,6 +1777,7 @@ export interface KvKeyMetadata { modRevision?: number | null; version?: number | null; lease?: number | null; + ttl?: number | null; valueSize?: number | null; czxid?: number | null; mzxid?: number | null; @@ -1811,6 +1812,10 @@ export interface KvGetResponse { metadata?: KvKeyMetadata | null; } +export interface KvGetOptions { + metadataOnly?: boolean | null; +} + export interface KvPutResponse { revision?: number | null; version?: number | null; @@ -1823,6 +1828,9 @@ export type KvWriteMode = "upsert" | "create" | "update"; export type KvCreateMode = "persistent" | "ephemeral" | "persistent_sequential" | "ephemeral_sequential"; export interface KvPutOptions { + lease?: number | null; + ttl?: number | null; + preserveLease?: boolean | null; writeMode?: KvWriteMode | null; createMode?: KvCreateMode | null; } @@ -1836,12 +1844,25 @@ export async function etcdListPrefix(connectionId: string, prefix: string, limit return invoke("etcd_list_prefix", { connectionId, prefix, limit, continuation }); } -export async function etcdGet(connectionId: string, key: string): Promise { - return invoke("etcd_get", { connectionId, key }); +export async function etcdSupportsTtl(connectionId: string): Promise { + return invoke("etcd_supports_ttl", { connectionId }); } -export async function etcdPut(connectionId: string, key: string, value: KvValue, lease?: number | null): Promise { - return invoke("etcd_put", { connectionId, key, value, lease }); +export async function etcdGet(connectionId: string, key: string, options?: KvGetOptions | null): Promise { + return invoke("etcd_get", { connectionId, key, metadataOnly: options?.metadataOnly ?? null }); +} + +export async function etcdPut(connectionId: string, key: string, value: KvValue, options?: KvPutOptions | number | null): Promise { + const legacyLease = typeof options === "number" ? options : null; + const putOptions = typeof options === "object" ? options : null; + return invoke("etcd_put", { + connectionId, + key, + value, + lease: legacyLease ?? putOptions?.lease ?? null, + ttl: putOptions?.ttl ?? null, + preserveLease: putOptions?.preserveLease ?? null, + }); } export async function etcdDelete(connectionId: string, key: string): Promise { diff --git a/apps/desktop/src/lib/kv/kvMetadataRefresh.ts b/apps/desktop/src/lib/kv/kvMetadataRefresh.ts new file mode 100644 index 0000000000..c0fa5ae267 --- /dev/null +++ b/apps/desktop/src/lib/kv/kvMetadataRefresh.ts @@ -0,0 +1,65 @@ +import type { KvGetResponse, KvKeySummary } from "@/lib/backend/api"; + +export type KvMetadataRefreshDecision = { type: "notFound" } | { type: "stop" } | { type: "reload" } | { type: "update"; ttl: number }; + +export class KvListRequestGuard { + private foregroundGeneration = 0; + + beginForegroundRequest(): number { + return ++this.foregroundGeneration; + } + + isForegroundRequestCurrent(generation: number): boolean { + return generation === this.foregroundGeneration; + } +} + +export function decideKvMetadataRefresh(current: KvGetResponse | null, incoming: KvGetResponse): KvMetadataRefreshDecision { + if (!incoming.found) return { type: "notFound" }; + if (!current?.found || !incoming.metadata) return { type: "stop" }; + if (!current.metadata) return { type: "reload" }; + + const revisionChanged = current.metadata.modRevision != null && incoming.metadata.modRevision != null && current.metadata.modRevision !== incoming.metadata.modRevision; + const leaseChanged = current.metadata.lease != null && incoming.metadata.lease != null && current.metadata.lease !== incoming.metadata.lease; + const ttl = incoming.metadata.ttl; + + if (revisionChanged || leaseChanged || ttl == null) return { type: "reload" }; + return { type: "update", ttl: Math.max(0, ttl) }; +} + +export function updateKvResponseTtl(current: KvGetResponse, ttl: number): boolean { + if (!current.metadata) return false; + current.metadata.ttl = ttl; + return true; +} + +export function mergeKvValueRefresh(current: KvGetResponse | null, incoming: KvGetResponse): KvGetResponse { + if (!current?.found || !incoming.found || !current.metadata || !incoming.metadata) return incoming; + + const sameValue = current.value?.encoding === incoming.value?.encoding && current.value?.data === incoming.value?.data; + const sameRevision = current.metadata.modRevision === incoming.metadata.modRevision; + const sameLease = current.metadata.lease === incoming.metadata.lease; + if (!sameValue || !sameRevision || !sameLease) return incoming; + + if (incoming.metadata.ttl != null) current.metadata.ttl = Math.max(0, incoming.metadata.ttl); + return current; +} + +export function removeMissingKvKey(keys: readonly KvKeySummary[], missingKey: string): KvKeySummary[] { + return keys.filter((key) => key.key !== missingKey); +} + +export function knownKvLeaseKeys(keys: readonly KvKeySummary[], selectedKey: string | null): string[] { + return keys.filter((key) => key.key !== selectedKey && typeof key.lease === "number" && key.lease > 0).map((key) => key.key); +} + +export function mergeKvKeyMetadata(keys: readonly KvKeySummary[], key: string, incoming: KvGetResponse): KvKeySummary[] { + if (!incoming.found) return removeMissingKvKey(keys, key); + if (!incoming.metadata) return [...keys]; + return keys.map((item) => (item.key === key ? { ...item, ...incoming.metadata, key: item.key, valueSize: item.valueSize } : item)); +} + +export function nextKvLeaseRefreshDelay(currentDelayMs: number, failed: boolean, baseDelayMs = 2000, maxDelayMs = 30000): number { + if (!failed) return baseDelayMs; + return Math.min(maxDelayMs, Math.max(baseDelayMs, currentDelayMs) * 2); +} diff --git a/apps/desktop/src/lib/kv/kvTtl.ts b/apps/desktop/src/lib/kv/kvTtl.ts new file mode 100644 index 0000000000..73bf8803d2 --- /dev/null +++ b/apps/desktop/src/lib/kv/kvTtl.ts @@ -0,0 +1,13 @@ +export type ParsedOptionalTtl = { ok: true; ttl: number | null } | { ok: false }; + +export function parseOptionalTtl(value: string | number | null | undefined): ParsedOptionalTtl { + if (value == null) return { ok: true, ttl: null }; + + const trimmed = String(value).trim(); + if (!trimmed) return { ok: true, ttl: null }; + if (!/^[1-9]\d*$/.test(trimmed)) return { ok: false }; + + const ttl = Number(trimmed); + if (!Number.isSafeInteger(ttl)) return { ok: false }; + return { ok: true, ttl }; +} diff --git a/crates/dbx-core/assets/agent-protocol-v1.json b/crates/dbx-core/assets/agent-protocol-v1.json index cec73f1784..63cd20bf50 100644 --- a/crates/dbx-core/assets/agent-protocol-v1.json +++ b/crates/dbx-core/assets/agent-protocol-v1.json @@ -14,7 +14,8 @@ "paged_query", "transaction", "ddl", - "kv" + "kv", + "kv_ttl" ], "capabilities": [ "connect", diff --git a/crates/dbx-core/assets/agent-protocol-v2.json b/crates/dbx-core/assets/agent-protocol-v2.json index 96798e8e6a..a00b78a1c6 100644 --- a/crates/dbx-core/assets/agent-protocol-v2.json +++ b/crates/dbx-core/assets/agent-protocol-v2.json @@ -15,6 +15,7 @@ "transaction", "ddl", "kv", + "kv_ttl", "multi_session" ], "capabilities": [ diff --git a/crates/dbx-core/src/agent_kv.rs b/crates/dbx-core/src/agent_kv.rs index 8b279c65a5..b3cabf7b48 100644 --- a/crates/dbx-core/src/agent_kv.rs +++ b/crates/dbx-core/src/agent_kv.rs @@ -24,6 +24,7 @@ pub struct KvKeyMetadata { pub mod_revision: Option, pub version: Option, pub lease: Option, + pub ttl: Option, pub value_size: Option, pub czxid: Option, pub mzxid: Option, @@ -68,6 +69,8 @@ pub struct KvListPrefixResponse { #[serde(rename_all = "camelCase")] pub struct KvGetRequest { pub key: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata_only: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -87,6 +90,10 @@ pub struct KvPutRequest { #[serde(skip_serializing_if = "Option::is_none")] pub lease: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub ttl: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub preserve_lease: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub write_mode: Option, #[serde(skip_serializing_if = "Option::is_none")] pub create_mode: Option, @@ -98,6 +105,10 @@ pub struct KvPutOptions { #[serde(skip_serializing_if = "Option::is_none")] pub lease: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub ttl: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub preserve_lease: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub write_mode: Option, #[serde(skip_serializing_if = "Option::is_none")] pub create_mode: Option, @@ -161,7 +172,12 @@ pub fn kv_list_prefix_params_with_options( } pub fn kv_get_params(key: &str) -> serde_json::Value { - serde_json::to_value(KvGetRequest { key: key.to_string() }).expect("KV get request should serialize") + kv_get_params_with_options(key, false) +} + +pub fn kv_get_params_with_options(key: &str, metadata_only: bool) -> serde_json::Value { + serde_json::to_value(KvGetRequest { key: key.to_string(), metadata_only: metadata_only.then_some(true) }) + .expect("KV get request should serialize") } pub fn kv_put_params(key: &str, value: KvValue, lease: Option) -> serde_json::Value { @@ -173,6 +189,8 @@ pub fn kv_put_params_with_options(key: &str, value: KvValue, options: KvPutOptio key: key.to_string(), value, lease: options.lease, + ttl: options.ttl, + preserve_lease: options.preserve_lease, write_mode: options.write_mode, create_mode: options.create_mode, }) @@ -206,12 +224,22 @@ pub async fn kv_list_prefix_core_with_options( connection_id, AgentKvMethod::ListPrefix, kv_list_prefix_params_with_options(prefix, limit, continuation, recursive), + None, ) .await } pub async fn kv_get_core(state: &AppState, connection_id: &str, key: &str) -> Result { - call_agent_kv(state, connection_id, AgentKvMethod::Get, kv_get_params(key)).await + kv_get_core_with_options(state, connection_id, key, false).await +} + +pub async fn kv_get_core_with_options( + state: &AppState, + connection_id: &str, + key: &str, + metadata_only: bool, +) -> Result { + call_agent_kv(state, connection_id, AgentKvMethod::Get, kv_get_params_with_options(key, metadata_only), None).await } pub async fn kv_put_core( @@ -231,11 +259,37 @@ pub async fn kv_put_core_with_options( value: KvValue, options: KvPutOptions, ) -> Result { - call_agent_kv(state, connection_id, AgentKvMethod::Put, kv_put_params_with_options(key, value, options)).await + let required_capability = kv_put_required_capability(&options); + call_agent_kv( + state, + connection_id, + AgentKvMethod::Put, + kv_put_params_with_options(key, value, options), + required_capability, + ) + .await +} + +fn kv_put_required_capability(options: &KvPutOptions) -> Option { + (options.ttl.is_some() || options.preserve_lease == Some(true)).then_some(AgentCapability::KvTtl) } pub async fn kv_delete_core(state: &AppState, connection_id: &str, key: &str) -> Result { - call_agent_kv(state, connection_id, AgentKvMethod::Delete, kv_delete_params(key)).await + call_agent_kv(state, connection_id, AgentKvMethod::Delete, kv_delete_params(key), None).await +} + +pub async fn kv_supports_ttl_core(state: &AppState, connection_id: &str) -> Result { + ensure_agent_kv_pool(state, connection_id).await?; + + let connections = state.connections.read().await; + let pool = connections.get(connection_id).ok_or("Connection not found")?; + match pool { + PoolKind::Agent(client) => { + let client = client.lock().await; + Ok(client.supports_capability(AgentCapability::KvTtl)) + } + _ => Err("Not an agent key-value connection".to_string()), + } } async fn ensure_agent_kv_pool(state: &AppState, connection_id: &str) -> Result<(), String> { @@ -247,6 +301,7 @@ async fn call_agent_kv( connection_id: &str, method: AgentKvMethod, params: serde_json::Value, + required_capability: Option, ) -> Result { ensure_agent_kv_pool(state, connection_id).await?; @@ -258,6 +313,11 @@ async fn call_agent_kv( if !client.supports_capability(AgentCapability::Kv) { return Err("Agent does not support key-value operations".to_string()); } + if required_capability.is_some_and(|capability| !client.supports_capability(capability)) { + return Err( + "Installed etcd Agent does not support TTL. Update the etcd driver and reconnect.".to_string() + ); + } client.call_kv_method(method, params).await } _ => Err("Not an agent key-value connection".to_string()), @@ -302,6 +362,13 @@ mod tests { #[test] fn serializes_kv_get_put_delete_params() { assert_eq!(kv_get_params("/app/name"), serde_json::json!({ "key": "/app/name" })); + assert_eq!( + kv_get_params_with_options("/app/name", true), + serde_json::json!({ + "key": "/app/name", + "metadataOnly": true + }) + ); assert_eq!( kv_put_params("/app/name", KvValue { encoding: KvValueEncoding::Utf8, data: "dbx".to_string() }, Some(42),), serde_json::json!({ @@ -349,6 +416,50 @@ mod tests { "createMode": "ephemeral_sequential" }) ); + assert_eq!( + kv_put_params_with_options( + "/jobs/expiring", + KvValue { encoding: KvValueEncoding::Utf8, data: "value".to_string() }, + KvPutOptions { ttl: Some(60), ..KvPutOptions::default() }, + ), + serde_json::json!({ + "key": "/jobs/expiring", + "value": { + "encoding": "utf8", + "data": "value" + }, + "ttl": 60 + }) + ); + assert_eq!( + kv_put_params_with_options( + "/jobs/expiring", + KvValue { encoding: KvValueEncoding::Utf8, data: "updated".to_string() }, + KvPutOptions { preserve_lease: Some(true), ..KvPutOptions::default() }, + ), + serde_json::json!({ + "key": "/jobs/expiring", + "value": { + "encoding": "utf8", + "data": "updated" + }, + "preserveLease": true + }) + ); + } + + #[test] + fn requires_ttl_capability_only_for_ttl_aware_puts() { + assert_eq!(kv_put_required_capability(&KvPutOptions::default()), None); + assert_eq!(kv_put_required_capability(&KvPutOptions { lease: Some(42), ..KvPutOptions::default() }), None); + assert_eq!( + kv_put_required_capability(&KvPutOptions { ttl: Some(60), ..KvPutOptions::default() }), + Some(AgentCapability::KvTtl) + ); + assert_eq!( + kv_put_required_capability(&KvPutOptions { preserve_lease: Some(true), ..KvPutOptions::default() }), + Some(AgentCapability::KvTtl) + ); } #[test] @@ -360,6 +471,7 @@ mod tests { "modRevision": 2, "version": 3, "lease": 0, + "ttl": 30, "valueSize": 5 }], "continuation": "next-token", @@ -369,6 +481,7 @@ mod tests { assert_eq!(decoded.keys[0].key, "/app/name"); assert_eq!(decoded.keys[0].metadata.mod_revision, Some(2)); + assert_eq!(decoded.keys[0].metadata.ttl, Some(30)); assert_eq!(decoded.continuation.as_deref(), Some("next-token")); assert_eq!(decoded.revision, Some(9)); } diff --git a/crates/dbx-core/src/db/agent_driver.rs b/crates/dbx-core/src/db/agent_driver.rs index 52a4652d65..73acfb0990 100644 --- a/crates/dbx-core/src/db/agent_driver.rs +++ b/crates/dbx-core/src/db/agent_driver.rs @@ -353,11 +353,12 @@ pub enum AgentCapability { Transaction, Ddl, Kv, + KvTtl, MultiSession, } impl AgentCapability { - pub const ALL: [Self; 9] = [ + pub const ALL: [Self; 10] = [ Self::Connect, Self::TestConnection, Self::Metadata, @@ -366,6 +367,7 @@ impl AgentCapability { Self::Transaction, Self::Ddl, Self::Kv, + Self::KvTtl, Self::MultiSession, ]; @@ -379,6 +381,7 @@ impl AgentCapability { Self::Transaction => "transaction", Self::Ddl => "ddl", Self::Kv => "kv", + Self::KvTtl => "kv_ttl", Self::MultiSession => "multi_session", } } @@ -1635,7 +1638,7 @@ pub fn is_unsupported_handshake_error(error: &str) -> bool { } pub fn agent_supports_capability(handshake: Option<&AgentHandshake>, capability: AgentCapability) -> bool { - if capability == AgentCapability::Kv { + if matches!(capability, AgentCapability::Kv | AgentCapability::KvTtl) { return handshake.map(|value| value.supports(capability)).unwrap_or(false); } handshake.map(|value| value.supports(capability)).unwrap_or(true) @@ -2306,8 +2309,9 @@ for line in sys.stdin: assert_eq!(AgentCapability::Transaction.as_str(), "transaction"); assert_eq!(AgentCapability::Ddl.as_str(), "ddl"); assert_eq!(AgentCapability::Kv.as_str(), "kv"); + assert_eq!(AgentCapability::KvTtl.as_str(), "kv_ttl"); assert_eq!(AgentCapability::MultiSession.as_str(), "multi_session"); - assert_eq!(AgentCapability::ALL.len(), 9); + assert_eq!(AgentCapability::ALL.len(), 10); } #[test] @@ -2625,6 +2629,8 @@ for line in sys.stdin: assert!(!agent_supports_capability(Some(&handshake), AgentCapability::Query)); assert!(!agent_supports_capability(None, AgentCapability::Kv)); assert!(!agent_supports_capability(Some(&handshake), AgentCapability::Kv)); + assert!(!agent_supports_capability(None, AgentCapability::KvTtl)); + assert!(!agent_supports_capability(Some(&handshake), AgentCapability::KvTtl)); } #[test] diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 72e2d9540d..f1830bc3b2 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -444,6 +444,7 @@ async fn main() { .route("/redis/slowlog-get", post(routes::redis::slowlog_get)) .route("/redis/cluster-master-nodes", post(routes::redis::cluster_master_nodes)) // etcd + .route("/etcd/supports-ttl", post(routes::etcd::supports_ttl)) .route("/etcd/list-prefix", post(routes::etcd::list_prefix)) .route("/etcd/get", post(routes::etcd::get)) .route("/etcd/put", post(routes::etcd::put)) diff --git a/crates/dbx-web/src/routes/etcd.rs b/crates/dbx-web/src/routes/etcd.rs index 0c6073d709..7051edaa91 100644 --- a/crates/dbx-web/src/routes/etcd.rs +++ b/crates/dbx-web/src/routes/etcd.rs @@ -31,11 +31,18 @@ pub struct EtcdListPrefixRequest { pub continuation: Option, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EtcdConnectionRequest { + pub connection_id: String, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct EtcdKeyRequest { pub connection_id: String, pub key: String, + pub metadata_only: Option, } #[derive(Deserialize)] @@ -45,6 +52,17 @@ pub struct EtcdPutRequest { pub key: String, pub value: dbx_core::agent_kv::KvValue, pub lease: Option, + pub ttl: Option, + pub preserve_lease: Option, +} + +pub async fn supports_ttl( + State(state): State>, + Json(req): Json, +) -> Result, AppError> { + let result = + dbx_core::agent_kv::kv_supports_ttl_core(&state.app, &req.connection_id).await.map_err(AppError::from)?; + Ok(Json(result)) } pub async fn list_prefix( @@ -67,8 +85,14 @@ pub async fn get( State(state): State>, Json(req): Json, ) -> Result, AppError> { - let result = - dbx_core::agent_kv::kv_get_core(&state.app, &req.connection_id, &req.key).await.map_err(AppError::from)?; + let result = dbx_core::agent_kv::kv_get_core_with_options( + &state.app, + &req.connection_id, + &req.key, + req.metadata_only.unwrap_or(false), + ) + .await + .map_err(AppError::from)?; Ok(Json(result)) } @@ -77,9 +101,20 @@ pub async fn put( Json(req): Json, ) -> Result, AppError> { ensure_writable(&state.app, &req.connection_id, "Put").await?; - let result = dbx_core::agent_kv::kv_put_core(&state.app, &req.connection_id, &req.key, req.value, req.lease) - .await - .map_err(AppError::from)?; + let result = dbx_core::agent_kv::kv_put_core_with_options( + &state.app, + &req.connection_id, + &req.key, + req.value, + dbx_core::agent_kv::KvPutOptions { + lease: req.lease, + ttl: req.ttl, + preserve_lease: req.preserve_lease, + ..dbx_core::agent_kv::KvPutOptions::default() + }, + ) + .await + .map_err(AppError::from)?; Ok(Json(result)) } diff --git a/src-tauri/src/commands/etcd_cmd.rs b/src-tauri/src/commands/etcd_cmd.rs index d8a22d4487..ec863dcd42 100644 --- a/src-tauri/src/commands/etcd_cmd.rs +++ b/src-tauri/src/commands/etcd_cmd.rs @@ -2,7 +2,12 @@ use std::sync::Arc; use tauri::State; use crate::commands::connection::{ensure_connection_writable, AppState}; -use dbx_core::agent_kv::{KvDeleteResponse, KvGetResponse, KvListPrefixResponse, KvPutResponse, KvValue}; +use dbx_core::agent_kv::{KvDeleteResponse, KvGetResponse, KvListPrefixResponse, KvPutOptions, KvPutResponse, KvValue}; + +#[tauri::command] +pub async fn etcd_supports_ttl(state: State<'_, Arc>, connection_id: String) -> Result { + dbx_core::agent_kv::kv_supports_ttl_core(&state, &connection_id).await +} #[tauri::command] pub async fn etcd_list_prefix( @@ -20,8 +25,9 @@ pub async fn etcd_get( state: State<'_, Arc>, connection_id: String, key: String, + metadata_only: Option, ) -> Result { - dbx_core::agent_kv::kv_get_core(&state, &connection_id, &key).await + dbx_core::agent_kv::kv_get_core_with_options(&state, &connection_id, &key, metadata_only.unwrap_or(false)).await } #[tauri::command] @@ -31,9 +37,18 @@ pub async fn etcd_put( key: String, value: KvValue, lease: Option, + ttl: Option, + preserve_lease: Option, ) -> Result { ensure_connection_writable(&state, &connection_id, "Put").await?; - dbx_core::agent_kv::kv_put_core(&state, &connection_id, &key, value, lease).await + dbx_core::agent_kv::kv_put_core_with_options( + &state, + &connection_id, + &key, + value, + KvPutOptions { lease, ttl, preserve_lease, ..KvPutOptions::default() }, + ) + .await } #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fcb841123a..cb724d4383 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1540,6 +1540,7 @@ pub fn run() { commands::redis_pubsub_server::redis_pubsub_server_port, commands::redis_cmd::redis_slowlog_get, commands::redis_cmd::redis_cluster_master_nodes, + commands::etcd_cmd::etcd_supports_ttl, commands::etcd_cmd::etcd_list_prefix, commands::etcd_cmd::etcd_get, commands::etcd_cmd::etcd_put,