From 0d85a36ed84034c028cc997c4cbe92d6b3c642cf Mon Sep 17 00:00:00 2001 From: Joshua Chi <112539+JoshuaChi@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:50:19 +0800 Subject: [PATCH 01/18] chore #2: move d-lmdb library sources into workspace member directory Pure rename, no content changes (src/, tests/, Cargo.toml, README.md -> d-lmdb/). First step of splitting into a workspace with a separate d-lmdb-server crate for the HTTP/Docker layer, keeping the library --- .agents/skills/mempalace-dengine/SKILL.md | 155 +++++++++++++++++++++ Cargo.toml => d-lmdb/Cargo.toml | 0 README.md => d-lmdb/README.md | 0 {src => d-lmdb/src}/config.rs | 0 {src => d-lmdb/src}/db.rs | 0 {src => d-lmdb/src}/db_test.rs | 0 {src => d-lmdb/src}/error.rs | 0 {src => d-lmdb/src}/lib.rs | 0 {src => d-lmdb/src}/state_machine.rs | 0 {src => d-lmdb/src}/state_machine_test.rs | 0 {src => d-lmdb/src}/storage_engine.rs | 0 {src => d-lmdb/src}/storage_engine_test.rs | 0 {src => d-lmdb/src}/time.rs | 0 {src => d-lmdb/src}/wire/batch_test.rs | 0 {src => d-lmdb/src}/wire/mod.rs | 0 {src => d-lmdb/src}/wire/values.rs | 0 {src => d-lmdb/src}/wire/values_test.rs | 0 {tests => d-lmdb/tests}/integration.rs | 0 18 files changed, 155 insertions(+) create mode 100644 .agents/skills/mempalace-dengine/SKILL.md rename Cargo.toml => d-lmdb/Cargo.toml (100%) rename README.md => d-lmdb/README.md (100%) rename {src => d-lmdb/src}/config.rs (100%) rename {src => d-lmdb/src}/db.rs (100%) rename {src => d-lmdb/src}/db_test.rs (100%) rename {src => d-lmdb/src}/error.rs (100%) rename {src => d-lmdb/src}/lib.rs (100%) rename {src => d-lmdb/src}/state_machine.rs (100%) rename {src => d-lmdb/src}/state_machine_test.rs (100%) rename {src => d-lmdb/src}/storage_engine.rs (100%) rename {src => d-lmdb/src}/storage_engine_test.rs (100%) rename {src => d-lmdb/src}/time.rs (100%) rename {src => d-lmdb/src}/wire/batch_test.rs (100%) rename {src => d-lmdb/src}/wire/mod.rs (100%) rename {src => d-lmdb/src}/wire/values.rs (100%) rename {src => d-lmdb/src}/wire/values_test.rs (100%) rename {tests => d-lmdb/tests}/integration.rs (100%) diff --git a/.agents/skills/mempalace-dengine/SKILL.md b/.agents/skills/mempalace-dengine/SKILL.md new file mode 100644 index 0000000..94492e2 --- /dev/null +++ b/.agents/skills/mempalace-dengine/SKILL.md @@ -0,0 +1,155 @@ +--- +name: mempalace-dengine +description: Save cross-session d-engine ecosystem decisions/design records to the mempalace MCP — when to save, dedup check, wing/room naming conventions +--- + +# MemPalace (mempalace-dengine) Conversation Save Skillset + +Scope: d-engine ecosystem repos (`d-engine`, `d-lmdb`, `d-stream`, `d-engine-product-design`, +`d-engine-jepsen`) using the `mempalace-dengine` MCP instance. This is the sibling doc to +`.claude/commands/save.md` (d-engine repo) — that skill writes ticket-scoped implementation +records to `d-engine-product-design/tickets|topics|decisions`; this skill writes cross-session, +searchable decision/design records to mempalace, for content that's more discussion-shaped +than doc-shaped (expert-consulted debugging, positioning pivots, root-cause findings). + +## Before Saving: Dedup Check (MUST run first) + +Before creating any drawer, check for an existing one on the same topic: + +``` +mempalace_check_duplicate({ content: "" }) +mempalace_search({ query: "" }) # if check_duplicate is inconclusive +``` + +- **Existing drawer, same topic still open** → this is an update/continuation. Add a new + drawer but title it as a follow-up and link back: `(follow-up to drawer_id: ...)` — + mempalace has no in-place append, so "update" in practice means "new drawer, explicitly linked." +- **Existing drawer, but this is a genuinely new decision/phase** → new drawer, still link + the prior one under `## Related`. +- **Nothing found** → create new drawer. + +Skipping this step is the main cause of duplicate/fragmented drawers — evidence of this +already exists in the palace: `d_engine`, `d-engine`, and `dengine` are three separate wings +today because of inconsistent naming, not three separate projects. Don't add a fourth variant. + +## When to Save + +Save when ANY of these occur AND the content has a concrete outcome (not just "we discussed X"): + +| Trigger | Example from this project | +| ------------------------------------- | -------------------------------------------------------------------- | +| Product/architecture positioning decided | d-stream pivot from broker to embedded pipeline (2026-06-07) | +| Design decision after expert consultation | d-stream write-durability WAL design; d-lmdb pros/cons framing | +| Root cause found for a real bug | LMDB empty-key MDB_BAD_VALSIZE root cause | +| README / positioning rewrite recorded | d-lmdb "fault tolerance not scaling" reframing | +| Cross-project convention established | "distributed fault tolerance, not distributed scaling" reused across d-lmdb and d-stream | +| Ticket implementation decision (if not already going through `save.md`) | Design choices made mid-debugging before a ticket exists | + +Do not auto-save on keyword match — every candidate still goes through Dedup Check and +Dry-Run Check below. + +## What NOT to Save + +- Clarifying questions with no decision attached ("what does get_linearizable do?") +- Draft proposals that were rejected or superseded before being acted on +- Anything already fully captured in `d-engine-product-design/decisions/NNN-*.md` or + `tickets/milestones/.../N.md` via the `save.md` skill — don't duplicate across both systems. + Rule of thumb: **ticket-scoped implementation detail → `save.md` / decisions folder. + Cross-session discussion/design reasoning that doesn't map to one ticket → mempalace.** +- Code-level detail fully recoverable by reading the current source (file paths, function + signatures) — link to the file/line instead of pasting code that will drift + +## Save Format + +```typescript +mempalace_add_drawer({ + wing: "d-lmdb", // exact repo name, see Wing Convention below — never guess + room: "decisions", // see Room Convention + content: ` +# {Topic} — {YYYY-MM-DD} + +## Context +{1-2 sentences — why this came up} + +## Conclusion / Decision +{the core outcome, phrased so it stands alone when searched} +- {point 1} +- {point 2} + +## Honest Limits / Known Constraints +{if applicable — this project's norm is to state explicitly what a decision does NOT solve, don't omit it} + +## Related +{linked drawer_id(s) or file paths, or "-" if none} +`, +}); +``` + +## Wing Convention — canonical names only + +| Wing | Repo | +| ------------------------- | ---------------------------------------- | +| `d-engine` | github.com/DEventLab/d-engine | +| `d-lmdb` | github.com/DEventLab/d-lmdb | +| `d-stream` | github.com/DEventLab/d-stream | +| `d-engine-product-design` | design/knowledge repo | +| `d-engine-jepsen` | Jepsen test harness | + +**Always use the hyphenated form matching the actual repo/directory name.** Do not use +underscores (`d_engine`) or no-separator forms (`dengine`) — those already exist as stray +wings from earlier inconsistent saves and should not be added to. If you're unsure which repo +a discussion belongs to, check the working directory path, not habit. + +## Room Convention + +| Room | Use | +| -------------- | ---------------------------------------------------------------- | +| `decisions` | Default. Almost everything goes here. | +| `architecture` | Only when the entire drawer is architecture/design, no decision outcome yet | + +Don't over-split rooms — this project's palace already has legacy fragmentation +(`general`, `technical`, `tla`, per-crate room names from old imports). Going forward, +`decisions` is the default; only deviate when content is *exclusively* architecture-in-progress +with no decision reached yet. + +**Searchability note**: room is a weak filter. Lead the drawer title/first line with the most +specific searchable noun — ticket number (`#422`), crate name (`d-lmdb`), technical term +(`MDB_BAD_VALSIZE`) — not buried in paragraph 2. + +## Content Rules + +1. **Skill file language vs. saved content language**: this document (the skill file itself) + is English throughout. What actually gets *saved into mempalace* is different: body prose + may stay in the original discussion language (Chinese, matching how this team actually + communicates — see project CLAUDE.md) to preserve fidelity ("accurate record" was an + explicit requirement from Joshua). Section headers/structure (Context, Decision, Related, + etc.) stay in English for scannability across drawers. Keep ticket numbers, error strings, + function/crate names verbatim regardless of language. +2. **Self-contained**: readable and actionable without the original conversation, 3+ months later. +3. **Concrete over comprehensive**: every section should contain at least one searchable + entity (ticket #, error string, crate/function name, date). Cut sentences that only say + "we discussed X" without landing on an outcome. +4. **One drawer per topic-conclusion**, not per message. A single debugging session that + converges on one root cause = one drawer, even if it took many turns. +5. **Honest limits stay in**: this project's norm (established across d-lmdb/d-stream + positioning work) is to record what a decision does *not* solve, not just what it solves. + Don't strip that when compressing for mempalace. + +## Session Boundaries + +- Thread converges on a decision/root-cause → one drawer for the whole thread. +- Same topic resumed days later → new drawer, Dedup-Check-linked to the old one (see above; + mempalace has no true append, so "continuation" = new drawer + explicit backlink). +- Genuinely new topic → new drawer, no link needed unless directly relevant. + +## Dry-Run Check (run silently before every save) + +1. Does this drawer lead with a concrete searchable noun (ticket #, crate name, error string)? + If no → tighten the opening line. +2. Did I run Dedup Check and either link to an existing drawer or confirm this is new? + If no → go back and check. +3. Searched 3+ months from now, will the first two lines surface the actual decision — + not just "a discussion happened"? If no → revise. +4. Is the wing name one of the five canonical repo names above, not a variant? If no → fix it. + +All four pass → save. Otherwise → revise or skip. diff --git a/Cargo.toml b/d-lmdb/Cargo.toml similarity index 100% rename from Cargo.toml rename to d-lmdb/Cargo.toml diff --git a/README.md b/d-lmdb/README.md similarity index 100% rename from README.md rename to d-lmdb/README.md diff --git a/src/config.rs b/d-lmdb/src/config.rs similarity index 100% rename from src/config.rs rename to d-lmdb/src/config.rs diff --git a/src/db.rs b/d-lmdb/src/db.rs similarity index 100% rename from src/db.rs rename to d-lmdb/src/db.rs diff --git a/src/db_test.rs b/d-lmdb/src/db_test.rs similarity index 100% rename from src/db_test.rs rename to d-lmdb/src/db_test.rs diff --git a/src/error.rs b/d-lmdb/src/error.rs similarity index 100% rename from src/error.rs rename to d-lmdb/src/error.rs diff --git a/src/lib.rs b/d-lmdb/src/lib.rs similarity index 100% rename from src/lib.rs rename to d-lmdb/src/lib.rs diff --git a/src/state_machine.rs b/d-lmdb/src/state_machine.rs similarity index 100% rename from src/state_machine.rs rename to d-lmdb/src/state_machine.rs diff --git a/src/state_machine_test.rs b/d-lmdb/src/state_machine_test.rs similarity index 100% rename from src/state_machine_test.rs rename to d-lmdb/src/state_machine_test.rs diff --git a/src/storage_engine.rs b/d-lmdb/src/storage_engine.rs similarity index 100% rename from src/storage_engine.rs rename to d-lmdb/src/storage_engine.rs diff --git a/src/storage_engine_test.rs b/d-lmdb/src/storage_engine_test.rs similarity index 100% rename from src/storage_engine_test.rs rename to d-lmdb/src/storage_engine_test.rs diff --git a/src/time.rs b/d-lmdb/src/time.rs similarity index 100% rename from src/time.rs rename to d-lmdb/src/time.rs diff --git a/src/wire/batch_test.rs b/d-lmdb/src/wire/batch_test.rs similarity index 100% rename from src/wire/batch_test.rs rename to d-lmdb/src/wire/batch_test.rs diff --git a/src/wire/mod.rs b/d-lmdb/src/wire/mod.rs similarity index 100% rename from src/wire/mod.rs rename to d-lmdb/src/wire/mod.rs diff --git a/src/wire/values.rs b/d-lmdb/src/wire/values.rs similarity index 100% rename from src/wire/values.rs rename to d-lmdb/src/wire/values.rs diff --git a/src/wire/values_test.rs b/d-lmdb/src/wire/values_test.rs similarity index 100% rename from src/wire/values_test.rs rename to d-lmdb/src/wire/values_test.rs diff --git a/tests/integration.rs b/d-lmdb/tests/integration.rs similarity index 100% rename from tests/integration.rs rename to d-lmdb/tests/integration.rs From 1c1debd333ef07bd869a9a07791021d9df72656d Mon Sep 17 00:00:00 2001 From: Joshua Chi <112539+JoshuaChi@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:55:47 +0800 Subject: [PATCH 02/18] chore #2: add workspace root manifest, d-lmdb-server skeleton - New root Cargo.toml: [workspace] members = [d-lmdb, d-lmdb-server] - d-lmdb-server: placeholder bin, depends on d-lmdb via path+version - .gitignore: commit Cargo.lock now that workspace has a deployable binary - d-lmdb/Cargo.toml: fix [[example]] paths (examples/ stayed at workspace root) --- .gitignore | 5 +- Cargo.lock | 2651 +++++++++++++++++++++++++++++++++++++ Cargo.toml | 3 + d-lmdb-server/Cargo.toml | 11 + d-lmdb-server/src/main.rs | 9 + d-lmdb/Cargo.toml | 4 +- 6 files changed, 2679 insertions(+), 4 deletions(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 d-lmdb-server/Cargo.toml create mode 100644 d-lmdb-server/src/main.rs diff --git a/.gitignore b/.gitignore index 728a876..9984ee9 100644 --- a/.gitignore +++ b/.gitignore @@ -33,5 +33,6 @@ target .DS_Store .zed/ -# Library crate: consumers resolve their own dependency versions. -Cargo.lock +# Workspace now includes d-lmdb-server, a deployable binary — commit Cargo.lock +# for reproducible builds. (d-lmdb the library still doesn't need one committed +# on its own, but Cargo workspaces share a single lockfile at the root.) diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..05c8f55 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2651 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "astral-tokio-tar" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b18457efd137254e016bbde5e1d88df61c4e1a5ae2223746e56123bac6af2463" +dependencies = [ + "futures-core", + "libc", + "portable-atomic", + "rustc-hash", + "rustix", + "tokio", + "tokio-stream", + "xattr", +] + +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "config" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68578f196d2a33ff61b27fae256c3164f65e36382648e30666dde05b8cc9dfdf" +dependencies = [ + "nom", + "pathdiff", + "serde", + "toml", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-skiplist" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df29de440c58ca2cc6e587ec3d22347551a32435fbde9d2bff64e78a9ffa151b" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "d-engine" +version = "0.2.4" +source = "git+https://github.com/DEventLab/d-engine?branch=main#350c559cd9be96621ea9e45d6d4799eaf27c4a33" +dependencies = [ + "d-engine-server", +] + +[[package]] +name = "d-engine-core" +version = "0.2.4" +source = "git+https://github.com/DEventLab/d-engine?branch=main#350c559cd9be96621ea9e45d6d4799eaf27c4a33" +dependencies = [ + "astral-tokio-tar", + "async-compression", + "async-stream", + "async-trait", + "bincode", + "bytes", + "config", + "crc32fast", + "crossbeam-skiplist", + "d-engine-proto", + "dashmap", + "futures", + "http-body", + "http-body-util", + "lru", + "memmap2", + "metrics", + "mockall", + "prost", + "rand 0.9.5", + "serde", + "sha2", + "tempfile", + "thiserror", + "tokio", + "tokio-stream", + "tonic", + "tonic-health", + "tracing", + "uuid", +] + +[[package]] +name = "d-engine-proto" +version = "0.2.4" +source = "git+https://github.com/DEventLab/d-engine?branch=main#350c559cd9be96621ea9e45d6d4799eaf27c4a33" +dependencies = [ + "bytes", + "prost", + "serde", + "tonic", + "tonic-build", + "tracing", + "vergen", +] + +[[package]] +name = "d-engine-server" +version = "0.2.4" +source = "git+https://github.com/DEventLab/d-engine?branch=main#350c559cd9be96621ea9e45d6d4799eaf27c4a33" +dependencies = [ + "arc-swap", + "async-trait", + "bincode", + "bytes", + "config", + "crc32fast", + "crossbeam", + "crossbeam-skiplist", + "d-engine-core", + "d-engine-proto", + "dashmap", + "futures", + "http-body", + "http-body-util", + "metrics", + "parking_lot", + "prost", + "rcgen", + "serde", + "tempfile", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tonic-health", + "tracing", +] + +[[package]] +name = "d-lmdb" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "bincode", + "bytes", + "config", + "d-engine", + "heed", + "prost", + "serde", + "tempfile", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "d-lmdb-server" +version = "0.1.0" +dependencies = [ + "d-lmdb", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + +[[package]] +name = "doxygen-rs" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "415b6ec780d34dcf624666747194393603d0373b7141eef01d12ee58881507d9" +dependencies = [ + "phf", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fragile" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8878864ba14bb86e818a412bfd6f18f9eabd4ec0f008a28e8f7eb61db532fcf9" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "heed" +version = "0.20.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d4f449bab7320c56003d37732a917e18798e2f1709d80263face2b4f9436ddb" +dependencies = [ + "bitflags", + "byteorder", + "heed-traits", + "heed-types", + "libc", + "lmdb-master-sys", + "once_cell", + "page_size", + "serde", + "synchronoise", + "url", +] + +[[package]] +name = "heed-traits" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3130048d404c57ce5a1ac61a903696e8fcde7e8c2991e9fcfc1f27c3ef74ff" + +[[package]] +name = "heed-types" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d3f528b053a6d700b2734eabcd0fd49cb8230647aa72958467527b0b7917114" +dependencies = [ + "bincode", + "byteorder", + "heed-traits", + "serde", + "serde_json", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2 0.6.5", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lmdb-master-sys" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaeb9bd22e73bd1babffff614994b341e9b2008de7bb73bf1f7e9154f1978f8b" +dependencies = [ + "cc", + "doxygen-rs", + "libc", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0b564323a0fb6d54b864f625ae139de9612e27edb944dda37c109f05aac531" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "metrics" +version = "0.24.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2" +dependencies = [ + "portable-atomic", + "rapidhash", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mockall" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43766c2b5203b10de348ffe19f7e54564b64f3d6018ff7648d1e2d6d3a0f0a48" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "lazy_static", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af7cbce79ec385a1d4f54baa90a76401eb15d9cab93685f62e7e9f942aa00ae2" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.7", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.119", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rapidhash" +version = "4.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synchronoise" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dbc01390fc626ce8d1cffe3376ded2b72a11bb70e1c75f404a210e4daa4def2" +dependencies = [ + "crossbeam-queue", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.5", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tonic" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "flate2", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "socket2 0.5.10", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac6f67be712d12f0b41328db3137e0d0757645d8904b4cb7d51cd9c2279e847" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tonic-health" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb87334d340313fefa513b6e60794d44a86d5f039b523229c99c323e4e19ca4b" +dependencies = [ + "prost", + "tokio", + "tokio-stream", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vergen" +version = "8.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990d9ea5967266ea0ccf413a4aa5c42a93dbcfda9cb49a97de6931726b12566" +dependencies = [ + "anyhow", + "cfg-if", + "rustversion", + "time", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..eec5310 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +resolver = "2" +members = ["d-lmdb", "d-lmdb-server"] diff --git a/d-lmdb-server/Cargo.toml b/d-lmdb-server/Cargo.toml new file mode 100644 index 0000000..cdbe45c --- /dev/null +++ b/d-lmdb-server/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "d-lmdb-server" +version = "0.1.0" +edition = "2024" +description = "Standalone HTTP server / Docker entrypoint for d-lmdb" +license = "MIT OR Apache-2.0" +repository = "https://github.com/DEventLab/d-lmdb" +rust-version = "1.89" + +[dependencies] +d-lmdb = { path = "../d-lmdb", version = "0.1" } diff --git a/d-lmdb-server/src/main.rs b/d-lmdb-server/src/main.rs new file mode 100644 index 0000000..ed33da6 --- /dev/null +++ b/d-lmdb-server/src/main.rs @@ -0,0 +1,9 @@ +//! Standalone HTTP server / Docker entrypoint for d-lmdb. +//! +//! Placeholder — HTTP layer (axum/clap, serve/healthcheck subcommands) is not +//! yet implemented. This exists to prove the workspace wiring (`d-lmdb-server` +//! depending on `d-lmdb` via path) compiles end to end. + +fn main() { + println!("d-lmdb-server: HTTP layer not yet implemented"); +} diff --git a/d-lmdb/Cargo.toml b/d-lmdb/Cargo.toml index bfda5dd..46496e2 100644 --- a/d-lmdb/Cargo.toml +++ b/d-lmdb/Cargo.toml @@ -33,8 +33,8 @@ d-engine = { git = "https://github.com/DEventLab/d-engine", branch = "main", def [[example]] name = "single_node" -path = "examples/single-node/single_node.rs" +path = "../examples/single-node/single_node.rs" [[example]] name = "three_node" -path = "examples/three-node/three_node.rs" +path = "../examples/three-node/three_node.rs" From c8b22cc4590354c6e8d19dade43a6430dcbc8148 Mon Sep 17 00:00:00 2001 From: Joshua Chi <112539+JoshuaChi@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:16:12 +0800 Subject: [PATCH 03/18] fix #2: get_linearizable/get_lease leaked wire envelope, unify decode path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - get_linearizable/get_lease returned raw LMDB bytes (tag byte included) instead of the decoded payload — found via live smoke test - extract decode_live_value (pure) + decode_and_reap (decode + reap) so get/get_linearizable/get_lease share one decode path - add get_lease: leader-lease read, same guarantee as get_linearizable - rename get_live→fetch_local, resolve_read→decode_and_reap, ttl_filter→decode_for_scan (private helpers, avoid get_-prefix smell) - 8 new unit tests: ttl/non-ttl, boundary expiry, corrupt tag, truncated header --- d-lmdb/src/db.rs | 115 +++++++++++++++++++++++++++++++----------- d-lmdb/src/db_test.rs | 75 +++++++++++++++++++++++++++ d-lmdb/src/lib.rs | 1 + 3 files changed, 162 insertions(+), 29 deletions(-) diff --git a/d-lmdb/src/db.rs b/d-lmdb/src/db.rs index 8192598..fedf36a 100644 --- a/d-lmdb/src/db.rs +++ b/d-lmdb/src/db.rs @@ -118,7 +118,7 @@ impl DLmdb { &self, key: impl AsRef<[u8]>, ) -> Result> { - self.get_live(key.as_ref()) + self.fetch_local(key.as_ref()) } /// Eventual read. Not guaranteed to reflect the latest committed write. @@ -126,7 +126,7 @@ impl DLmdb { &self, key: impl AsRef<[u8]>, ) -> Result { - Ok(self.get_live(key.as_ref())?.is_some()) + Ok(self.fetch_local(key.as_ref())?.is_some()) } /// Eventual read. Not guaranteed to reflect the latest committed write. @@ -134,35 +134,34 @@ impl DLmdb { &self, keys: &[K], ) -> Result>> { - keys.iter().map(|k| self.get_live(k.as_ref())).collect() + keys.iter().map(|k| self.fetch_local(k.as_ref())).collect() } - /// Reads the raw value from the state machine (byte-transparent) and applies - /// TTL semantics on top: decode the wire envelope, treat an expired TTL value - /// as absent, and physically clean up expired entries synchronously - /// (local-only — see `LmdbStateMachine::delete_local`; safe without Raft - /// because expiry is decided by the already-replicated `expires_at`). - fn get_live( + /// Raw bytes from the local state machine, then `decode_and_reap`. + fn fetch_local( &self, key: &[u8], ) -> Result> { - let Some(raw) = self.state_machine.get(key)? else { + let raw = self.state_machine.get(key)?; + self.decode_and_reap(key, raw) + } + + /// Shared by all read paths: decode + apply TTL. Local delete-on-expiry is + /// safe without Raft since `expires_at` is already replicated. + fn decode_and_reap( + &self, + key: &[u8], + raw: Option, + ) -> Result> { + let Some(raw) = raw else { return Ok(None); }; - match Value::decode(&raw) { - Ok(Value::Raw(payload)) => Ok(Some(Bytes::copy_from_slice(payload))), - Ok(Value::Ttl { - expires_at, - payload, - }) => { - if expires_at <= unix_now_secs() { - self.state_machine.delete_local(key)?; - Ok(None) - } else { - Ok(Some(Bytes::copy_from_slice(payload))) - } + match decode_live_value(&raw, unix_now_secs())? { + DecodedValue::Present(payload) => Ok(Some(payload)), + DecodedValue::Expired => { + self.state_machine.delete_local(key)?; + Ok(None) } - Err(_) => Err(Error::Storage("corrupt value: invalid tag byte".into())), } } @@ -185,7 +184,7 @@ impl DLmdb { &self, limit: Option, ) -> Result { - Ok(self.state_machine.scan_all(limit, Some(ttl_filter))?) + Ok(self.state_machine.scan_all(limit, Some(decode_for_scan))?) } /// Eventual read. Not guaranteed to reflect the latest committed write. @@ -203,7 +202,7 @@ impl DLmdb { prefix.as_ref(), after, limit, - Some(ttl_filter), + Some(decode_for_scan), )?) } @@ -221,7 +220,7 @@ impl DLmdb { start.as_ref(), Some(end.as_ref()), limit, - Some(ttl_filter), + Some(decode_for_scan), )?) } @@ -239,7 +238,7 @@ impl DLmdb { start.as_ref(), end.as_ref(), limit, - Some(ttl_filter), + Some(decode_for_scan), )?) } @@ -250,7 +249,22 @@ impl DLmdb { &self, key: impl AsRef<[u8]>, ) -> Result> { - Ok(self.inner.client().get_linearizable(key).await?) + let key = key.as_ref(); + let raw = self.inner.client().get_linearizable(key).await?; + self.decode_and_reap(key, raw) + } + + // ---- async lease read: leader-lease optimized, still strongly consistent ---- + + /// Lease read. Same guarantee as `get_linearizable`, faster when the leader's + /// lease is valid. Lease duration is a d-engine startup config, not d-lmdb's. + pub async fn get_lease( + &self, + key: impl AsRef<[u8]>, + ) -> Result> { + let key = key.as_ref(); + let raw = self.inner.client().get_lease(key).await?; + self.decode_and_reap(key, raw) } // ---- async writes: Raft consensus --------------------------------------- @@ -359,6 +373,13 @@ impl DLmdb { self.inner.watch_membership().borrow().clone() } + /// The configured max value size in bytes. Exposed so callers building a + /// layer on top (e.g. an HTTP request body limit) can reuse this instead + /// of duplicating it as a separate config value. + pub fn max_value_bytes(&self) -> usize { + self.max_value_bytes + } + /// Gracefully stop the node. pub async fn close(self) -> Result<()> { self.inner.stop().await?; @@ -366,6 +387,42 @@ impl DLmdb { } } +// ---- live-read decode ----------------------------------------------------------- + +/// Outcome of decoding a stored value against the current time. +#[derive(Debug, PartialEq, Eq)] +enum DecodedValue { + /// Present and live: payload with the wire envelope stripped. + Present(Bytes), + /// Present in storage but past its TTL — logically absent. + Expired, +} + +/// Decodes a raw stored value and applies TTL semantics. Pure — no I/O. +/// +/// Note: expired entries found via `decode_for_scan` (scan path) are NOT +/// physically reaped, unlike point reads via `decode_and_reap` — scans run +/// on a read-only LMDB txn and structurally cannot delete. +fn decode_live_value( + raw: &[u8], + now_secs: u64, +) -> Result { + match Value::decode(raw) { + Ok(Value::Raw(payload)) => Ok(DecodedValue::Present(Bytes::copy_from_slice(payload))), + Ok(Value::Ttl { + expires_at, + payload, + }) => { + if expires_at <= now_secs { + Ok(DecodedValue::Expired) + } else { + Ok(DecodedValue::Present(Bytes::copy_from_slice(payload))) + } + } + Err(_) => Err(Error::Storage("corrupt value: invalid tag byte".into())), + } +} + // ---- scan filter -------------------------------------------------------------- /// Decides whether a raw `kv_db` value is live, and what to return for it. @@ -373,7 +430,7 @@ impl DLmdb { /// a "tag byte" or "expires_at" is. `None` drops the entry (expired, or an /// unrecognized/corrupt tag); `Some(payload)` keeps it, already stripped of /// the wire envelope. -fn ttl_filter(raw: &[u8]) -> Option> { +fn decode_for_scan(raw: &[u8]) -> Option> { match Value::decode(raw) { Ok(Value::Raw(payload)) => Some(payload.to_vec()), Ok(Value::Ttl { diff --git a/d-lmdb/src/db_test.rs b/d-lmdb/src/db_test.rs index 405ffa2..e435a7b 100644 --- a/d-lmdb/src/db_test.rs +++ b/d-lmdb/src/db_test.rs @@ -1,6 +1,10 @@ +use super::DecodedValue; +use super::decode_live_value; use super::validate_key; use super::validate_value; use crate::Error; +use crate::wire::Value; +use bytes::Bytes; const MAX_KEY_BYTES: usize = 512; const MAX_VALUE_BYTES: usize = 1024 * 1024; @@ -63,3 +67,74 @@ fn test_validate_value_rejects_value_over_limit() { other => panic!("expected ValueTooLarge, got {other:?}"), } } + +// ── decode_live_value ──────────────────────────────────────────────────────── +// +// Shared by fetch_local/get_linearizable/get_lease. This is the exact bug that +// slipped through before: get_linearizable returned the raw wire envelope +// (tag byte included) instead of the decoded payload. Every case here is +// checked against the *encoded* bytes (via Value::encode_raw/encode_ttl), not +// hand-built payloads, so a regression here fails the same way it would in +// production — an extra leading byte, not just "wrong enum variant". + +#[test] +fn test_decode_live_value_non_ttl_strips_envelope() { + // No leading tag byte in the returned payload — this is precisely what + // get_linearizable failed to do. + let encoded = Value::encode_raw(b"world"); + let decoded = decode_live_value(&encoded, 1_000).unwrap(); + assert_eq!(decoded, DecodedValue::Present(Bytes::from_static(b"world"))); +} + +#[test] +fn test_decode_live_value_ttl_not_yet_expired_returns_payload() { + let encoded = Value::encode_ttl(b"world", Some(2_000)); + let decoded = decode_live_value(&encoded, 1_000).unwrap(); + assert_eq!(decoded, DecodedValue::Present(Bytes::from_static(b"world"))); +} + +#[test] +fn test_decode_live_value_ttl_exactly_at_expiry_is_expired() { + // Boundary: expires_at == now_secs must be treated as expired (`<=`, not `<`). + let encoded = Value::encode_ttl(b"world", Some(1_000)); + let decoded = decode_live_value(&encoded, 1_000).unwrap(); + assert_eq!(decoded, DecodedValue::Expired); +} + +#[test] +fn test_decode_live_value_ttl_past_expiry_is_expired() { + let encoded = Value::encode_ttl(b"world", Some(500)); + let decoded = decode_live_value(&encoded, 1_000).unwrap(); + assert_eq!(decoded, DecodedValue::Expired); +} + +#[test] +fn test_decode_live_value_empty_payload_roundtrips() { + // Empty values are valid (see test_validate_value_accepts_empty_value) — + // must not be confused with "no bytes at all" (corrupt). + let encoded = Value::encode_raw(b""); + let decoded = decode_live_value(&encoded, 1_000).unwrap(); + assert_eq!(decoded, DecodedValue::Present(Bytes::new())); +} + +#[test] +fn test_decode_live_value_rejects_unrecognized_tag_byte() { + let corrupt = vec![0xFF, b'x']; + match decode_live_value(&corrupt, 1_000) { + Err(Error::Storage(msg)) => assert!(msg.contains("invalid tag byte")), + other => panic!("expected Error::Storage, got {other:?}"), + } +} + +#[test] +fn test_decode_live_value_rejects_empty_bytes() { + // No tag byte at all — must error, not panic on an out-of-bounds index. + assert!(decode_live_value(&[], 1_000).is_err()); +} + +#[test] +fn test_decode_live_value_rejects_truncated_ttl_header() { + // Tag says TTL but there aren't 8 bytes for expires_at — must error, not panic. + let truncated = vec![0x01, 0x00, 0x00]; + assert!(decode_live_value(&truncated, 1_000).is_err()); +} diff --git a/d-lmdb/src/lib.rs b/d-lmdb/src/lib.rs index 195473b..789f7b3 100644 --- a/d-lmdb/src/lib.rs +++ b/d-lmdb/src/lib.rs @@ -37,6 +37,7 @@ mod time; mod wire; pub use d_engine::BatchOp; +pub use d_engine::LeaderInfo; pub use db::DLmdb; pub use error::Error; pub use error::Result; From bdedbe42bc71231f3f2218cb45b2b79a3d15e8d0 Mon Sep 17 00:00:00 2001 From: Joshua Chi <112539+JoshuaChi@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:16:19 +0800 Subject: [PATCH 04/18] chore #2: point d-engine dep at fix/425-leader-hint branch Needed for leader_hint passthrough in the upcoming HTTP error mapping (d-engine main doesn't have the #425 leader_hint fix yet). --- Cargo.lock | 184 +++++++++++++++++++++++++++++++++++++++++++++- d-lmdb/Cargo.toml | 4 +- 2 files changed, 182 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 05c8f55..6545d84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,12 +23,56 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -119,10 +163,13 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", "http-body-util", + "hyper", + "hyper-util", "itoa", "matchit", "memchr", @@ -130,10 +177,15 @@ dependencies = [ "percent-encoding", "pin-project-lite", "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", "sync_wrapper", + "tokio", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -152,6 +204,7 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -224,6 +277,52 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "compression-codecs" version = "0.4.38" @@ -350,7 +449,7 @@ dependencies = [ [[package]] name = "d-engine" version = "0.2.4" -source = "git+https://github.com/DEventLab/d-engine?branch=main#350c559cd9be96621ea9e45d6d4799eaf27c4a33" +source = "git+https://github.com/DEventLab/d-engine?branch=fix%2F425-leader-hint#f7932084d1c7fc9e836b8ba4367d8d45f0ffff29" dependencies = [ "d-engine-server", ] @@ -358,7 +457,7 @@ dependencies = [ [[package]] name = "d-engine-core" version = "0.2.4" -source = "git+https://github.com/DEventLab/d-engine?branch=main#350c559cd9be96621ea9e45d6d4799eaf27c4a33" +source = "git+https://github.com/DEventLab/d-engine?branch=fix%2F425-leader-hint#f7932084d1c7fc9e836b8ba4367d8d45f0ffff29" dependencies = [ "astral-tokio-tar", "async-compression", @@ -395,7 +494,7 @@ dependencies = [ [[package]] name = "d-engine-proto" version = "0.2.4" -source = "git+https://github.com/DEventLab/d-engine?branch=main#350c559cd9be96621ea9e45d6d4799eaf27c4a33" +source = "git+https://github.com/DEventLab/d-engine?branch=fix%2F425-leader-hint#f7932084d1c7fc9e836b8ba4367d8d45f0ffff29" dependencies = [ "bytes", "prost", @@ -409,7 +508,7 @@ dependencies = [ [[package]] name = "d-engine-server" version = "0.2.4" -source = "git+https://github.com/DEventLab/d-engine?branch=main#350c559cd9be96621ea9e45d6d4799eaf27c4a33" +source = "git+https://github.com/DEventLab/d-engine?branch=fix%2F425-leader-hint#f7932084d1c7fc9e836b8ba4367d8d45f0ffff29" dependencies = [ "arc-swap", "async-trait", @@ -463,7 +562,14 @@ dependencies = [ name = "d-lmdb-server" version = "0.1.0" dependencies = [ + "axum", + "bytes", + "clap", "d-lmdb", + "serde", + "tokio", + "toml", + "tower-http", ] [[package]] @@ -1027,6 +1133,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.14.0" @@ -1249,6 +1361,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "page_size" version = "0.6.0" @@ -1708,6 +1826,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "scopeguard" version = "1.2.0" @@ -1757,6 +1881,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -1766,6 +1901,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1852,6 +1999,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -2176,6 +2329,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -2194,6 +2363,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -2287,6 +2457,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.24.0" diff --git a/d-lmdb/Cargo.toml b/d-lmdb/Cargo.toml index 46496e2..81f792f 100644 --- a/d-lmdb/Cargo.toml +++ b/d-lmdb/Cargo.toml @@ -11,7 +11,7 @@ rust-version = "1.89" [dependencies] # production: d-engine = "0.2" -d-engine = { git = "https://github.com/DEventLab/d-engine", branch = "main", default-features = false, features = ["server"] } +d-engine = { git = "https://github.com/DEventLab/d-engine", branch = "fix/425-leader-hint", default-features = false, features = ["server"] } heed = "0.20" bytes = "1" @@ -29,7 +29,7 @@ anyhow = "1" tempfile = "3" tokio = { version = "1", features = ["rt-multi-thread", "macros", "test-util"] } tracing-subscriber = "0.3" -d-engine = { git = "https://github.com/DEventLab/d-engine", branch = "main", default-features = false, features = ["server", "__test_support"] } +d-engine = { git = "https://github.com/DEventLab/d-engine", branch = "fix/425-leader-hint", default-features = false, features = ["server", "__test_support"] } [[example]] name = "single_node" From e88ee19586572c5e47a60744c8ddc62537006ff9 Mon Sep 17 00:00:00 2001 From: Joshua Chi <112539+JoshuaChi@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:31:28 +0800 Subject: [PATCH 05/18] =?UTF-8?q?fix=20#2:=20correct=20lmdb=5Fmap=5Fsize?= =?UTF-8?q?=20=E2=86=92=20map=5Fsize=5Fgb=20typo=20in=20single-node=20exam?= =?UTF-8?q?ple=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/single-node/config.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/single-node/config.toml b/examples/single-node/config.toml index db9ba97..a9ed9e3 100644 --- a/examples/single-node/config.toml +++ b/examples/single-node/config.toml @@ -7,4 +7,4 @@ log_dir = "./logs" [lmdb] data_dir = "./data" -lmdb_map_size = 100 +map_size_gb = 10 From 76ae8c800e6d194848456ab643cf7c9356416c1b Mon Sep 17 00:00:00 2001 From: Joshua Chi <112539+JoshuaChi@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:31:41 +0800 Subject: [PATCH 06/18] feat #2: d-lmdb-server HTTP+JSON API, CLI, and config template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - kv.rs: PUT/GET/DELETE /kv/{key}, ?level= consistency dial (eventual/linearizable/lease), exhaustively matched enum - error.rs: d_lmdb::Error → HTTP response per decisions/015 (400/503/500), plus a response-normalizing middleware so extractor rejections and business errors share one JSON shape - health.rs: /primary, /replica, /status - main.rs: serve/healthcheck subcommands (clap, CONFIG env fallback) - config.example.toml: shipped template, real config.toml gitignored - d-lmdb/lib.rs: re-export ClientApiError/ErrorCode/LeaderHint, needed for error.rs to name d-engine's error types --- .gitignore | 4 + Cargo.lock | 13 +- d-lmdb-server/Cargo.toml | 14 ++ d-lmdb-server/config.example.toml | 12 ++ d-lmdb-server/src/http/error.rs | 211 +++++++++++++++++++++ d-lmdb-server/src/http/error_test.rs | 268 +++++++++++++++++++++++++++ d-lmdb-server/src/http/health.rs | 57 ++++++ d-lmdb-server/src/http/kv.rs | 106 +++++++++++ d-lmdb-server/src/http/kv_test.rs | 34 ++++ d-lmdb-server/src/http/mod.rs | 35 ++++ d-lmdb-server/src/main.rs | 98 +++++++++- d-lmdb/src/lib.rs | 3 + 12 files changed, 845 insertions(+), 10 deletions(-) create mode 100644 d-lmdb-server/config.example.toml create mode 100644 d-lmdb-server/src/http/error.rs create mode 100644 d-lmdb-server/src/http/error_test.rs create mode 100644 d-lmdb-server/src/http/health.rs create mode 100644 d-lmdb-server/src/http/kv.rs create mode 100644 d-lmdb-server/src/http/kv_test.rs create mode 100644 d-lmdb-server/src/http/mod.rs diff --git a/.gitignore b/.gitignore index 9984ee9..136a932 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,7 @@ target # Workspace now includes d-lmdb-server, a deployable binary — commit Cargo.lock # for reproducible builds. (d-lmdb the library still doesn't need one committed # on its own, but Cargo workspaces share a single lockfile at the root.) + +# Developer's real d-lmdb-server config, copied from config.example.toml — may +# contain machine-local paths, gets regenerated per-developer, never committed. +/d-lmdb-server/config.toml diff --git a/Cargo.lock b/Cargo.lock index 6545d84..893b601 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -449,7 +449,7 @@ dependencies = [ [[package]] name = "d-engine" version = "0.2.4" -source = "git+https://github.com/DEventLab/d-engine?branch=fix%2F425-leader-hint#f7932084d1c7fc9e836b8ba4367d8d45f0ffff29" +source = "git+https://github.com/DEventLab/d-engine?branch=fix%2F425-leader-hint#4a192e114b3833c805ac6e301739c9b1a81e9818" dependencies = [ "d-engine-server", ] @@ -457,7 +457,7 @@ dependencies = [ [[package]] name = "d-engine-core" version = "0.2.4" -source = "git+https://github.com/DEventLab/d-engine?branch=fix%2F425-leader-hint#f7932084d1c7fc9e836b8ba4367d8d45f0ffff29" +source = "git+https://github.com/DEventLab/d-engine?branch=fix%2F425-leader-hint#4a192e114b3833c805ac6e301739c9b1a81e9818" dependencies = [ "astral-tokio-tar", "async-compression", @@ -494,7 +494,7 @@ dependencies = [ [[package]] name = "d-engine-proto" version = "0.2.4" -source = "git+https://github.com/DEventLab/d-engine?branch=fix%2F425-leader-hint#f7932084d1c7fc9e836b8ba4367d8d45f0ffff29" +source = "git+https://github.com/DEventLab/d-engine?branch=fix%2F425-leader-hint#4a192e114b3833c805ac6e301739c9b1a81e9818" dependencies = [ "bytes", "prost", @@ -508,7 +508,7 @@ dependencies = [ [[package]] name = "d-engine-server" version = "0.2.4" -source = "git+https://github.com/DEventLab/d-engine?branch=fix%2F425-leader-hint#f7932084d1c7fc9e836b8ba4367d8d45f0ffff29" +source = "git+https://github.com/DEventLab/d-engine?branch=fix%2F425-leader-hint#4a192e114b3833c805ac6e301739c9b1a81e9818" dependencies = [ "arc-swap", "async-trait", @@ -567,9 +567,14 @@ dependencies = [ "clap", "d-lmdb", "serde", + "serde_json", + "serde_urlencoded", "tokio", "toml", + "tower", "tower-http", + "tracing", + "uuid", ] [[package]] diff --git a/d-lmdb-server/Cargo.toml b/d-lmdb-server/Cargo.toml index cdbe45c..947ae06 100644 --- a/d-lmdb-server/Cargo.toml +++ b/d-lmdb-server/Cargo.toml @@ -9,3 +9,17 @@ rust-version = "1.89" [dependencies] d-lmdb = { path = "../d-lmdb", version = "0.1" } +axum = "0.8" +bytes = "1" +serde = { version = "1", features = ["derive"] } +tower-http = { version = "0.6", features = ["limit"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net"] } +clap = { version = "4", features = ["derive", "env"] } +toml = "0.8" +tracing = "0.1" +uuid = { version = "1", features = ["v4"] } + +[dev-dependencies] +serde_json = "1" +serde_urlencoded = "0.7" +tower = { version = "0.5", features = ["util"] } diff --git a/d-lmdb-server/config.example.toml b/d-lmdb-server/config.example.toml new file mode 100644 index 0000000..248dfd0 --- /dev/null +++ b/d-lmdb-server/config.example.toml @@ -0,0 +1,12 @@ +[cluster] +node_id = 1 +listen_address = "0.0.0.0:9081" +db_root_dir = "./data" +log_dir = "./logs" + +[lmdb] +data_dir = "./data" +map_size_gb = 10 + +[http] +listen_address = "0.0.0.0:8080" diff --git a/d-lmdb-server/src/http/error.rs b/d-lmdb-server/src/http/error.rs new file mode 100644 index 0000000..f8a574f --- /dev/null +++ b/d-lmdb-server/src/http/error.rs @@ -0,0 +1,211 @@ +//! Maps `d_lmdb::Error` to an HTTP response. +//! +//! Classification: `d-engine-product-design/decisions/015-http-error-classification.md`. +//! +//! | Status | Trigger | +//! |---|---| +//! | 400 | `KeyTooLarge` / `ValueTooLarge` / `Business{InvalidRequest}` | +//! | 503 | `Network{NotLeader}` (+ `leader_hint`, `Retry-After`) / `Business{ClusterUnavailable\|ProposeFailed\|RetryRequired\|TermOutdated}` | +//! | 500 | everything else — generic message by responsibility layer + `trace_id` | +//! +//! 409/429 are designed but **not implemented**: `StaleOperation`/`RateLimited` have +//! no real construction path through `EmbeddedEngine` today (see decision 015's Known +//! Limitation), so they fall through the wildcard into 500 rather than getting dead +//! match arms. + +use axum::Json; +use axum::http::HeaderValue; +use axum::http::StatusCode; +use axum::http::header::RETRY_AFTER; +use axum::response::IntoResponse; +use axum::response::Response; +use d_lmdb::ClientApiError; +use d_lmdb::ErrorCode; +use serde::Serialize; + +#[derive(Serialize)] +struct ErrorBody { + error: String, + #[serde(skip_serializing_if = "Option::is_none")] + leader_hint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + trace_id: Option, +} + +/// Mirrors `d_lmdb::LeaderHint` verbatim (not `{host, port}`): `address` is an +/// opaque string from Raft config, not guaranteed to be a parseable `SocketAddr` +/// (could be IPv6, a hostname, etc.) — relaying it as-is avoids parsing we'd have +/// to get right for formats we don't control. +#[derive(Serialize)] +struct LeaderHintDto { + leader_id: u32, + address: String, +} + +pub(super) struct HttpError { + status: StatusCode, + body: ErrorBody, + retry_after_secs: Option, +} + +impl HttpError { + pub(super) fn not_found() -> Self { + Self::plain(StatusCode::NOT_FOUND, "key not found") + } + + fn plain( + status: StatusCode, + message: &str, + ) -> Self { + Self { + status, + body: ErrorBody { + error: message.to_string(), + leader_hint: None, + trace_id: None, + }, + retry_after_secs: None, + } + } +} + +impl From for HttpError { + fn from(err: d_lmdb::Error) -> Self { + match &err { + d_lmdb::Error::KeyTooLarge(_) | d_lmdb::Error::ValueTooLarge(_) => { + Self::plain(StatusCode::BAD_REQUEST, &err.to_string()) + } + + d_lmdb::Error::Client(ClientApiError::Business { + code: ErrorCode::InvalidRequest, + message, + .. + }) => Self::plain(StatusCode::BAD_REQUEST, message), + + d_lmdb::Error::Client(ClientApiError::Network { + code: ErrorCode::NotLeader, + message, + retry_after_ms, + leader_hint, + }) => Self { + status: StatusCode::SERVICE_UNAVAILABLE, + body: ErrorBody { + error: message.clone(), + leader_hint: leader_hint.as_ref().map(|h| LeaderHintDto { + leader_id: h.leader_id, + address: h.address.clone(), + }), + trace_id: None, + }, + retry_after_secs: retry_after_ms_to_secs(*retry_after_ms), + }, + + d_lmdb::Error::Client(ClientApiError::Business { + code: + ErrorCode::ClusterUnavailable + | ErrorCode::ProposeFailed + | ErrorCode::RetryRequired + | ErrorCode::TermOutdated, + message, + .. + }) => Self { + status: StatusCode::SERVICE_UNAVAILABLE, + body: ErrorBody { + error: message.clone(), + leader_hint: None, + trace_id: None, + }, + retry_after_secs: None, + }, + + _ => { + let trace_id = uuid::Uuid::new_v4().to_string(); + tracing::error!(trace_id = %trace_id, error = %err, "unhandled d_lmdb error"); + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + body: ErrorBody { + error: internal_error_category(&err).to_string(), + leader_hint: None, + trace_id: Some(trace_id), + }, + retry_after_secs: None, + } + } + } + } +} + +/// 500 message grouped by responsibility layer — never a path, stack trace, or raw +/// error string. Exact detail lives in the `trace_id`-tagged log line, not the body. +fn internal_error_category(err: &d_lmdb::Error) -> &'static str { + match err { + d_lmdb::Error::Lmdb(_) | d_lmdb::Error::Storage(_) | d_lmdb::Error::Io(_) => "storage error", + d_lmdb::Error::Engine(_) => "cluster error", + d_lmdb::Error::Client(ClientApiError::Business { + code: ErrorCode::Uncategorized, + .. + }) => "cluster error", + d_lmdb::Error::ConfigError(_) | d_lmdb::Error::Path(_) => "configuration error", + _ => "internal error", + } +} + +/// `Retry-After` is in seconds (RFC 9110); `retry_after_ms` is milliseconds. Rounds +/// up: the header means "wait at least this long", and a bare `0` reads as "retry +/// immediately" to most HTTP clients — wrong signal for a 503. +fn retry_after_ms_to_secs(ms: Option) -> Option { + match ms { + Some(0) | None => None, + Some(ms) => Some(ms.div_ceil(1000)), + } +} + +impl IntoResponse for HttpError { + fn into_response(self) -> Response { + let mut response = (self.status, Json(self.body)).into_response(); + if let Some(secs) = self.retry_after_secs + && let Ok(value) = HeaderValue::from_str(&secs.to_string()) + { + response.headers_mut().insert(RETRY_AFTER, value); + } + response + } +} + +/// Wraps any non-JSON 4xx/5xx response (extractor rejections — `ValidKey`, axum's +/// own `QueryRejection`, etc.) into the same `{"error": ...}` shape `HttpError` +/// produces, so callers can always parse the body as JSON regardless of which +/// layer generated the error. The original text is preserved verbatim as `error` +/// — this normalizes the envelope, not the wording. +pub(super) async fn normalize_error_body(response: Response) -> Response { + if !response.status().is_client_error() && !response.status().is_server_error() { + return response; + } + let is_json = response + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.starts_with("application/json")); + if is_json { + return response; + } + + let status = response.status(); + let message = match axum::body::to_bytes(response.into_body(), usize::MAX).await { + Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(), + Err(_) => "internal error".to_string(), + }; + ( + status, + Json(ErrorBody { + error: message, + leader_hint: None, + trace_id: None, + }), + ) + .into_response() +} + +#[cfg(test)] +#[path = "error_test.rs"] +mod error_test; diff --git a/d-lmdb-server/src/http/error_test.rs b/d-lmdb-server/src/http/error_test.rs new file mode 100644 index 0000000..09bcb6c --- /dev/null +++ b/d-lmdb-server/src/http/error_test.rs @@ -0,0 +1,268 @@ +use axum::http::StatusCode; +use axum::http::header::RETRY_AFTER; +use axum::response::IntoResponse; +use d_lmdb::ClientApiError; +use d_lmdb::Error; +use d_lmdb::ErrorCode; +use d_lmdb::LeaderHint; + +use super::HttpError; +use super::internal_error_category; +use super::normalize_error_body; +use super::retry_after_ms_to_secs; + +async fn body_json(resp: axum::response::Response) -> serde_json::Value { + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + serde_json::from_slice(&bytes).unwrap() +} + +fn business( + code: ErrorCode, + message: &str, +) -> Error { + Error::Client(ClientApiError::Business { + code, + message: message.to_string(), + required_action: None, + }) +} + +// ── 400 ────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_key_too_large_maps_to_400() { + let http_err: HttpError = Error::KeyTooLarge(600).into(); + let resp = http_err.into_response(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let body = body_json(resp).await; + assert!(body["error"].as_str().unwrap().contains("600")); + assert!(body.get("leader_hint").is_none()); + assert!(body.get("trace_id").is_none()); +} + +#[tokio::test] +async fn test_value_too_large_maps_to_400() { + let http_err: HttpError = Error::ValueTooLarge(2_000_000).into(); + assert_eq!(http_err.status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_business_invalid_request_maps_to_400() { + let err = business(ErrorCode::InvalidRequest, "bad params"); + let http_err: HttpError = err.into(); + assert_eq!(http_err.status, StatusCode::BAD_REQUEST); + assert_eq!(http_err.body.error, "bad params"); +} + +// ── 503 ────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_not_leader_maps_to_503_with_leader_hint_and_retry_after() { + let err = Error::Client(ClientApiError::Network { + code: ErrorCode::NotLeader, + message: "not leader".to_string(), + retry_after_ms: Some(150), + leader_hint: Some(LeaderHint { + leader_id: 2, + address: "10.0.0.2:9081".to_string(), + }), + }); + let http_err: HttpError = err.into(); + assert_eq!(http_err.status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(http_err.retry_after_secs, Some(1)); // 150ms ceils to 1s + + let resp = http_err.into_response(); + assert_eq!(resp.headers().get(RETRY_AFTER).unwrap(), "1"); + let body = body_json(resp).await; + assert_eq!(body["leader_hint"]["leader_id"], 2); + assert_eq!(body["leader_hint"]["address"], "10.0.0.2:9081"); +} + +#[tokio::test] +async fn test_not_leader_without_hint_omits_leader_hint_field() { + let err = Error::Client(ClientApiError::Network { + code: ErrorCode::NotLeader, + message: "not leader".to_string(), + retry_after_ms: None, + leader_hint: None, + }); + let http_err: HttpError = err.into(); + assert_eq!(http_err.status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(http_err.retry_after_secs, None); + let resp = http_err.into_response(); + assert!(resp.headers().get(RETRY_AFTER).is_none()); + let body = body_json(resp).await; + assert!(body.get("leader_hint").is_none()); +} + +#[tokio::test] +async fn test_cluster_unavailable_maps_to_503_without_leader_hint() { + let err = business(ErrorCode::ClusterUnavailable, "cluster unavailable"); + let http_err: HttpError = err.into(); + assert_eq!(http_err.status, StatusCode::SERVICE_UNAVAILABLE); + assert!(http_err.body.leader_hint.is_none()); +} + +#[tokio::test] +async fn test_term_outdated_maps_to_503() { + // decisions/015: TermOutdated means the leader just stepped down mid-write — + // the client should retry, this is not an unactionable 500. + let err = business(ErrorCode::TermOutdated, "stale term error"); + let http_err: HttpError = err.into(); + assert_eq!(http_err.status, StatusCode::SERVICE_UNAVAILABLE); +} + +#[tokio::test] +async fn test_propose_failed_and_retry_required_map_to_503() { + for code in [ErrorCode::ProposeFailed, ErrorCode::RetryRequired] { + let http_err: HttpError = business(code, "retry").into(); + assert_eq!(http_err.status, StatusCode::SERVICE_UNAVAILABLE); + } +} + +// ── 500 fallback ───────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_unmapped_error_falls_back_to_500_with_trace_id() { + // decisions/015 Known Limitation: StaleOperation/RateLimited have no real + // construction path via EmbeddedEngine — they deliberately fall through here. + let err = business(ErrorCode::StaleOperation, "stale operation"); + let http_err: HttpError = err.into(); + assert_eq!(http_err.status, StatusCode::INTERNAL_SERVER_ERROR); + assert!(http_err.body.trace_id.is_some()); + + let resp = http_err.into_response(); + let body = body_json(resp).await; + assert_eq!(body["trace_id"].as_str().unwrap().len(), 36); // uuid v4 string length +} + +#[tokio::test] +async fn test_storage_error_maps_to_500() { + let http_err: HttpError = Error::Storage("corrupt value".to_string()).into(); + assert_eq!(http_err.status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(http_err.body.error, "storage error"); +} + +// ── HttpError::bad_request / not_found ────────────────────────────────────── + +#[test] +fn test_not_found_constructor() { + let http_err = HttpError::not_found(); + assert_eq!(http_err.status, StatusCode::NOT_FOUND); + assert_eq!(http_err.body.error, "key not found"); +} + +// ── internal_error_category ───────────────────────────────────────────────── + +#[test] +fn test_internal_error_category_storage() { + assert_eq!( + internal_error_category(&Error::Storage("x".to_string())), + "storage error" + ); +} + +#[test] +fn test_internal_error_category_configuration() { + assert_eq!(internal_error_category(&Error::Path("x".to_string())), "configuration error"); +} + +#[test] +fn test_internal_error_category_cluster() { + let err = business(ErrorCode::Uncategorized, "x"); + assert_eq!(internal_error_category(&err), "cluster error"); +} + +#[test] +fn test_internal_error_category_fallback_is_internal_error() { + let err = Error::Client(ClientApiError::Protocol { + code: ErrorCode::InvalidResponse, + message: "x".to_string(), + supported_versions: None, + }); + assert_eq!(internal_error_category(&err), "internal error"); +} + +// ── retry_after_ms_to_secs ─────────────────────────────────────────────────── + +#[test] +fn test_retry_after_ms_to_secs_rounds_up() { + assert_eq!(retry_after_ms_to_secs(Some(150)), Some(1)); + assert_eq!(retry_after_ms_to_secs(Some(1001)), Some(2)); + assert_eq!(retry_after_ms_to_secs(Some(1000)), Some(1)); // exact multiple, no over-round +} + +#[test] +fn test_retry_after_ms_to_secs_zero_is_omitted() { + // 0 would read as "retry immediately" to most HTTP clients — misleading + // when the real signal is "we don't have a useful wait estimate". + assert_eq!(retry_after_ms_to_secs(Some(0)), None); +} + +#[test] +fn test_retry_after_ms_to_secs_none_is_omitted() { + assert_eq!(retry_after_ms_to_secs(None), None); +} + +// ── normalize_error_body ───────────────────────────────────────────────────── +// +// Simulates the exact bug this middleware fixes: an extractor rejection (like +// `ValidKey`'s `(StatusCode, &'static str)`) produces a plain-text 4xx that +// bypasses `HttpError` entirely. Routed through a real Router + the layer (not +// called directly) so this proves the wiring in `mod.rs`, not just the function. + +fn test_router() -> axum::Router { + use axum::routing::get; + + axum::Router::new() + .route("/plain-text-400", get(|| async { (StatusCode::BAD_REQUEST, "key must not contain '/'") })) + .route( + "/already-json-400", + get(|| async { + ( + StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({"error": "already structured"})), + ) + }), + ) + .route("/ok", get(|| async { "fine" })) + .layer(axum::middleware::map_response(normalize_error_body)) +} + +async fn get_response(path: &str) -> axum::response::Response { + use tower::ServiceExt; + + let req = axum::http::Request::builder() + .uri(path) + .body(axum::body::Body::empty()) + .unwrap(); + test_router().oneshot(req).await.unwrap() +} + +#[tokio::test] +async fn test_plain_text_rejection_gets_wrapped_into_json() { + let resp = get_response("/plain-text-400").await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + assert_eq!( + resp.headers().get(axum::http::header::CONTENT_TYPE).unwrap(), + "application/json" + ); + let body = body_json(resp).await; + assert_eq!(body["error"], "key must not contain '/'"); +} + +#[tokio::test] +async fn test_already_json_response_passes_through_unchanged() { + let resp = get_response("/already-json-400").await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let body = body_json(resp).await; + assert_eq!(body["error"], "already structured"); +} + +#[tokio::test] +async fn test_success_response_is_not_touched() { + let resp = get_response("/ok").await; + assert_eq!(resp.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + assert_eq!(&bytes[..], b"fine"); +} diff --git a/d-lmdb-server/src/http/health.rs b/d-lmdb-server/src/http/health.rs new file mode 100644 index 0000000..700f2d1 --- /dev/null +++ b/d-lmdb-server/src/http/health.rs @@ -0,0 +1,57 @@ +//! /primary, /replica, /status — read-only endpoints for LB health checks and operators. + +use std::sync::Arc; + +use axum::Json; +use axum::Router; +use axum::extract::State; +use axum::http::StatusCode; +use axum::routing::get; +use d_lmdb::DLmdb; +use d_lmdb::LeaderInfo; +use serde::Serialize; + +/// LB target for write routing: 200 if this node is leader, else 503. +async fn primary_handler(State(db): State>) -> StatusCode { + if db.is_leader() { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + } +} + +/// LB target for read routing: 200 if this node is a follower, else 503. +async fn replica_handler(State(db): State>) -> StatusCode { + if db.is_leader() { + StatusCode::SERVICE_UNAVAILABLE + } else { + StatusCode::OK + } +} + +/// Human-facing: current leader + cluster membership. Not used for LB routing. +#[derive(Serialize)] +struct StatusResponse { + leader: Option, + members: Vec, + learners: Vec, + committed_index: u64, +} + +async fn status_handler(State(db): State>) -> Json { + let membership = db.cluster_info(); + Json(StatusResponse { + leader: db.leader(), + members: membership.members.into_iter().collect(), + learners: membership.learners.into_iter().collect(), + committed_index: membership.committed_index, + }) +} + +/// Merges /primary, /replica, /status onto the given router. +pub(super) fn add_routes(router: Router>) -> Router> { + router + .route("/primary", get(primary_handler)) + .route("/replica", get(replica_handler)) + .route("/status", get(status_handler)) +} diff --git a/d-lmdb-server/src/http/kv.rs b/d-lmdb-server/src/http/kv.rs new file mode 100644 index 0000000..6b4bba5 --- /dev/null +++ b/d-lmdb-server/src/http/kv.rs @@ -0,0 +1,106 @@ +//! PUT/GET/DELETE /kv/:key — key validation, body limit, wraps DLmdb put/get/delete. + +use std::sync::Arc; + +use axum::body::Bytes; +use axum::extract::FromRequestParts; +use axum::extract::Path; +use axum::extract::Query; +use axum::extract::State; +use axum::http::StatusCode; +use axum::http::request::Parts; +use axum::routing::MethodRouter; +use axum::routing::get; +use d_lmdb::DLmdb; +use serde::Deserialize; + +use super::error::HttpError; + +/// A `/kv/:key` path segment that has already passed validation: UTF-8 (guaranteed by +/// axum's `Path`), non-empty, and does not contain `/` (v1 scope — see plan). +struct ValidKey(String); + +impl FromRequestParts for ValidKey +where + S: Send + Sync, +{ + type Rejection = (StatusCode, &'static str); + + async fn from_request_parts( + parts: &mut Parts, + state: &S, + ) -> Result { + let Path(key) = Path::::from_request_parts(parts, state) + .await + .map_err(|_| (StatusCode::BAD_REQUEST, "invalid key encoding"))?; + if key.is_empty() { + return Err((StatusCode::BAD_REQUEST, "key must not be empty")); + } + if key.contains('/') { + return Err((StatusCode::BAD_REQUEST, "key must not contain '/'")); + } + Ok(ValidKey(key)) + } +} + +/// `?level=` picks how fresh a read must be, trading speed for certainty. +/// Absent or `eventual` (default): fast local read, may be briefly stale. +/// `linearizable`: confirmed via Raft ReadIndex — always current, slower. +/// `lease`: leader-lease optimized — as current as `linearizable`, faster +/// when the lease is valid; must land on the leader. +#[derive(Deserialize, Default, Clone, Copy, PartialEq, Eq, Debug)] +#[serde(rename_all = "lowercase")] +enum ConsistencyLevel { + #[default] + Eventual, + Linearizable, + Lease, +} + +#[derive(Deserialize)] +struct ConsistencyParam { + #[serde(default)] + level: ConsistencyLevel, +} + +async fn get_handler( + State(db): State>, + ValidKey(key): ValidKey, + Query(q): Query, +) -> Result { + let value = match q.level { + ConsistencyLevel::Eventual => db.get(&key)?, + ConsistencyLevel::Linearizable => db.get_linearizable(&key).await?, + ConsistencyLevel::Lease => db.get_lease(&key).await?, + }; + match value { + Some(bytes) => Ok(bytes), + None => Err(HttpError::not_found()), + } +} + +async fn put_handler( + State(db): State>, + ValidKey(key): ValidKey, + body: Bytes, +) -> Result { + db.put(&key, &body).await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn delete_handler( + State(db): State>, + ValidKey(key): ValidKey, +) -> Result { + db.delete(&key).await?; + Ok(StatusCode::NO_CONTENT) +} + +/// Wires all three /kv/:key methods onto one route. +pub(super) fn route() -> MethodRouter> { + get(get_handler).put(put_handler).delete(delete_handler) +} + +#[cfg(test)] +#[path = "kv_test.rs"] +mod kv_test; diff --git a/d-lmdb-server/src/http/kv_test.rs b/d-lmdb-server/src/http/kv_test.rs new file mode 100644 index 0000000..90cc8ec --- /dev/null +++ b/d-lmdb-server/src/http/kv_test.rs @@ -0,0 +1,34 @@ +use super::ConsistencyLevel; +use super::ConsistencyParam; + +fn parse(query: &str) -> Result { + serde_urlencoded::from_str(query) +} + +#[test] +fn test_absent_level_defaults_to_eventual() { + assert_eq!(parse("").unwrap().level, ConsistencyLevel::Eventual); +} + +#[test] +fn test_level_eventual() { + assert_eq!(parse("level=eventual").unwrap().level, ConsistencyLevel::Eventual); +} + +#[test] +fn test_level_linearizable() { + assert_eq!(parse("level=linearizable").unwrap().level, ConsistencyLevel::Linearizable); +} + +#[test] +fn test_level_lease() { + assert_eq!(parse("level=lease").unwrap().level, ConsistencyLevel::Lease); +} + +#[test] +fn test_unknown_level_is_rejected() { + // This is what makes the "forgot to wire up a new level" bug class impossible: + // the enum is exhaustively matched in get_handler, and anything that doesn't + // deserialize into a known variant fails right here, not silently falls through. + assert!(parse("level=bogus").is_err()); +} diff --git a/d-lmdb-server/src/http/mod.rs b/d-lmdb-server/src/http/mod.rs new file mode 100644 index 0000000..0df7c67 --- /dev/null +++ b/d-lmdb-server/src/http/mod.rs @@ -0,0 +1,35 @@ +//! HTTP entry point: router assembly, shared `DLmdb` state, `serve()` for main.rs. + +mod error; +mod health; +mod kv; + +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::Router; +use d_lmdb::DLmdb; +use tower_http::limit::RequestBodyLimitLayer; + +/// JSON/transport overhead allowance on top of DLmdb's configured max value size, +/// so a value that's exactly at the limit doesn't get rejected by the transport +/// layer before it ever reaches `db.put()`'s own validation. +const BODY_LIMIT_OVERHEAD_BYTES: usize = 1024; + +/// Builds the router and serves it on `addr` until the process exits. +pub async fn serve( + db: Arc, + addr: SocketAddr, +) -> std::io::Result<()> { + let body_limit = db.max_value_bytes() + BODY_LIMIT_OVERHEAD_BYTES; + + let app = Router::new().route("/kv/{key}", kv::route()); + let app = health::add_routes(app); + let app = app + .layer(RequestBodyLimitLayer::new(body_limit)) + .layer(axum::middleware::map_response(error::normalize_error_body)) + .with_state(db); + + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await +} diff --git a/d-lmdb-server/src/main.rs b/d-lmdb-server/src/main.rs index ed33da6..8b650f1 100644 --- a/d-lmdb-server/src/main.rs +++ b/d-lmdb-server/src/main.rs @@ -1,9 +1,95 @@ //! Standalone HTTP server / Docker entrypoint for d-lmdb. -//! -//! Placeholder — HTTP layer (axum/clap, serve/healthcheck subcommands) is not -//! yet implemented. This exists to prove the workspace wiring (`d-lmdb-server` -//! depending on `d-lmdb` via path) compiles end to end. -fn main() { - println!("d-lmdb-server: HTTP layer not yet implemented"); +mod http; + +use std::net::SocketAddr; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +use clap::Parser; +use clap::Subcommand; +use d_lmdb::DLmdb; +use serde::Deserialize; + +#[derive(Parser)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Start the node and serve the HTTP API. + Serve { + #[arg(long, env = "CONFIG")] + config: PathBuf, + }, + /// Probe this node's /status endpoint; exit 0 if healthy. Used by Docker HEALTHCHECK. + Healthcheck { + #[arg(long, env = "CONFIG")] + config: PathBuf, + }, +} + +/// Only the section d-lmdb-server itself cares about — d-lmdb's own `[cluster]`/`[lmdb]` +/// sections in the same file are parsed separately by `DLmdb::open_from_file`. +#[derive(Deserialize)] +struct RootConfig { + http: HttpSection, +} + +#[derive(Deserialize)] +struct HttpSection { + listen_address: SocketAddr, +} + +fn read_http_listen_address(config_path: &Path) -> std::io::Result { + let text = std::fs::read_to_string(config_path)?; + let root: RootConfig = + toml::from_str(&text).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + Ok(root.http.listen_address) +} + +/// Raw HTTP/1.1 GET, no client dependency — this only ever talks to the same +/// container's own loopback address, so no TLS/redirects/DNS are needed. +fn probe_status_endpoint(addr: SocketAddr) -> bool { + use std::io::Read; + use std::io::Write; + use std::net::TcpStream; + use std::time::Duration; + + let Ok(mut stream) = TcpStream::connect_timeout(&addr, Duration::from_secs(2)) else { + return false; + }; + let _ = stream.set_read_timeout(Some(Duration::from_secs(2))); + let request = b"GET /status HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + if stream.write_all(request).is_err() { + return false; + } + let mut response = String::new(); + if stream.read_to_string(&mut response).is_err() { + return false; + } + response.starts_with("HTTP/1.1 200") +} + +#[tokio::main] +async fn main() -> std::io::Result<()> { + let cli = Cli::parse(); + match cli.command { + Command::Serve { config } => { + let db = DLmdb::open_from_file(&config).await.map_err(std::io::Error::other)?; + let addr = read_http_listen_address(&config)?; + http::serve(Arc::new(db), addr).await + } + Command::Healthcheck { config } => { + let addr = read_http_listen_address(&config)?; + if probe_status_endpoint(addr) { + Ok(()) + } else { + std::process::exit(1); + } + } + } } diff --git a/d-lmdb/src/lib.rs b/d-lmdb/src/lib.rs index 789f7b3..8fbc79e 100644 --- a/d-lmdb/src/lib.rs +++ b/d-lmdb/src/lib.rs @@ -37,6 +37,9 @@ mod time; mod wire; pub use d_engine::BatchOp; +pub use d_engine::ClientApiError; +pub use d_engine::ErrorCode; +pub use d_engine::LeaderHint; pub use d_engine::LeaderInfo; pub use db::DLmdb; pub use error::Error; From 5b259c4da01bbd2168d477b9870429889f78ab95 Mon Sep 17 00:00:00 2001 From: Joshua Chi <112539+JoshuaChi@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:22:34 +0800 Subject: [PATCH 07/18] feat #2: add Dockerfile, entrypoint, and .dockerignore for d-lmdb-server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cargo-chef multi-stage build, --bin d-lmdb-server only (workspace's own examples/ excluded from the final image) - docker-entrypoint.sh: root chowns bind-mounted /data to fixed 1000:1000, then drops to appuser via gosu — server process never runs as root - HEALTHCHECK start-period=10s, derived from 5 measured cold starts (worst case 863ms) rather than a guessed value - .dockerignore excludes rust-toolchain.toml — its pinned components (rustfmt/clippy) were being installed on every build for no reason, the base image is already the exact matching Rust version Verified: real docker build + docker run, HEALTHCHECK reaches "healthy", PUT/GET across all three consistency levels, and the error-response normalization middleware confirmed working against a live container. --- .dockerignore | 14 +++++++++++ Dockerfile | 58 ++++++++++++++++++++++++++++++++++++++++++++ docker-entrypoint.sh | 13 ++++++++++ 3 files changed, 85 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100755 docker-entrypoint.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a69806b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +target/ +.git/ +.zed/ +.DS_Store +data/ +db/ +logs/ +*.mdb +d-lmdb-server/config.toml + +# The rust:1.89-slim-bookworm base image already IS the pinned version — this +# file's only effect inside the build is making rustup fetch clippy/rustfmt +# (unused for `cargo build`) on every build. +rust-toolchain.toml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d1184e7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,58 @@ +# syntax=docker/dockerfile:1 +# +# Multi-stage build for d-lmdb-server, layered via cargo-chef so dependency +# compilation is cached separately from application code changes. + +FROM rust:1.89-slim-bookworm AS chef +# protobuf-compiler: d-engine-proto's build.rs shells out to `protoc`. +RUN apt-get update \ + && apt-get install -y --no-install-recommends protobuf-compiler \ + && rm -rf /var/lib/apt/lists/* +RUN cargo install cargo-chef --locked +WORKDIR /app + +FROM chef AS planner +COPY . . +RUN cargo chef prepare --recipe-path recipe.json + +FROM chef AS builder +COPY --from=planner /app/recipe.json recipe.json +RUN cargo chef cook --release --recipe-path recipe.json +COPY . . +RUN cargo build --release -p d-lmdb-server + +FROM debian:bookworm-slim AS runtime + +# gosu: drop from root to appuser after the entrypoint fixes up mounted-volume +# ownership — see docker-entrypoint.sh. ca-certificates: TLS roots, needed if +# the Raft cluster ever talks over TLS. +RUN apt-get update \ + && apt-get install -y --no-install-recommends gosu ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Fixed UID/GID (not just UID) — docker-compose bind-mounts a host directory +# onto /data, and the host side must be chowned to this same 1000:1000 ahead +# of time. See docker-entrypoint.sh for the runtime half of this. +RUN groupadd -g 1000 appuser \ + && useradd -u 1000 -g appuser -m -s /usr/sbin/nologin appuser \ + && mkdir -p /data \ + && chown appuser:appuser /data + +COPY --from=builder /app/target/release/d-lmdb-server /usr/local/bin/d-lmdb-server +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# 9081: Raft inter-node RPC. 8080: HTTP client API. Both configurable via the +# mounted config's [cluster]/[http] listen_address — EXPOSE here is documentation, +# not enforcement. +EXPOSE 9081 8080 + +# start-period=10s: measured 5 cold starts locally (serve → first successful +# healthcheck), worst case 863ms (first-ever exec, page cache cold — the +# closest local proxy for a freshly started container). 10s leaves ~10x margin +# for slower/loaded hosts without masking a real startup failure for too long. +HEALTHCHECK --interval=5s --timeout=3s --start-period=10s --retries=3 \ + CMD ["d-lmdb-server", "healthcheck"] + +ENTRYPOINT ["docker-entrypoint.sh"] +CMD ["d-lmdb-server", "serve"] diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..e2c3836 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Runs as root on container start so it can fix ownership of a bind-mounted +# /data whose host-side UID/GID may not match appuser's fixed 1000:1000 — +# then drops privileges and re-execs itself, so the real server process never +# runs as root. +set -e + +if [ "$(id -u)" = "0" ]; then + chown -R appuser:appuser /data + exec gosu appuser "$0" "$@" +fi + +exec "$@" From 4bd1cd904f242bb6fa4f94e2ed70d58cfc45b75f Mon Sep 17 00:00:00 2001 From: Joshua Chi <112539+JoshuaChi@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:20:45 +0800 Subject: [PATCH 08/18] fix #2: leader_hint uses HTTP port, not Raft port address was the Raft peer address (e.g. node3:9081), unusable by HTTP clients. Now swaps in this node's own HTTP port. --- d-lmdb-server/src/http/error.rs | 52 +++++++++++++++++++++++++--- d-lmdb-server/src/http/error_test.rs | 24 +++++++++++++ d-lmdb-server/src/http/mod.rs | 1 + 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/d-lmdb-server/src/http/error.rs b/d-lmdb-server/src/http/error.rs index f8a574f..33379d9 100644 --- a/d-lmdb-server/src/http/error.rs +++ b/d-lmdb-server/src/http/error.rs @@ -13,6 +13,8 @@ //! Limitation), so they fall through the wildcard into 500 rather than getting dead //! match arms. +use std::sync::OnceLock; + use axum::Json; use axum::http::HeaderValue; use axum::http::StatusCode; @@ -32,16 +34,56 @@ struct ErrorBody { trace_id: Option, } -/// Mirrors `d_lmdb::LeaderHint` verbatim (not `{host, port}`): `address` is an -/// opaque string from Raft config, not guaranteed to be a parseable `SocketAddr` -/// (could be IPv6, a hostname, etc.) — relaying it as-is avoids parsing we'd have -/// to get right for formats we don't control. +/// `d_lmdb::LeaderHint.address` is the leader's Raft peer address (e.g. +/// `http://node3:9081`) — useless to an HTTP client, which needs the leader's +/// HTTP port instead. `address` here has the host from the Raft address with +/// this node's own HTTP port substituted in (see `rewrite_to_http_address`), +/// not `h.address` relayed verbatim. #[derive(Serialize)] struct LeaderHintDto { leader_id: u32, address: String, } +/// This node's own `[http] listen_address` port, set once in `http::serve()`. +/// Read when rewriting a peer's Raft address into an HTTP one — see +/// `rewrite_to_http_address`. +static LOCAL_HTTP_PORT: OnceLock = OnceLock::new(); + +pub(super) fn set_local_http_port(port: u16) { + let _ = LOCAL_HTTP_PORT.set(port); +} + +/// Swaps the Raft port in a peer address for this node's own HTTP port. +/// Assumes every node in the cluster serves HTTP on the same port (true for +/// homogeneous deployments — same image/config, different hostnames — which +/// is what docker-compose/k8s give you). Falls back to the raw Raft address, +/// unusable as it is, only if this node's own HTTP port was somehow never set. +/// +/// Known limitation: the rewritten address is only reachable by clients on the +/// same network as the cluster (e.g. other containers on the same docker-compose +/// network) — an external client reaching the cluster through published/mapped +/// ports won't be able to resolve the bare hostname either way. Not the primary +/// routing path regardless (external LB health-checking `/primary` is — see +/// `ha-deployment-load-balancing.md`); this only matters for direct-to-node +/// callers that skip the LB. See decisions doc `leader-hint-http-address-mismatch.md`. +fn rewrite_to_http_address(raft_address: &str) -> String { + match LOCAL_HTTP_PORT.get() { + Some(port) => replace_port(raft_address, *port), + None => raft_address.to_string(), + } +} + +/// Pure string rewrite: replaces whatever comes after the last `:` with `port`. +/// Keeps any scheme prefix intact (`http://node3:9081` -> `http://node3:8080`). +fn replace_port( + address: &str, + port: u16, +) -> String { + let host = address.rsplit_once(':').map_or(address, |(host, _)| host); + format!("{host}:{port}") +} + pub(super) struct HttpError { status: StatusCode, body: ErrorBody, @@ -93,7 +135,7 @@ impl From for HttpError { error: message.clone(), leader_hint: leader_hint.as_ref().map(|h| LeaderHintDto { leader_id: h.leader_id, - address: h.address.clone(), + address: rewrite_to_http_address(&h.address), }), trace_id: None, }, diff --git a/d-lmdb-server/src/http/error_test.rs b/d-lmdb-server/src/http/error_test.rs index 09bcb6c..3ff0060 100644 --- a/d-lmdb-server/src/http/error_test.rs +++ b/d-lmdb-server/src/http/error_test.rs @@ -9,6 +9,7 @@ use d_lmdb::LeaderHint; use super::HttpError; use super::internal_error_category; use super::normalize_error_body; +use super::replace_port; use super::retry_after_ms_to_secs; async fn body_json(resp: axum::response::Response) -> serde_json::Value { @@ -183,6 +184,29 @@ fn test_internal_error_category_fallback_is_internal_error() { assert_eq!(internal_error_category(&err), "internal error"); } +// ── replace_port ───────────────────────────────────────────────────────────── +// +// Pure function, no LOCAL_HTTP_PORT global involved — the leader_hint tests +// above rely on that global being unset for the whole test binary (no test +// calls set_local_http_port, so fallback is deterministic); these test the +// actual rewrite logic in isolation instead. + +#[test] +fn test_replace_port_keeps_scheme_prefix() { + assert_eq!(replace_port("http://node3:9081", 8080), "http://node3:8080"); +} + +#[test] +fn test_replace_port_no_scheme() { + assert_eq!(replace_port("10.0.0.2:9081", 8080), "10.0.0.2:8080"); +} + +#[test] +fn test_replace_port_no_colon_appends_port() { + // No `:` to split on at all — treat the whole string as the host. + assert_eq!(replace_port("localhost", 8080), "localhost:8080"); +} + // ── retry_after_ms_to_secs ─────────────────────────────────────────────────── #[test] diff --git a/d-lmdb-server/src/http/mod.rs b/d-lmdb-server/src/http/mod.rs index 0df7c67..ca0de0c 100644 --- a/d-lmdb-server/src/http/mod.rs +++ b/d-lmdb-server/src/http/mod.rs @@ -22,6 +22,7 @@ pub async fn serve( addr: SocketAddr, ) -> std::io::Result<()> { let body_limit = db.max_value_bytes() + BODY_LIMIT_OVERHEAD_BYTES; + error::set_local_http_port(addr.port()); let app = Router::new().route("/kv/{key}", kv::route()); let app = health::add_routes(app); From 5563ba7b4133abe7cba886d6da36bcd64aea4bfc Mon Sep 17 00:00:00 2001 From: Joshua Chi <112539+JoshuaChi@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:20:52 +0800 Subject: [PATCH 09/18] feat #2: docker-compose 3-node cluster + HAProxy + smoke test HAProxy routes writes and strong reads to whoever passes /primary. smoke-test.sh: client only talks to the LB, verifies leader crash + re-election + writes resume, zero client-side changes. --- compose/haproxy.cfg | 42 ++++++++++++ compose/node1/config.toml | 17 +++++ compose/node2/config.toml | 17 +++++ compose/node3/config.toml | 17 +++++ compose/smoke-test.sh | 132 ++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 50 +++++++++++++++ 6 files changed, 275 insertions(+) create mode 100644 compose/haproxy.cfg create mode 100644 compose/node1/config.toml create mode 100644 compose/node2/config.toml create mode 100644 compose/node3/config.toml create mode 100755 compose/smoke-test.sh create mode 100644 docker-compose.yml diff --git a/compose/haproxy.cfg b/compose/haproxy.cfg new file mode 100644 index 0000000..62a8691 --- /dev/null +++ b/compose/haproxy.cfg @@ -0,0 +1,42 @@ +global + maxconn 4096 + +defaults + mode http + timeout connect 5s + timeout client 30s + timeout server 30s + +frontend kv_frontend + bind *:80 + # PUT/DELETE are writes — must land on the leader. + acl is_write method PUT DELETE + # ?level=linearizable/lease reads also require the leader (ReadIndex / + # lease-read both need to originate there) — a follower rejects these + # with 503 NotLeader, same as a write. Only a plain/eventual GET can + # go to any node. + acl is_strong_read url_param(level) -m str linearizable lease + use_backend write_backend if is_write + use_backend write_backend if is_strong_read + default_backend read_backend + +# Only the node currently passing /primary (i.e. the leader) is in rotation. +# fall 2/rise 1 with a 2s interval: drops a dead leader within ~4s, and picks +# up a newly elected one as soon as a single health check confirms it. +backend write_backend + option httpchk GET /primary + http-check expect status 200 + default-server inter 2s fall 2 rise 1 + server node1 node1:8080 check + server node2 node2:8080 check + server node3 node3:8080 check + +# Any live node can serve reads — /status returns 200 regardless of role, +# it's just a liveness check here, not a leader check. +backend read_backend + balance roundrobin + option httpchk GET /status + default-server inter 2s + server node1 node1:8080 check + server node2 node2:8080 check + server node3 node3:8080 check diff --git a/compose/node1/config.toml b/compose/node1/config.toml new file mode 100644 index 0000000..1b5bc1a --- /dev/null +++ b/compose/node1/config.toml @@ -0,0 +1,17 @@ +[cluster] +node_id = 1 +listen_address = "0.0.0.0:9081" +initial_cluster = [ + { id = 1, address = "node1:9081", role = 1, status = 3 }, + { id = 2, address = "node2:9081", role = 1, status = 3 }, + { id = 3, address = "node3:9081", role = 1, status = 3 }, +] +db_root_dir = "/data/raft" +log_dir = "/data/logs" + +[lmdb] +data_dir = "/data/lmdb" +map_size_gb = 1 + +[http] +listen_address = "0.0.0.0:8080" diff --git a/compose/node2/config.toml b/compose/node2/config.toml new file mode 100644 index 0000000..23ca303 --- /dev/null +++ b/compose/node2/config.toml @@ -0,0 +1,17 @@ +[cluster] +node_id = 2 +listen_address = "0.0.0.0:9081" +initial_cluster = [ + { id = 1, address = "node1:9081", role = 1, status = 3 }, + { id = 2, address = "node2:9081", role = 1, status = 3 }, + { id = 3, address = "node3:9081", role = 1, status = 3 }, +] +db_root_dir = "/data/raft" +log_dir = "/data/logs" + +[lmdb] +data_dir = "/data/lmdb" +map_size_gb = 1 + +[http] +listen_address = "0.0.0.0:8080" diff --git a/compose/node3/config.toml b/compose/node3/config.toml new file mode 100644 index 0000000..f7d9e5d --- /dev/null +++ b/compose/node3/config.toml @@ -0,0 +1,17 @@ +[cluster] +node_id = 3 +listen_address = "0.0.0.0:9081" +initial_cluster = [ + { id = 1, address = "node1:9081", role = 1, status = 3 }, + { id = 2, address = "node2:9081", role = 1, status = 3 }, + { id = 3, address = "node3:9081", role = 1, status = 3 }, +] +db_root_dir = "/data/raft" +log_dir = "/data/logs" + +[lmdb] +data_dir = "/data/lmdb" +map_size_gb = 1 + +[http] +listen_address = "0.0.0.0:8080" diff --git a/compose/smoke-test.sh b/compose/smoke-test.sh new file mode 100755 index 0000000..9e04d97 --- /dev/null +++ b/compose/smoke-test.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# 3-node d-lmdb-server + HAProxy smoke test. +# +# The primary story: a client only ever talks to the LB (localhost:8080). +# It never needs to know which node is leader, or that a leader even +# exists — HAProxy's health-check-based routing (compose/haproxy.cfg) +# handles that, including automatically re-routing after a leader crash. +# +# Usage: compose/smoke-test.sh (run from repo root) +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +declare -A PORT=( [node1]=18081 [node2]=18082 [node3]=18083 ) +LB_URL="http://localhost:8080" +COMPOSE="docker compose" + +pass() { echo "PASS: $1"; } +fail() { echo "FAIL: $1"; cleanup_on_fail; exit 1; } + +cleanup_on_fail() { + echo "--- container logs on failure ---" + $COMPOSE logs --tail=50 +} + +command -v jq >/dev/null || { echo "jq is required (brew install jq)"; exit 1; } + +echo "== bringing up 3-node cluster + HAProxy ==" +$COMPOSE up -d --build + +echo "== waiting for all 3 nodes to report healthy ==" +for name in node1 node2 node3; do + for _ in $(seq 1 60); do + status=$($COMPOSE ps -q "$name" | xargs docker inspect --format '{{.State.Health.Status}}' 2>/dev/null || echo "starting") + [ "$status" = "healthy" ] && break + sleep 1 + done + [ "$status" = "healthy" ] || fail "$name never became healthy (last status: $status)" +done +pass "all 3 nodes healthy" + +find_leader_directly() { + for name in node1 node2 node3; do + code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${PORT[$name]}/primary") + [ "$code" = "200" ] && { echo "$name"; return; } + done + echo "" +} + +echo "== confirming exactly one node is leader (direct check, precondition) ==" +leader=$(find_leader_directly) +[ -n "$leader" ] || fail "no node reports /primary=200" +primary_count=0 +for name in node1 node2 node3; do + code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${PORT[$name]}/primary") + [ "$code" = "200" ] && primary_count=$((primary_count + 1)) +done +[ "$primary_count" -eq 1 ] || fail "expected exactly 1 leader, got $primary_count" +pass "exactly one leader: $leader" + +echo "== waiting for HAProxy to pick up the leader in write_backend ==" +for _ in $(seq 1 30); do + code=$(curl -s -o /dev/null -w '%{http_code}' -X PUT "$LB_URL/kv/lb-warmup" -d 'x' || echo "000") + [ "$code" = "204" ] && break + sleep 1 +done +[ "$code" = "204" ] || fail "LB never started routing writes successfully (last code: $code)" +pass "LB is routing writes" + +echo "== PUT through the LB only — client never touches a node directly ==" +code=$(curl -s -o /dev/null -w '%{http_code}' -X PUT "$LB_URL/kv/smoke-key" -d 'smoke-value') +[ "$code" = "204" ] || fail "PUT via LB returned $code, expected 204" +pass "PUT via LB succeeded" + +echo "== GET through the LB (linearizable — read_backend round-robins across all" +echo " nodes, and a default eventual read can legitimately race a follower that" +echo " hasn't applied this write yet; linearizable avoids that race) ==" +value=$(curl -s "$LB_URL/kv/smoke-key?level=linearizable") +[ "$value" = "smoke-value" ] || fail "GET via LB returned '$value', expected 'smoke-value'" +pass "GET via LB returned correct value" + +echo "== (supplementary, non-blocking) direct PUT to a follower still returns leader_hint ==" +follower="" +for name in node1 node2 node3; do + [ "$name" != "$leader" ] && { follower="$name"; break; } +done +body_file=$(mktemp) +code=$(curl -s -o "$body_file" -w '%{http_code}' -X PUT "http://localhost:${PORT[$follower]}/kv/smoke-key" -d 'wrong-node') +body=$(cat "$body_file") +rm -f "$body_file" +if [ "$code" = "503" ] && [ -n "$(echo "$body" | jq -r '.leader_hint.address // empty')" ]; then + pass "follower still rejects direct writes with a leader_hint present (known limitation: address may not be externally reachable, see leader-hint-http-address-mismatch.md — not the primary routing path, LB is)" +else + echo "NOTE: direct-to-follower rejection didn't look as expected (code=$code, body=$body) — not failing the suite, this path is superseded by the LB" +fi + +echo "== killing the leader ($leader) to force re-election ==" +leader_container=$($COMPOSE ps -q "$leader") +docker kill "$leader_container" >/dev/null +old_leader="$leader" + +echo "== confirming a new leader is elected (direct check) ==" +new_leader="" +for _ in $(seq 1 60); do + for name in node1 node2 node3; do + [ "$name" = "$old_leader" ] && continue + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 1 "http://localhost:${PORT[$name]}/primary" 2>/dev/null || echo "000") + [ "$code" = "200" ] && { new_leader="$name"; break 2; } + done + sleep 1 +done +[ -n "$new_leader" ] || fail "no new leader elected within 60s of killing $old_leader" +pass "new leader elected after crash: $new_leader" + +echo "== the real point: write through the LB again, with ZERO client-side changes ==" +success="" +for _ in $(seq 1 30); do + code=$(curl -s -o /dev/null -w '%{http_code}' -X PUT "$LB_URL/kv/post-failover-key" -d 'still-alive' || echo "000") + [ "$code" = "204" ] && { success=1; break; } + sleep 1 +done +[ -n "$success" ] || fail "LB never resumed routing writes after leader crash (last code: $code)" +value=$(curl -s "$LB_URL/kv/post-failover-key?level=linearizable") +[ "$value" = "still-alive" ] || fail "post-failover GET via LB returned '$value'" +pass "LB automatically re-routed to the new leader — client never had to know anything changed" + +echo "== tearing down ==" +$COMPOSE down -v + +echo +echo "ALL SMOKE TESTS PASSED" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c6028d7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +services: + node1: + build: . + image: d-lmdb-server:compose + hostname: node1 + environment: + CONFIG: /etc/dlmdb/config.toml + volumes: + - ./compose/node1/config.toml:/etc/dlmdb/config.toml:ro + - node1-data:/data + ports: + - "18081:8080" + + node2: + image: d-lmdb-server:compose + hostname: node2 + environment: + CONFIG: /etc/dlmdb/config.toml + volumes: + - ./compose/node2/config.toml:/etc/dlmdb/config.toml:ro + - node2-data:/data + ports: + - "18082:8080" + + node3: + image: d-lmdb-server:compose + hostname: node3 + environment: + CONFIG: /etc/dlmdb/config.toml + volumes: + - ./compose/node3/config.toml:/etc/dlmdb/config.toml:ro + - node3-data:/data + ports: + - "18083:8080" + + lb: + image: haproxy:2.9-alpine + volumes: + - ./compose/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro + ports: + - "8080:80" + depends_on: + - node1 + - node2 + - node3 + +volumes: + node1-data: + node2-data: + node3-data: From 322c2cd8d9f4a4d4626009c5734838cf5327df13 Mon Sep 17 00:00:00 2001 From: Joshua Chi <112539+JoshuaChi@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:38:32 +0800 Subject: [PATCH 10/18] docs #2: add d-lmdb-server README, fix config.example.toml paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relative paths (./data) crashed in the container — non-root user has no write permission at that cwd. Verified fix with a real docker run. --- d-lmdb-server/README.md | 77 +++++++++++++++++++++++++++++++ d-lmdb-server/config.example.toml | 6 +-- 2 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 d-lmdb-server/README.md diff --git a/d-lmdb-server/README.md b/d-lmdb-server/README.md new file mode 100644 index 0000000..8127146 --- /dev/null +++ b/d-lmdb-server/README.md @@ -0,0 +1,77 @@ +# d-lmdb-server + +HTTP+JSON server wrapping [d-lmdb](../d-lmdb) over a Raft cluster. Docker-deployable — talk to it over plain HTTP, no Rust required. + +## Quick Start + +**Single node:** + +```bash +docker build -t d-lmdb-server -f ../Dockerfile .. +cp config.example.toml config.toml +docker run -d -p 8080:8080 -e CONFIG=/etc/dlmdb/config.toml \ + -v $(pwd)/config.toml:/etc/dlmdb/config.toml:ro -v dlmdb-data:/data \ + d-lmdb-server + +curl -X PUT localhost:8080/kv/hello -d world +curl localhost:8080/kv/hello +``` + +**3-node HA cluster** (from repo root): + +```bash +docker compose up -d --build +curl -X PUT localhost:8080/kv/hello -d world # via HAProxy, always finds the leader +curl "localhost:8080/kv/hello?level=linearizable" +``` + +`compose/smoke-test.sh` runs a full automated walkthrough, including killing the leader container and confirming the cluster recovers with zero client-side changes. + +## API + +| Method | Path | | +| -------- | ----------- | ------------------------------------------------------------------------------ | +| `PUT` | `/kv/{key}` | Write, body = raw value bytes. `204` on success. | +| `GET` | `/kv/{key}` | Read. `200` + raw body, or `404`. | +| `DELETE` | `/kv/{key}` | Delete. `204` — idempotent, deleting a missing key still succeeds. | +| `GET` | `/primary` | `200` if this node is leader, else `503`. For LB health checks routing writes. | +| `GET` | `/replica` | `200` if this node is a follower, else `503`. | +| `GET` | `/status` | Always `200`. `{leader, members, learners, committed_index}`. | + +**`GET ?level=`** picks read consistency: + +| `level` | Meaning | +| ------------------- | ----------------------------------------------------------------------------------------------------------- | +| absent / `eventual` | Local read, fast, may be briefly stale. Default. | +| `linearizable` | Confirmed via Raft ReadIndex — always current, slower. Requires the leader. | +| `lease` | Leader-lease optimized — as current as `linearizable`, faster when the lease is valid. Requires the leader. | + +## Errors + +Every non-2xx response is JSON: `{"error": "...", "leader_hint": {...}, "trace_id": "..."}` (`leader_hint`/`trace_id` only present when relevant — extractor-level rejections get normalized into this shape too, never plain text). `400` = your input, `503` = not leader / cluster unavailable, retry; `500` = server-side, look up `trace_id` in server logs for detail. Full classification and rationale: `decisions/015-http-error-classification.md` in `d-engine-product-design`. + +**Known limitation**: `leader_hint.address` is only reachable by clients on the same network as the cluster (e.g. other containers on the same docker-compose network) — not usable by an external client connecting through mapped/published ports. Not a primary mechanism regardless — see High Availability below. + +## Config (`config.toml`, see `config.example.toml`) + +```toml +[cluster] +node_id = 1 +listen_address = "0.0.0.0:9081" +db_root_dir = "/data/raft" +log_dir = "/data/logs" +# initial_cluster = [...] # required for multi-node — see compose/node1/config.toml + +[lmdb] +data_dir = "/data/lmdb" +map_size_gb = 10 + +[http] +listen_address = "0.0.0.0:8080" +``` + +Path via `d-lmdb-server serve --config `, or the `CONFIG` env var (what Docker uses). `d-lmdb-server healthcheck` probes `/status` on the same config — this is what the image's `HEALTHCHECK` calls. + +## High Availability + +Put a load balancer in front of an odd number of nodes (3+), routing `PUT`/`DELETE` and `GET ?level=linearizable|lease` to whichever node's `/primary` returns `200`; plain `GET` can round-robin any live node. `compose/haproxy.cfg` is a working reference, `compose/smoke-test.sh` proves the failover story end to end. diff --git a/d-lmdb-server/config.example.toml b/d-lmdb-server/config.example.toml index 248dfd0..f9274c7 100644 --- a/d-lmdb-server/config.example.toml +++ b/d-lmdb-server/config.example.toml @@ -1,11 +1,11 @@ [cluster] node_id = 1 listen_address = "0.0.0.0:9081" -db_root_dir = "./data" -log_dir = "./logs" +db_root_dir = "/data/raft" +log_dir = "/data/logs" [lmdb] -data_dir = "./data" +data_dir = "/data/lmdb" map_size_gb = 10 [http] From 81aa24990ebe0bad701d0b45dbfbca0f65c3c0c0 Mon Sep 17 00:00:00 2001 From: Joshua Chi <112539+JoshuaChi@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:20:40 +0800 Subject: [PATCH 11/18] chore #2: stop tracking .agents/, add to .gitignore --- .agents/skills/mempalace-dengine/SKILL.md | 155 ---------------------- .gitignore | 3 +- 2 files changed, 2 insertions(+), 156 deletions(-) delete mode 100644 .agents/skills/mempalace-dengine/SKILL.md diff --git a/.agents/skills/mempalace-dengine/SKILL.md b/.agents/skills/mempalace-dengine/SKILL.md deleted file mode 100644 index 94492e2..0000000 --- a/.agents/skills/mempalace-dengine/SKILL.md +++ /dev/null @@ -1,155 +0,0 @@ ---- -name: mempalace-dengine -description: Save cross-session d-engine ecosystem decisions/design records to the mempalace MCP — when to save, dedup check, wing/room naming conventions ---- - -# MemPalace (mempalace-dengine) Conversation Save Skillset - -Scope: d-engine ecosystem repos (`d-engine`, `d-lmdb`, `d-stream`, `d-engine-product-design`, -`d-engine-jepsen`) using the `mempalace-dengine` MCP instance. This is the sibling doc to -`.claude/commands/save.md` (d-engine repo) — that skill writes ticket-scoped implementation -records to `d-engine-product-design/tickets|topics|decisions`; this skill writes cross-session, -searchable decision/design records to mempalace, for content that's more discussion-shaped -than doc-shaped (expert-consulted debugging, positioning pivots, root-cause findings). - -## Before Saving: Dedup Check (MUST run first) - -Before creating any drawer, check for an existing one on the same topic: - -``` -mempalace_check_duplicate({ content: "" }) -mempalace_search({ query: "" }) # if check_duplicate is inconclusive -``` - -- **Existing drawer, same topic still open** → this is an update/continuation. Add a new - drawer but title it as a follow-up and link back: `(follow-up to drawer_id: ...)` — - mempalace has no in-place append, so "update" in practice means "new drawer, explicitly linked." -- **Existing drawer, but this is a genuinely new decision/phase** → new drawer, still link - the prior one under `## Related`. -- **Nothing found** → create new drawer. - -Skipping this step is the main cause of duplicate/fragmented drawers — evidence of this -already exists in the palace: `d_engine`, `d-engine`, and `dengine` are three separate wings -today because of inconsistent naming, not three separate projects. Don't add a fourth variant. - -## When to Save - -Save when ANY of these occur AND the content has a concrete outcome (not just "we discussed X"): - -| Trigger | Example from this project | -| ------------------------------------- | -------------------------------------------------------------------- | -| Product/architecture positioning decided | d-stream pivot from broker to embedded pipeline (2026-06-07) | -| Design decision after expert consultation | d-stream write-durability WAL design; d-lmdb pros/cons framing | -| Root cause found for a real bug | LMDB empty-key MDB_BAD_VALSIZE root cause | -| README / positioning rewrite recorded | d-lmdb "fault tolerance not scaling" reframing | -| Cross-project convention established | "distributed fault tolerance, not distributed scaling" reused across d-lmdb and d-stream | -| Ticket implementation decision (if not already going through `save.md`) | Design choices made mid-debugging before a ticket exists | - -Do not auto-save on keyword match — every candidate still goes through Dedup Check and -Dry-Run Check below. - -## What NOT to Save - -- Clarifying questions with no decision attached ("what does get_linearizable do?") -- Draft proposals that were rejected or superseded before being acted on -- Anything already fully captured in `d-engine-product-design/decisions/NNN-*.md` or - `tickets/milestones/.../N.md` via the `save.md` skill — don't duplicate across both systems. - Rule of thumb: **ticket-scoped implementation detail → `save.md` / decisions folder. - Cross-session discussion/design reasoning that doesn't map to one ticket → mempalace.** -- Code-level detail fully recoverable by reading the current source (file paths, function - signatures) — link to the file/line instead of pasting code that will drift - -## Save Format - -```typescript -mempalace_add_drawer({ - wing: "d-lmdb", // exact repo name, see Wing Convention below — never guess - room: "decisions", // see Room Convention - content: ` -# {Topic} — {YYYY-MM-DD} - -## Context -{1-2 sentences — why this came up} - -## Conclusion / Decision -{the core outcome, phrased so it stands alone when searched} -- {point 1} -- {point 2} - -## Honest Limits / Known Constraints -{if applicable — this project's norm is to state explicitly what a decision does NOT solve, don't omit it} - -## Related -{linked drawer_id(s) or file paths, or "-" if none} -`, -}); -``` - -## Wing Convention — canonical names only - -| Wing | Repo | -| ------------------------- | ---------------------------------------- | -| `d-engine` | github.com/DEventLab/d-engine | -| `d-lmdb` | github.com/DEventLab/d-lmdb | -| `d-stream` | github.com/DEventLab/d-stream | -| `d-engine-product-design` | design/knowledge repo | -| `d-engine-jepsen` | Jepsen test harness | - -**Always use the hyphenated form matching the actual repo/directory name.** Do not use -underscores (`d_engine`) or no-separator forms (`dengine`) — those already exist as stray -wings from earlier inconsistent saves and should not be added to. If you're unsure which repo -a discussion belongs to, check the working directory path, not habit. - -## Room Convention - -| Room | Use | -| -------------- | ---------------------------------------------------------------- | -| `decisions` | Default. Almost everything goes here. | -| `architecture` | Only when the entire drawer is architecture/design, no decision outcome yet | - -Don't over-split rooms — this project's palace already has legacy fragmentation -(`general`, `technical`, `tla`, per-crate room names from old imports). Going forward, -`decisions` is the default; only deviate when content is *exclusively* architecture-in-progress -with no decision reached yet. - -**Searchability note**: room is a weak filter. Lead the drawer title/first line with the most -specific searchable noun — ticket number (`#422`), crate name (`d-lmdb`), technical term -(`MDB_BAD_VALSIZE`) — not buried in paragraph 2. - -## Content Rules - -1. **Skill file language vs. saved content language**: this document (the skill file itself) - is English throughout. What actually gets *saved into mempalace* is different: body prose - may stay in the original discussion language (Chinese, matching how this team actually - communicates — see project CLAUDE.md) to preserve fidelity ("accurate record" was an - explicit requirement from Joshua). Section headers/structure (Context, Decision, Related, - etc.) stay in English for scannability across drawers. Keep ticket numbers, error strings, - function/crate names verbatim regardless of language. -2. **Self-contained**: readable and actionable without the original conversation, 3+ months later. -3. **Concrete over comprehensive**: every section should contain at least one searchable - entity (ticket #, error string, crate/function name, date). Cut sentences that only say - "we discussed X" without landing on an outcome. -4. **One drawer per topic-conclusion**, not per message. A single debugging session that - converges on one root cause = one drawer, even if it took many turns. -5. **Honest limits stay in**: this project's norm (established across d-lmdb/d-stream - positioning work) is to record what a decision does *not* solve, not just what it solves. - Don't strip that when compressing for mempalace. - -## Session Boundaries - -- Thread converges on a decision/root-cause → one drawer for the whole thread. -- Same topic resumed days later → new drawer, Dedup-Check-linked to the old one (see above; - mempalace has no true append, so "continuation" = new drawer + explicit backlink). -- Genuinely new topic → new drawer, no link needed unless directly relevant. - -## Dry-Run Check (run silently before every save) - -1. Does this drawer lead with a concrete searchable noun (ticket #, crate name, error string)? - If no → tighten the opening line. -2. Did I run Dedup Check and either link to an existing drawer or confirm this is new? - If no → go back and check. -3. Searched 3+ months from now, will the first two lines surface the actual decision — - not just "a discussion happened"? If no → revise. -4. Is the wing name one of the five canonical repo names above, not a variant? If no → fix it. - -All four pass → save. Otherwise → revise or skip. diff --git a/.gitignore b/.gitignore index 136a932..4d1cb25 100644 --- a/.gitignore +++ b/.gitignore @@ -29,9 +29,10 @@ target /logs/ **/*.mdb -# OS / editor +# OS / editor / local AI-agent tooling config .DS_Store .zed/ +.agents/ # Workspace now includes d-lmdb-server, a deployable binary — commit Cargo.lock # for reproducible builds. (d-lmdb the library still doesn't need one committed From 470fb7aee3105e42cfd47134dacaa1722c35715b Mon Sep 17 00:00:00 2001 From: Joshua Chi <112539+JoshuaChi@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:20:47 +0800 Subject: [PATCH 12/18] ci #2: multi-arch Docker release workflow, gated on smoke test --- .github/workflows/docker-release.yml | 114 +++++++++++++++++++++++++++ compose/smoke-test.sh | 12 ++- 2 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/docker-release.yml diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml new file mode 100644 index 0000000..84012b5 --- /dev/null +++ b/.github/workflows/docker-release.yml @@ -0,0 +1,114 @@ +name: Docker Release + +# Triggered by creating a GitHub Release, not every push to main. +on: + release: + types: [created] + +permissions: + contents: read + +env: + IMAGE: deventlab/d-lmdb + +jobs: + # Build, smoke-test, then push by digest — per platform. arm64 runs + # under QEMU: verifies correctness, not real hardware timing. + build-test-push: + name: Build, test, push (${{ matrix.platform }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + platform: [linux/amd64, linux/arm64] + steps: + - name: Set platform tag for artifact naming + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV" + + - uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image for this platform (local only, not pushed yet) + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + tags: d-lmdb-server:compose + load: true + + - name: Run 3-node smoke test against the freshly built image + run: DLMDB_SKIP_BUILD=1 ./compose/smoke-test.sh + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_ACCESS_TOKEN }} + + - name: Push by digest (smoke test passed — cached layers, no rebuild) + id: push + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + outputs: type=image,name=docker.io/${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.push.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + # Only runs if every platform above succeeded. + merge: + name: Merge multi-platform manifest + runs-on: ubuntu-latest + needs: build-test-push + steps: + - name: Strip 'v' prefix from release tag + run: | + VERSION=${{ github.event.release.tag_name }} + echo "STRIPPED_VERSION=${VERSION#v}" >> "$GITHUB_ENV" + + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_ACCESS_TOKEN }} + + - name: Create and push manifest list + working-directory: /tmp/digests + run: | + # shellcheck disable=SC2046 # intentional word-splitting + docker buildx imagetools create \ + -t docker.io/${{ env.IMAGE }}:${{ env.STRIPPED_VERSION }} \ + -t docker.io/${{ env.IMAGE }}:latest \ + $(printf 'docker.io/${{ env.IMAGE }}@sha256:%s ' *) + + - name: Verify manifest + run: docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:${{ env.STRIPPED_VERSION }} diff --git a/compose/smoke-test.sh b/compose/smoke-test.sh index 9e04d97..6697a76 100755 --- a/compose/smoke-test.sh +++ b/compose/smoke-test.sh @@ -7,6 +7,12 @@ # handles that, including automatically re-routing after a leader crash. # # Usage: compose/smoke-test.sh (run from repo root) +# +# DLMDB_SKIP_BUILD=1: reuse whatever image is already loaded under the +# d-lmdb-server:compose tag instead of building — for CI, which builds one +# specific platform first (see .github/workflows/docker-release.yml) and +# wants this script to test exactly that image, not rebuild for the host's +# native platform. set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -27,7 +33,11 @@ cleanup_on_fail() { command -v jq >/dev/null || { echo "jq is required (brew install jq)"; exit 1; } echo "== bringing up 3-node cluster + HAProxy ==" -$COMPOSE up -d --build +if [ "${DLMDB_SKIP_BUILD:-}" = "1" ]; then + $COMPOSE up -d +else + $COMPOSE up -d --build +fi echo "== waiting for all 3 nodes to report healthy ==" for name in node1 node2 node3; do From b9fb3ee5a69aa8a84c72621637ac3bd871de2b87 Mon Sep 17 00:00:00 2001 From: Joshua Chi <112539+JoshuaChi@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:26:19 +0800 Subject: [PATCH 13/18] docs #2: add root README (landing page + library/service split) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moved "what this solves/doesn't solve" here from d-lmdb/README.md — it's a project-level claim, not library-specific. --- README.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++ d-lmdb/README.md | 19 ++---------------- 2 files changed, 52 insertions(+), 17 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..78c46a5 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# d-lmdb + +![status](https://img.shields.io/badge/status-experimental-orange) +[![CI](https://github.com/deventlab/d-lmdb/actions/workflows/ci.yml/badge.svg)](https://github.com/deventlab/d-lmdb/actions/workflows/ci.yml) +![Static Badge](https://img.shields.io/badge/license-MIT%20%7C%20Apache--2.0-blue) + +d-lmdb demonstrates one concrete way [d-engine](https://github.com/DEventLab/d-engine) (a Raft consensus library) can add distributed replication to an existing embedded storage engine — LMDB was chosen because it's a well-known, compact embedded key-value store. The broader point: **d-engine can pair with many storage engines and applications to add an optional, replicated write path** — LMDB is just this example. + +This repo ships the same idea two ways — pick one: + +## Choose your path + +| | `d-lmdb` (library) | `d-lmdb-server` (service) | +|---|---|---| +| You are | writing Rust, embedding the store in your process | running any language, want a standalone service | +| You get | in-process calls, no network hop | HTTP+JSON over the network, `docker run` | +| Trade-off | fastest reads/writes, but every replica embeds a Raft node | one network hop, but replicas are decoupled from your app | + +→ Rust, embedded: [`d-lmdb/README.md`](./d-lmdb/README.md) +→ HTTP, Docker: [`d-lmdb-server/README.md`](./d-lmdb-server/README.md) + +## What this solves + +- **Single point of failure → quorum fault tolerance.** Writes go through Raft; the cluster keeps serving as long as a majority of nodes are up. +- **Cross-machine strong consistency, one extra dependency.** Multiple nodes see the same linearizable write history — no separate coordination service to run. +- **Reads stay fast.** Reads bypass Raft and hit local storage directly, in both the embedded and HTTP form. + +## What this doesn't solve + +Stated plainly, so expectations are set correctly: + +- **Write throughput does not improve — it gets worse.** A local write is one fsync; a replicated write is a network round trip plus a quorum of fsyncs. This trades latency/throughput for availability and consistency. +- **No sharding.** d-engine replicates data, it does not partition it — every node holds the full dataset. +- **Still just a key-value store.** No relational modeling or complex queries. +- **Raft adds real operational surface.** Leader election, membership changes, snapshots, log compaction — none of this exists with plain LMDB. + +## Repository layout + +- [`d-lmdb/`](./d-lmdb) — the Rust library +- [`d-lmdb-server/`](./d-lmdb-server) — the HTTP server binary + Docker image +- [`compose/`](./compose) — 3-node HA cluster reference (HAProxy + smoke test) +- [`examples/`](./examples) — runnable single-node / three-node examples using the library directly + +## Project Status + +Early-stage and evolving — intended for evaluation, experimentation, and community feedback. Not yet extensively validated in production. Issues, discussions, forks, and PRs are welcome. + +## License + +Licensed under either of [Apache License 2.0](LICENSE-APACHE) or [MIT License](LICENSE-MIT) at your option. Built on [d-engine](https://github.com/DEventLab/d-engine) (Apache-2.0 / MIT) and [LMDB](https://www.symas.com/lmdb) (OpenLDAP Public License) — see [NOTICES](NOTICES). diff --git a/d-lmdb/README.md b/d-lmdb/README.md index f1c674e..23676fe 100644 --- a/d-lmdb/README.md +++ b/d-lmdb/README.md @@ -16,24 +16,9 @@ The broader point this project is meant to demonstrate: **d-engine can be paired d-lmdb wraps LMDB with Raft consensus. Reads stay local and synchronous. Writes go through Raft and become replicated and strongly consistent. The only dependencies beyond d-engine are LMDB and the small set of crates d-engine itself needs. -d-lmdb aims at distributed _fault tolerance_, not distributed _scaling_. It adds a replicated write path in front of the LMDB you already know — the read path is left untouched. See [What d-lmdb Doesn't Solve](#what-d-lmdb-doesnt-solve) for where this stops being useful. +d-lmdb aims at distributed _fault tolerance_, not distributed _scaling_. It adds a replicated write path in front of the LMDB you already know — the read path is left untouched. ---- - -## What d-lmdb Solves - -- **Single point of failure → quorum fault tolerance.** Writes go through Raft; the cluster can keep serving as long as a majority of nodes are up. Plain LMDB has no replica — a lost disk or crashed process can mean lost data. -- **Cross-machine strong consistency, one additional dependency.** Multiple nodes see the same linearizable write history, with d-engine as the only extra piece — no separate coordination service to run. -- **LMDB's read path stays untouched.** `get()` bypasses Raft entirely and reads the local LMDB file directly — reads are not slowed down by the replication layer. - -## What d-lmdb Doesn't Solve - -Stating these plainly up front, so expectations are set correctly: - -- **Write throughput does not improve — it gets worse.** A local LMDB write is one fsync. A d-lmdb write is a network round trip plus a quorum of fsyncs. This trades write latency and throughput for availability and consistency. -- **No sharding.** d-engine replicates data, it does not partition it. Every node holds the full dataset — if your data does not fit on one node's LMDB, d-lmdb does not help. -- **Still just a key-value store.** Adding Raft does not add relational modeling or complex queries — the surface is the same get/put/scan LMDB already had. -- **Raft adds real operational surface.** Leader election, membership changes, snapshots, log compaction — none of this exists with plain LMDB. Reads keeping their performance does not mean operations stay as simple as LMDB alone. +See the [repo-level README](../README.md#what-this-solves) for what this project solves and doesn't solve — that applies equally to this library and to `d-lmdb-server`. --- From 3349d934b180de5076f1c9d211b2eb082dd4a91e Mon Sep 17 00:00:00 2001 From: Joshua Chi <112539+JoshuaChi@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:03:39 +0800 Subject: [PATCH 14/18] docs #2: dedupe README decision-tree, restore honest limitations --- README.md | 37 ++++++++++++------------ d-lmdb-server/README.md | 10 +++++-- d-lmdb/README.md | 62 ++++++++++++----------------------------- 3 files changed, 44 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 78c46a5..7851cd3 100644 --- a/README.md +++ b/README.md @@ -2,37 +2,38 @@ ![status](https://img.shields.io/badge/status-experimental-orange) [![CI](https://github.com/deventlab/d-lmdb/actions/workflows/ci.yml/badge.svg)](https://github.com/deventlab/d-lmdb/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/deventlab/d-lmdb/graph/badge.svg)](https://codecov.io/gh/deventlab/d-lmdb) ![Static Badge](https://img.shields.io/badge/license-MIT%20%7C%20Apache--2.0-blue) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/deventlab/d-lmdb) -d-lmdb demonstrates one concrete way [d-engine](https://github.com/DEventLab/d-engine) (a Raft consensus library) can add distributed replication to an existing embedded storage engine — LMDB was chosen because it's a well-known, compact embedded key-value store. The broader point: **d-engine can pair with many storage engines and applications to add an optional, replicated write path** — LMDB is just this example. +## What This Is -This repo ships the same idea two ways — pick one: +d-lmdb demonstrates one concrete way [d-engine](https://github.com/DEventLab/d-engine) (a Raft consensus library) can add distributed replication to an existing embedded storage engine — LMDB was chosen because it's a well-known, compact embedded key-value store. + +The broader point: **d-engine adds an optional replicated write path to storage engines and applications.** — LMDB is one possible example. ## Choose your path -| | `d-lmdb` (library) | `d-lmdb-server` (service) | -|---|---|---| -| You are | writing Rust, embedding the store in your process | running any language, want a standalone service | -| You get | in-process calls, no network hop | HTTP+JSON over the network, `docker run` | -| Trade-off | fastest reads/writes, but every replica embeds a Raft node | one network hop, but replicas are decoupled from your app | +| | `d-lmdb` (library) | `d-lmdb-server` (service) | +| --------- | -------------------------------------------------------- | ------------------------------------------------- | +| You are | writing Rust, embedding the store in your process | running any language, want a standalone service | +| You get | in-process calls, no HTTP hop | HTTP+JSON over the network, standalone deployment | +| Trade-off | replicated writes add a network round-trip vs plain LMDB | one extra HTTP hop vs embedded `d-lmdb` | + +→ Rust, embedded: [`d-lmdb/README.md`](./d-lmdb/README.md) +→ HTTP, standalone: [`d-lmdb-server/README.md`](./d-lmdb-server/README.md) -→ Rust, embedded: [`d-lmdb/README.md`](./d-lmdb/README.md) -→ HTTP, Docker: [`d-lmdb-server/README.md`](./d-lmdb-server/README.md) +--- ## What this solves -- **Single point of failure → quorum fault tolerance.** Writes go through Raft; the cluster keeps serving as long as a majority of nodes are up. -- **Cross-machine strong consistency, one extra dependency.** Multiple nodes see the same linearizable write history — no separate coordination service to run. -- **Reads stay fast.** Reads bypass Raft and hit local storage directly, in both the embedded and HTTP form. +- **Replication → High Availability.** d-engine adds an optional replicated write path, turning a single-node embedded store into a quorum-tolerant system. ## What this doesn't solve -Stated plainly, so expectations are set correctly: - -- **Write throughput does not improve — it gets worse.** A local write is one fsync; a replicated write is a network round trip plus a quorum of fsyncs. This trades latency/throughput for availability and consistency. -- **No sharding.** d-engine replicates data, it does not partition it — every node holds the full dataset. -- **Still just a key-value store.** No relational modeling or complex queries. -- **Raft adds real operational surface.** Leader election, membership changes, snapshots, log compaction — none of this exists with plain LMDB. +- **Write throughput will not improve.** A local LMDB write is one fsync; a d-lmdb write pays a network round-trip plus a quorum fsync — this trades latency and throughput for availability and consistency. +- **No sharding.** d-engine replicates data, it doesn't partition it — every node stores the full dataset. If your data doesn't fit on one node's LMDB, d-lmdb won't help. +- **LMDB's own constraints are untouched.** d-lmdb wraps LMDB, it doesn't fix it — map-size limits, single-writer-per-node, and long-lived-reader map growth are all still there. ## Repository layout diff --git a/d-lmdb-server/README.md b/d-lmdb-server/README.md index 8127146..12d09dc 100644 --- a/d-lmdb-server/README.md +++ b/d-lmdb-server/README.md @@ -1,6 +1,12 @@ # d-lmdb-server -HTTP+JSON server wrapping [d-lmdb](../d-lmdb) over a Raft cluster. Docker-deployable — talk to it over plain HTTP, no Rust required. +--- + +d-lmdb-server is for developer who want distributed, LMDB-backed key-value +storage as a standalone service — no Rust, no embedding, no client library. +It wraps [d-lmdb](../d-lmdb) behind HTTP+JSON: writes go through Raft +consensus and are replicated across nodes, reads stay local by default. +Docker-deployable — talk to it over plain HTTP from any language. ## Quick Start @@ -50,8 +56,6 @@ curl "localhost:8080/kv/hello?level=linearizable" Every non-2xx response is JSON: `{"error": "...", "leader_hint": {...}, "trace_id": "..."}` (`leader_hint`/`trace_id` only present when relevant — extractor-level rejections get normalized into this shape too, never plain text). `400` = your input, `503` = not leader / cluster unavailable, retry; `500` = server-side, look up `trace_id` in server logs for detail. Full classification and rationale: `decisions/015-http-error-classification.md` in `d-engine-product-design`. -**Known limitation**: `leader_hint.address` is only reachable by clients on the same network as the cluster (e.g. other containers on the same docker-compose network) — not usable by an external client connecting through mapped/published ports. Not a primary mechanism regardless — see High Availability below. - ## Config (`config.toml`, see `config.example.toml`) ```toml diff --git a/d-lmdb/README.md b/d-lmdb/README.md index 23676fe..c5ccf40 100644 --- a/d-lmdb/README.md +++ b/d-lmdb/README.md @@ -1,27 +1,12 @@ # d-lmdb -![status](https://img.shields.io/badge/status-experimental-orange) -[![CI](https://github.com/deventlab/d-lmdb/actions/workflows/ci.yml/badge.svg)](https://github.com/deventlab/d-lmdb/actions/workflows/ci.yml) -[![codecov](https://codecov.io/gh/deventlab/d-lmdb/graph/badge.svg)](https://codecov.io/gh/deventlab/d-lmdb) -![Static Badge](https://img.shields.io/badge/license-MIT%20%7C%20Apache--2.0-blue) -[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/deventlab/d-lmdb) - -> Built to demonstrate d-engine's replication capabilities. - -## What This Is - -d-lmdb is an implementation that demonstrates one concrete way [d-engine](https://github.com/DEventLab/d-engine) can add distributed replication to an existing embedded storage engine. LMDB was chosen because it is a well-known, compact embedded key-value store. - -The broader point this project is meant to demonstrate: **d-engine can be paired with many kinds of storage engines and applications to add an optional, replicated write path** — LMDB is just the example chosen here. - -d-lmdb wraps LMDB with Raft consensus. Reads stay local and synchronous. Writes go through Raft and become replicated and strongly consistent. The only dependencies beyond d-engine are LMDB and the small set of crates d-engine itself needs. - -d-lmdb aims at distributed _fault tolerance_, not distributed _scaling_. It adds a replicated write path in front of the LMDB you already know — the read path is left untouched. - -See the [repo-level README](../README.md#what-this-solves) for what this project solves and doesn't solve — that applies equally to this library and to `d-lmdb-server`. - --- +d-lmdb is for Rust developers who want LMDB as their storage engine, with +distributed fault tolerance built in. It replicates LMDB across nodes (via +Raft consensus): reads stay local and synchronous, writes are replicated and +strongly consistent. + ## Migration from LMDB Most read code doesn't change: @@ -44,15 +29,6 @@ db.put(b"user:1", b"alice").await?; db.delete(b"user:1").await?; ``` -## Read Consistency - -d-lmdb exposes the tradeoff explicitly: - -| Method | Consistency | When to use | -| ----------------------- | ------------ | ------------------------------------------------------------- | -| `get(key)` | Eventual | Default — local read, may lag the leader by replication delay | -| `get_linearizable(key)` | Linearizable | When you need read-your-writes after a concurrent write | - ## API ```rust @@ -95,6 +71,15 @@ db.cluster_info(); // MembershipSnapshot db.close().await?; // graceful shutdown ``` +## Read Consistency + +d-lmdb exposes the tradeoff explicitly: + +| Method | Consistency | When to use | +| ----------------------- | ------------ | ------------------------------------------------------------- | +| `get(key)` | Eventual | Default — local read, may lag the leader by replication delay | +| `get_linearizable(key)` | Linearizable | When you need read-your-writes after a concurrent write | + ## Architecture d-engine runs **embedded** — no separate server process. Reads bypass consensus entirely and go directly to the local LMDB instance. @@ -126,19 +111,8 @@ data_dir/ - Single storage engine: LMDB only, for both Raft log and KV data - Embedded mode — runs inside your process, no separate server to operate -## Project Status - -d-lmdb is early-stage and evolving. - -The core functionality is implemented and intended for evaluation, experimentation, and community feedback. - -It has not yet been extensively validated in production environments, so APIs and internal behavior may evolve before a stable release. - -Issues, discussions, forks, and pull requests are highly welcome. - -## License - -Licensed under either of [Apache License 2.0](LICENSE-APACHE) or [MIT License](LICENSE-MIT) at your option. +## Failure Behavior -This project is built on [d-engine](https://github.com/DEventLab/d-engine) (Apache-2.0 / MIT) -and [LMDB](https://www.symas.com/lmdb) (OpenLDAP Public License). See [NOTICES](NOTICES) for third-party attributions. +- If no leader is elected yet, writes block until `wait_ready()` completes or timeout. +- If the leader is unreachable, `put()`/`delete()` return an error — the caller must retry. +- Reads never fail due to leader unavailability (they bypass Raft entirely). From 206579b6d3ba2d32ea530eb523d9f86cdce4efe9 Mon Sep 17 00:00:00 2001 From: Joshua Chi <112539+JoshuaChi@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:06:51 +0800 Subject: [PATCH 15/18] fix #2: update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7851cd3..ab6df27 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ ## What This Is -d-lmdb demonstrates one concrete way [d-engine](https://github.com/DEventLab/d-engine) (a Raft consensus library) can add distributed replication to an existing embedded storage engine — LMDB was chosen because it's a well-known, compact embedded key-value store. +d-lmdb demonstrates [d-engine](https://github.com/DEventLab/d-engine)'s core capability: adding distributed, strongly-consistent replication to an existing embedded storage engine. The broader point: **d-engine adds an optional replicated write path to storage engines and applications.** — LMDB is one possible example. From 23d96c3685e4adef00216ad3316b5affe5d7fc4f Mon Sep 17 00:00:00 2001 From: Joshua Chi <112539+JoshuaChi@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:44:18 +0800 Subject: [PATCH 16/18] fix #2: ignore envrc --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4d1cb25..f1b392e 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ target # Developer's real d-lmdb-server config, copied from config.example.toml — may # contain machine-local paths, gets regenerated per-developer, never committed. /d-lmdb-server/config.toml +.envrc From 26aeb55c19c8af5c0768496bb0e9b6c85c1ebbb3 Mon Sep 17 00:00:00 2001 From: Joshua Chi <112539+JoshuaChi@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:53:13 +0800 Subject: [PATCH 17/18] fix #3: fix format --- d-lmdb-server/README.md | 8 +++++--- d-lmdb-server/src/http/error.rs | 4 +++- d-lmdb-server/src/http/error_test.rs | 10 ++++++++-- d-lmdb-server/src/http/kv_test.rs | 10 ++++++++-- d-lmdb-server/src/main.rs | 4 ++-- 5 files changed, 26 insertions(+), 10 deletions(-) diff --git a/d-lmdb-server/README.md b/d-lmdb-server/README.md index 12d09dc..d38fbb2 100644 --- a/d-lmdb-server/README.md +++ b/d-lmdb-server/README.md @@ -13,11 +13,11 @@ Docker-deployable — talk to it over plain HTTP from any language. **Single node:** ```bash -docker build -t d-lmdb-server -f ../Dockerfile .. +docker pull deventlab/d-lmdb:latest cp config.example.toml config.toml docker run -d -p 8080:8080 -e CONFIG=/etc/dlmdb/config.toml \ -v $(pwd)/config.toml:/etc/dlmdb/config.toml:ro -v dlmdb-data:/data \ - d-lmdb-server + deventlab/d-lmdb:latest curl -X PUT localhost:8080/kv/hello -d world curl localhost:8080/kv/hello @@ -26,7 +26,9 @@ curl localhost:8080/kv/hello **3-node HA cluster** (from repo root): ```bash -docker compose up -d --build +docker pull deventlab/d-lmdb:latest +docker tag deventlab/d-lmdb:latest d-lmdb-server:compose +docker compose up -d curl -X PUT localhost:8080/kv/hello -d world # via HAProxy, always finds the leader curl "localhost:8080/kv/hello?level=linearizable" ``` diff --git a/d-lmdb-server/src/http/error.rs b/d-lmdb-server/src/http/error.rs index 33379d9..07b9dc7 100644 --- a/d-lmdb-server/src/http/error.rs +++ b/d-lmdb-server/src/http/error.rs @@ -181,7 +181,9 @@ impl From for HttpError { /// error string. Exact detail lives in the `trace_id`-tagged log line, not the body. fn internal_error_category(err: &d_lmdb::Error) -> &'static str { match err { - d_lmdb::Error::Lmdb(_) | d_lmdb::Error::Storage(_) | d_lmdb::Error::Io(_) => "storage error", + d_lmdb::Error::Lmdb(_) | d_lmdb::Error::Storage(_) | d_lmdb::Error::Io(_) => { + "storage error" + } d_lmdb::Error::Engine(_) => "cluster error", d_lmdb::Error::Client(ClientApiError::Business { code: ErrorCode::Uncategorized, diff --git a/d-lmdb-server/src/http/error_test.rs b/d-lmdb-server/src/http/error_test.rs index 3ff0060..419edb9 100644 --- a/d-lmdb-server/src/http/error_test.rs +++ b/d-lmdb-server/src/http/error_test.rs @@ -165,7 +165,10 @@ fn test_internal_error_category_storage() { #[test] fn test_internal_error_category_configuration() { - assert_eq!(internal_error_category(&Error::Path("x".to_string())), "configuration error"); + assert_eq!( + internal_error_category(&Error::Path("x".to_string())), + "configuration error" + ); } #[test] @@ -239,7 +242,10 @@ fn test_router() -> axum::Router { use axum::routing::get; axum::Router::new() - .route("/plain-text-400", get(|| async { (StatusCode::BAD_REQUEST, "key must not contain '/'") })) + .route( + "/plain-text-400", + get(|| async { (StatusCode::BAD_REQUEST, "key must not contain '/'") }), + ) .route( "/already-json-400", get(|| async { diff --git a/d-lmdb-server/src/http/kv_test.rs b/d-lmdb-server/src/http/kv_test.rs index 90cc8ec..e27a68f 100644 --- a/d-lmdb-server/src/http/kv_test.rs +++ b/d-lmdb-server/src/http/kv_test.rs @@ -12,12 +12,18 @@ fn test_absent_level_defaults_to_eventual() { #[test] fn test_level_eventual() { - assert_eq!(parse("level=eventual").unwrap().level, ConsistencyLevel::Eventual); + assert_eq!( + parse("level=eventual").unwrap().level, + ConsistencyLevel::Eventual + ); } #[test] fn test_level_linearizable() { - assert_eq!(parse("level=linearizable").unwrap().level, ConsistencyLevel::Linearizable); + assert_eq!( + parse("level=linearizable").unwrap().level, + ConsistencyLevel::Linearizable + ); } #[test] diff --git a/d-lmdb-server/src/main.rs b/d-lmdb-server/src/main.rs index 8b650f1..14f3ece 100644 --- a/d-lmdb-server/src/main.rs +++ b/d-lmdb-server/src/main.rs @@ -46,8 +46,8 @@ struct HttpSection { fn read_http_listen_address(config_path: &Path) -> std::io::Result { let text = std::fs::read_to_string(config_path)?; - let root: RootConfig = - toml::from_str(&text).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + let root: RootConfig = toml::from_str(&text) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; Ok(root.http.listen_address) } From ff7654dfb96406295ee413e5978fe6a41c7ceb47 Mon Sep 17 00:00:00 2001 From: Joshua Chi <112539+JoshuaChi@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:07:41 +0800 Subject: [PATCH 18/18] =?UTF-8?q?fix=20#3:=20review=20fixes=20=E2=80=94=20?= =?UTF-8?q?tests,=20docs,=20CI,=20Docker=20CVEs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - kv_test: add handler integration tests (PUT/GET/DELETE via real DLmdb) - d-lmdb/README: fix API example (DLmdbConfig is pub(crate), won't compile) - d-lmdb-server/README: reference published Docker image in Quick Start - ci: push tested image (no rebuild); fix shell injection in release tag - smoke-test: tear down compose on failure - Dockerfile: apt-get upgrade to patch base image CVEs --- .github/workflows/docker-release.yml | 35 ++++--- Cargo.lock | 1 + Dockerfile | 1 + compose/smoke-test.sh | 1 + d-lmdb-server/Cargo.toml | 1 + d-lmdb-server/README.md | 3 + d-lmdb-server/src/http/kv_test.rs | 150 ++++++++++++++++++++++++++- d-lmdb/README.md | 6 +- 8 files changed, 177 insertions(+), 21 deletions(-) diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 84012b5..16c9bbc 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -52,19 +52,20 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_ACCESS_TOKEN }} - - name: Push by digest (smoke test passed — cached layers, no rebuild) + - name: Push tested image by digest (no rebuild) id: push - uses: docker/build-push-action@v6 - with: - context: . - platforms: ${{ matrix.platform }} - outputs: type=image,name=docker.io/${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true + run: | + TARGET="docker.io/${{ env.IMAGE }}:${{ github.sha }}" + docker tag d-lmdb-server:compose "$TARGET" + docker push "$TARGET" + DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "$TARGET" | grep -o 'sha256:[a-f0-9]\{64\}') + echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" - name: Export digest run: | mkdir -p /tmp/digests - digest="${{ steps.push.outputs.digest }}" - touch "/tmp/digests/${digest#sha256:}" + DIGEST="${{ steps.push.outputs.digest }}" + touch "/tmp/digests/${DIGEST#sha256:}" - name: Upload digest uses: actions/upload-artifact@v4 @@ -81,9 +82,10 @@ jobs: needs: build-test-push steps: - name: Strip 'v' prefix from release tag + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} run: | - VERSION=${{ github.event.release.tag_name }} - echo "STRIPPED_VERSION=${VERSION#v}" >> "$GITHUB_ENV" + printf 'STRIPPED_VERSION=%s\n' "${RELEASE_TAG#v}" >> "$GITHUB_ENV" - name: Download digests uses: actions/download-artifact@v4 @@ -103,12 +105,15 @@ jobs: - name: Create and push manifest list working-directory: /tmp/digests + env: + IMAGE: ${{ env.IMAGE }} run: | - # shellcheck disable=SC2046 # intentional word-splitting docker buildx imagetools create \ - -t docker.io/${{ env.IMAGE }}:${{ env.STRIPPED_VERSION }} \ - -t docker.io/${{ env.IMAGE }}:latest \ - $(printf 'docker.io/${{ env.IMAGE }}@sha256:%s ' *) + -t "docker.io/${IMAGE}:${STRIPPED_VERSION}" \ + -t "docker.io/${IMAGE}:latest" \ + $(printf 'docker.io/%s@sha256:%s ' "${IMAGE}" *) - name: Verify manifest - run: docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:${{ env.STRIPPED_VERSION }} + env: + IMAGE: ${{ env.IMAGE }} + run: docker buildx imagetools inspect "docker.io/${IMAGE}:${STRIPPED_VERSION}" diff --git a/Cargo.lock b/Cargo.lock index 893b601..50a1bfe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -569,6 +569,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", + "tempfile", "tokio", "toml", "tower", diff --git a/Dockerfile b/Dockerfile index d1184e7..2bfc1ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,6 +27,7 @@ FROM debian:bookworm-slim AS runtime # ownership — see docker-entrypoint.sh. ca-certificates: TLS roots, needed if # the Raft cluster ever talks over TLS. RUN apt-get update \ + && apt-get upgrade -y \ && apt-get install -y --no-install-recommends gosu ca-certificates \ && rm -rf /var/lib/apt/lists/* diff --git a/compose/smoke-test.sh b/compose/smoke-test.sh index 6697a76..e931cfa 100755 --- a/compose/smoke-test.sh +++ b/compose/smoke-test.sh @@ -28,6 +28,7 @@ fail() { echo "FAIL: $1"; cleanup_on_fail; exit 1; } cleanup_on_fail() { echo "--- container logs on failure ---" $COMPOSE logs --tail=50 + $COMPOSE down -v || true } command -v jq >/dev/null || { echo "jq is required (brew install jq)"; exit 1; } diff --git a/d-lmdb-server/Cargo.toml b/d-lmdb-server/Cargo.toml index 947ae06..bc5a887 100644 --- a/d-lmdb-server/Cargo.toml +++ b/d-lmdb-server/Cargo.toml @@ -22,4 +22,5 @@ uuid = { version = "1", features = ["v4"] } [dev-dependencies] serde_json = "1" serde_urlencoded = "0.7" +tempfile = "3" tower = { version = "0.5", features = ["util"] } diff --git a/d-lmdb-server/README.md b/d-lmdb-server/README.md index d38fbb2..6eaab18 100644 --- a/d-lmdb-server/README.md +++ b/d-lmdb-server/README.md @@ -19,6 +19,7 @@ docker run -d -p 8080:8080 -e CONFIG=/etc/dlmdb/config.toml \ -v $(pwd)/config.toml:/etc/dlmdb/config.toml:ro -v dlmdb-data:/data \ deventlab/d-lmdb:latest +sleep 3 curl -X PUT localhost:8080/kv/hello -d world curl localhost:8080/kv/hello ``` @@ -28,7 +29,9 @@ curl localhost:8080/kv/hello ```bash docker pull deventlab/d-lmdb:latest docker tag deventlab/d-lmdb:latest d-lmdb-server:compose +docker compose down -v 2>/dev/null # clean up any previous run docker compose up -d +sleep 10 # wait for leader election + HAProxy health checks curl -X PUT localhost:8080/kv/hello -d world # via HAProxy, always finds the leader curl "localhost:8080/kv/hello?level=linearizable" ``` diff --git a/d-lmdb-server/src/http/kv_test.rs b/d-lmdb-server/src/http/kv_test.rs index e27a68f..7fa5b0f 100644 --- a/d-lmdb-server/src/http/kv_test.rs +++ b/d-lmdb-server/src/http/kv_test.rs @@ -1,5 +1,18 @@ +use std::sync::Arc; + +use axum::Router; +use axum::body::Body; +use axum::http::Request; +use axum::http::StatusCode; +use d_lmdb::DLmdb; +use tempfile::TempDir; +use tower::ServiceExt; + use super::ConsistencyLevel; use super::ConsistencyParam; +use super::route; + +// ── ConsistencyLevel query parameter parsing ───────────────────────────────── fn parse(query: &str) -> Result { serde_urlencoded::from_str(query) @@ -33,8 +46,139 @@ fn test_level_lease() { #[test] fn test_unknown_level_is_rejected() { - // This is what makes the "forgot to wire up a new level" bug class impossible: - // the enum is exhaustively matched in get_handler, and anything that doesn't - // deserialize into a known variant fails right here, not silently falls through. assert!(parse("level=bogus").is_err()); } + +// ── Handler tests (real DLmdb in temp dir) ─────────────────────────────────── + +/// Single-node DLmdb + router, ready for test requests. +async fn setup() -> (TempDir, Router) { + let tmp = TempDir::new().expect("temp dir"); + let db = DLmdb::open(tmp.path()).await.expect("open db"); + db.wait_ready(std::time::Duration::from_secs(5)).await.expect("ready"); + let app = Router::new().route("/kv/{key}", route()).with_state(Arc::new(db)); + (tmp, app) +} + +async fn put( + app: &Router, + key: &str, + value: &str, +) -> StatusCode { + let req = Request::builder() + .method("PUT") + .uri(format!("/kv/{key}")) + .body(Body::from(value.to_string())) + .unwrap(); + app.clone().oneshot(req).await.unwrap().status() +} + +async fn get( + app: &Router, + key: &str, + query: &str, +) -> (StatusCode, String) { + let uri = if query.is_empty() { + format!("/kv/{key}") + } else { + format!("/kv/{key}?{query}") + }; + let req = Request::builder().method("GET").uri(&uri).body(Body::empty()).unwrap(); + let resp = app.clone().oneshot(req).await.unwrap(); + let status = resp.status(); + let body_bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024).await.unwrap(); + let body_str = String::from_utf8_lossy(&body_bytes).to_string(); + (status, body_str) +} + +async fn delete( + app: &Router, + key: &str, +) -> StatusCode { + let req = Request::builder() + .method("DELETE") + .uri(format!("/kv/{key}")) + .body(Body::empty()) + .unwrap(); + app.clone().oneshot(req).await.unwrap().status() +} + +// ── PUT ────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_put_new_key_returns_204_no_content() { + let (_tmp, app) = setup().await; + assert_eq!(put(&app, "hello", "world").await, StatusCode::NO_CONTENT); +} + +#[tokio::test] +async fn test_put_overwrites_existing_key_and_returns_204() { + let (_tmp, app) = setup().await; + put(&app, "key", "v1").await; + assert_eq!(put(&app, "key", "v2").await, StatusCode::NO_CONTENT); + let (status, body) = get(&app, "key", "").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body, "v2"); +} + +// ── GET ────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_get_returns_value_for_existing_key() { + let (_tmp, app) = setup().await; + put(&app, "greeting", "hello").await; + let (status, body) = get(&app, "greeting", "").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body, "hello"); +} + +#[tokio::test] +async fn test_get_missing_key_returns_404() { + let (_tmp, app) = setup().await; + let (status, _body) = get(&app, "nonexistent", "").await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_get_linearizable_returns_value() { + let (_tmp, app) = setup().await; + put(&app, "linear-key", "val").await; + let (status, body) = get(&app, "linear-key", "level=linearizable").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body, "val"); +} + +#[tokio::test] +async fn test_get_lease_returns_value() { + let (_tmp, app) = setup().await; + put(&app, "lease-key", "val").await; + let (status, body) = get(&app, "lease-key", "level=lease").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body, "val"); +} + +#[tokio::test] +async fn test_get_returns_empty_value_for_empty_body_put() { + let (_tmp, app) = setup().await; + put(&app, "empty-value", "").await; + let (status, body) = get(&app, "empty-value", "").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body, ""); +} + +// ── DELETE ─────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_delete_existing_key_returns_204() { + let (_tmp, app) = setup().await; + put(&app, "del-key", "x").await; + assert_eq!(delete(&app, "del-key").await, StatusCode::NO_CONTENT); + // Verify it's actually gone. + assert_eq!(get(&app, "del-key", "").await.0, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_delete_missing_key_returns_204_idempotent() { + let (_tmp, app) = setup().await; + assert_eq!(delete(&app, "never-existed").await, StatusCode::NO_CONTENT); +} diff --git a/d-lmdb/README.md b/d-lmdb/README.md index c5ccf40..8ad4bce 100644 --- a/d-lmdb/README.md +++ b/d-lmdb/README.md @@ -32,9 +32,9 @@ db.delete(b"user:1").await?; ## API ```rust -// Open a node (single-node or cluster, depending on config) -let db = DLmdb::open(DLmdbConfig::new("./data")).await?; -let db = DLmdb::open_from_file("config.toml").await?; // multi-node: config declares peers +// Open a node +let db = DLmdb::open("./data").await?; // single-node: just a data directory +let db = DLmdb::open_from_file("config.toml").await?; // multi-node: config declares peers db.wait_ready(Duration::from_secs(5)).await?; // block until a leader is elected // Reads — sync, no await, bypass Raft entirely