Skip to content

feat #3: Add d-lmdb-server — Docker image, HTTP API, and 3-node HA compose cluster - #4

Merged
JoshuaChi merged 18 commits into
mainfrom
feature/2-docker-image
Jul 29, 2026
Merged

JoshuaChi merged 18 commits into
mainfrom
feature/2-docker-image

Conversation

@JoshuaChi

@JoshuaChi JoshuaChi commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

What Does This PR Do?

Restructures d-lmdb into a Cargo workspace, adds d-lmdb-server (HTTP+JSON API wrapping d-lmdb), and ships it as a multi-arch Docker image with a docker-compose 3-node HA cluster and automated smoke test. A visibility release — d-lmdb stays experimental, but now anyone can try it without writing Rust.

Type:


Why Is This Needed?

Closes #3. d-lmdb is an embedded library, which excludes developers who don't write Rust. This PR adds a standalone service (d-lmdb-server) so anyone can evaluate d-lmdb's distributed LMDB semantics over plain HTTP — just docker compose up.


Checklist

Required:

  • make check (fmt + clippy + deny) passes
  • make test passes
  • Added tests for new code — error_test.rs (292 lines, full error-to-JSON mapping), kv_test.rs (34 lines), db_test.rs (140 lines)
  • Commits squashed to 1-2 logical units — currently 16; plan to squash into 2 before merge

If changing APIs:

  • Updated relevant docs — new root README.md (library vs service split), d-lmdb-server/README.md (API reference, config guide, HA setup)
  • Explained why complexity is justified — server is a thin HTTP wrapper (~600 lines); no middleware bloat

Testing

How tested:

  • Unit tests: HTTP error classification (all variants → JSON, including extractor-level rejections), KV endpoint
  • Integration tests: compose/smoke-test.sh — brings up 3 nodes + HAProxy, runs PUT/GET/DELETE through the LB, kills the leader container, confirms the cluster recovers and the LB re-routes with zero client-side changes
  • Manual testing: Docker build + docker compose up verified on macOS (arm64)

Does This Fit d-lmdb's Scope?

  • Stays within fault-tolerance, not scaling (no sharding, no multi-writer)
  • Keeps implementation simple — single-file HTTP routes, no middleware framework
  • Doesn't bloat the public API surface — d-lmdb library API is unchanged; d-lmdb-server is a separate crate

Reviewer Notes

  1. Commit count: 16 commits will be squashed to 2 before merge — (a) workspace + d-lmdb-server + fixes, (b) Docker + compose + CI. Kept granular now to ease review.
  2. d-engine branch dependency: d-lmdb/Cargo.toml pins d-engine to fix/425-leader-hint branch. This is temporary — needs to switch to a released version once that fix lands upstream.
  3. arm64 smoke test in CI: Runs under QEMU — verifies correctness, not real-hardware timing.
  4. Error handling design: d-lmdb-server/src/http/error.rs follows the classification in d-engine-product-design/decisions/015-http-error-classification.md. Every non-2xx response is JSON, never plain text — even extractor-level rejections are normalized.

Estimated review complexity:

  • Quick (< 100 lines)
  • Medium (~1,200 lines of new production code)
  • Deep (> 300 lines)

Summary by CodeRabbit

  • New Features

    • Added a standalone HTTP service with key-value operations, configurable read consistency, and health endpoints.
    • Added Docker and Compose support for running a persistent three-node cluster behind HAProxy.
    • Added automatic leader-aware routing, failover, and multi-platform image publishing.
    • Added TTL-aware value handling, size validation, compare-and-swap, scanning, and structured JSON errors.
  • Documentation

    • Added setup, configuration, API, architecture, and deployment guidance for the library and server.
  • Tests

    • Added coverage for storage behavior, expiration, error handling, clustering, and failover smoke tests.

JoshuaChi added 16 commits July 27, 2026 15:50
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
- 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)
… path

- 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
Needed for leader_hint passthrough in the upcoming HTTP error mapping
(d-engine main doesn't have the #425 leader_hint fix yet).
- 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
- 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.
address was the Raft peer address (e.g. node3:9081), unusable by
HTTP clients. Now swaps in this node's own HTTP port.
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.
Relative paths (./data) crashed in the container — non-root user has
no write permission at that cwd. Verified fix with a real docker run.
Moved "what this solves/doesn't solve" here from d-lmdb/README.md —
it's a project-level claim, not library-specific.
@coderabbitai

coderabbitai Bot commented Jul 28, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@JoshuaChi, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 40 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9aafeffd-f1b7-421b-b547-b7833b48b675

📥 Commits

Reviewing files that changed from the base of the PR and between 23d96c3 and ff7654d.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (10)
  • .github/workflows/docker-release.yml
  • Dockerfile
  • compose/smoke-test.sh
  • d-lmdb-server/Cargo.toml
  • d-lmdb-server/README.md
  • d-lmdb-server/src/http/error.rs
  • d-lmdb-server/src/http/error_test.rs
  • d-lmdb-server/src/http/kv_test.rs
  • d-lmdb-server/src/main.rs
  • d-lmdb/README.md
📝 Walkthrough

Walkthrough

Changes

The repository is converted into a Cargo workspace containing d-lmdb and a new d-lmdb-server crate. It adds LMDB persistence, TTL-aware reads, an HTTP API, Docker/Compose deployment with HAProxy, smoke tests, and a multi-platform Docker release workflow.

d-lmdb storage and API

Layer / File(s) Summary
Library contracts and wire formats
Cargo.toml, d-lmdb/Cargo.toml, d-lmdb/src/config.rs, d-lmdb/src/error.rs, d-lmdb/src/wire/*
Defines workspace and crate metadata, configuration loading, public errors, tagged raw/TTL values, and wire-format tests.
LMDB state machine and Raft storage
d-lmdb/src/state_machine.rs, d-lmdb/src/storage_engine.rs, d-lmdb/src/*_test.rs
Adds LMDB-backed state-machine operations, scans, snapshots, Raft log persistence, hard-state storage, and conformance tests.
TTL decoding and client behavior
d-lmdb/src/db.rs, d-lmdb/src/db_test.rs, d-lmdb/tests/integration.rs
Centralizes value decoding, removes expired point-read keys, filters expired scan entries, exposes value limits, and tests client behavior.

HTTP service and deployment

Layer / File(s) Summary
HTTP service and response contracts
d-lmdb-server/src/*, d-lmdb-server/README.md, d-lmdb-server/config.example.toml
Adds CLI startup, health endpoints, KV operations, consistency levels, request limits, and structured HTTP error responses.
Containerized cluster and failover validation
Dockerfile, docker-entrypoint.sh, docker-compose.yml, compose/*
Adds a non-root runtime image, three-node Compose topology, HAProxy routing, persistent volumes, and failover smoke tests.
Multi-platform release publishing
.github/workflows/docker-release.yml
Builds and tests amd64/arm64 images, publishes digests, creates versioned and latest manifests, and verifies them.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant HAProxy
  participant d-lmdb-server
  participant RaftCluster
  Client->>HAProxy: Send KV request
  HAProxy->>d-lmdb-server: Route read or leader-required request
  d-lmdb-server->>RaftCluster: Read or apply operation
  RaftCluster-->>d-lmdb-server: Return value or leader response
  d-lmdb-server-->>Client: Return HTTP response
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title names the main change: adding d-lmdb-server with Docker, HTTP API, and HA compose setup.
Linked Issues check ✅ Passed The PR delivers a runnable Docker image plus HTTP service, compose cluster, docs, and smoke tests matching issue #3.
Out of Scope Changes check ✅ Passed The changes stay focused on the Dockerized server release and supporting workspace/docs/config updates; no clear unrelated work stands out.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/2-docker-image

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
d-lmdb-server/config.example.toml (1)

1-13: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Document that the flat config fields are not read directly by the library.

DLmdbConfig::from_file only consumes [lmdb].data_dir, then appends raft and lmdb subpaths from there. [cluster].db_root_dir/log_dir are used by the server config path, so this example misleadingly presents incompatible file schemas in a single TOML. Split the examples/constraints or make the server only consume fields compatible with DLmdbConfig.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb-server/config.example.toml` around lines 1 - 13, Update the
configuration example to avoid presenting incompatible schemas together:
document that DLmdbConfig::from_file reads only [lmdb].data_dir and derives the
raft/lmdb subpaths, while [cluster].db_root_dir and log_dir belong to the server
configuration path. Split the examples or clearly separate the applicable fields
so users do not assume the library reads the flat cluster fields directly.
🧹 Nitpick comments (11)
d-lmdb/tests/integration.rs (1)

96-127: 📐 Maintainability & Code Quality | 🔵 Trivial

Good TTL coverage; the linearizable read path has none.

db_test.rs:71-78 documents that get_linearizable previously returned the raw wire envelope — a bug that unit tests on decode_live_value alone wouldn't have caught, since the defect was in the wiring, not the decoder. A single put → get_linearizable roundtrip here (and one for get_lease) would close that gap end to end.

Happy to add them.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/tests/integration.rs` around lines 96 - 127, Add integration tests
alongside the existing TTL tests covering end-to-end put-to-read roundtrips
through get_linearizable and get_lease. Verify each method returns the stored
user value rather than the raw wire envelope, using the established database
setup and cleanup pattern.
d-lmdb/src/db_test.rs (1)

71-140: 📐 Maintainability & Code Quality | 🔵 Trivial

Thorough coverage of decode_live_value; its scan-path twin has none.

decode_for_scan reimplements the same TTL/tag logic with different failure semantics (drops corrupt entries instead of erroring) and backs all four public scan APIs, so the two can drift silently. The boundary case (expires_at == now) is the one most likely to diverge.

Want me to add mirrored cases for decode_for_scan?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/src/db_test.rs` around lines 71 - 140, Add mirrored tests for
decode_for_scan covering raw and unexpired TTL payload decoding, exact and past
expiry, empty payload preservation, invalid tag bytes, empty input, and
truncated TTL headers. Exercise the public scan APIs backed by decode_for_scan
where appropriate, and verify the scan-specific behavior of dropping corrupt
entries while preserving the expires_at == now boundary semantics.
d-lmdb/src/db.rs (1)

433-442: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

unix_now_secs() is called once per scanned entry.

decode_for_scan is invoked for every candidate in the scan loop, so a large scan makes a clock call per key, and entries are evaluated against slightly different "now" values within a single scan — the boundary case can include one key and exclude its neighbour inconsistently. decode_live_value already takes now_secs as a parameter; do the same here and capture it once per scan via a closure.

♻️ Proposed refactor
-fn decode_for_scan(raw: &[u8]) -> Option<Vec<u8>> {
+fn decode_for_scan_at(
+    raw: &[u8],
+    now_secs: u64,
+) -> Option<Vec<u8>> {
     match Value::decode(raw) {
         Ok(Value::Raw(payload)) => Some(payload.to_vec()),
         Ok(Value::Ttl {
             expires_at,
             payload,
-        }) if expires_at > unix_now_secs() => Some(payload.to_vec()),
+        }) if expires_at > now_secs => Some(payload.to_vec()),
         _ => None,
     }
 }

Call sites become e.g. let now = unix_now_secs(); ... Some(move |raw: &[u8]| decode_for_scan_at(raw, now)).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/src/db.rs` around lines 433 - 442, Refactor decode_for_scan to accept
a captured now_secs value, such as via a decode_for_scan_at helper, and remove
its per-entry unix_now_secs() call. In the scan loop, capture unix_now_secs()
once and pass it through the existing closure used to decode candidates,
ensuring every entry is evaluated against the same timestamp.
d-lmdb/src/storage_engine_test.rs (1)

27-35: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Both conformance-suite builders hand out a fixed path with a no-op cleanup. Each builder holds one TempDir and derives a single fixed subdirectory, so repeated build() calls reopen the same LMDB environment and inherit prior state — and LMDB generally refuses a second in-process open of the same path.

  • d-lmdb/src/storage_engine_test.rs#L27-L35: derive a unique subdirectory per build() call (e.g. an incrementing counter under temp_dir) rather than the fixed temp_dir/raft.
  • d-lmdb/src/state_machine_test.rs#L29-L40: apply the same per-call unique subdirectory instead of the fixed temp_dir/lmdb_sm.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/src/storage_engine_test.rs` around lines 27 - 35, The
conformance-suite builders reuse fixed LMDB paths and do not clean them up. In
d-lmdb/src/storage_engine_test.rs lines 27-35, update the builder’s build method
to generate a unique subdirectory per call using an incrementing counter under
temp_dir, while leaving cleanup behavior unchanged. Apply the same per-call
unique-subdirectory change to the builder build method in
d-lmdb/src/state_machine_test.rs lines 29-40, replacing the fixed
temp_dir/lmdb_sm path.
d-lmdb/src/config.rs (1)

12-25: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

map_size_gb holds bytes, and the Deserialize derive here is unreachable.

state_machine.rs passes config.map_size_gb straight into EnvOpenOptions::map_size(...), and both constructors store bytes (new writes 10 * 1024^3, from_file multiplies by 1024^3). The _gb suffix invites a future caller to pass 10 and get a 10-byte map. Separately, file loading goes through LmdbFileSection, so the Deserialize derive on DLmdbConfig is never exercised — and if it ever were, max_key_bytes/max_value_bytes have no serde defaults while LmdbFileSection does, so the two paths would disagree.

♻️ Suggested rename + drop the unused derive
-#[derive(Deserialize)]
 pub(crate) struct DLmdbConfig {
     /// Root directory for all persisted data (Raft WAL + LMDB state machine)
     pub(crate) data_dir: PathBuf,
     /// LMDB memory-map size in bytes. Must be larger than the total dataset.
     /// Default: 10 GiB — adjust for your expected data volume.
-    #[serde(default = "default_map_size_gb")]
-    pub(crate) map_size_gb: usize,
+    pub(crate) map_size_bytes: usize,

Update the two constructors and state_machine.rs accordingly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/src/config.rs` around lines 12 - 25, Rename DLmdbConfig.map_size_gb to
a bytes-based name such as map_size_bytes, update both configuration
constructors and the state_machine.rs EnvOpenOptions::map_size call to use it,
and remove the unused Deserialize derive and serde default attribute from
DLmdbConfig. Preserve the existing byte values and LmdbFileSection
deserialization/default behavior.
d-lmdb/src/state_machine_test.rs (2)

240-256: 📐 Maintainability & Code Quality | 🔵 Trivial

No coverage for scan_prefix_bounded's after cursor.

The pagination parameter is untested, which is how the unclamped-after behavior I flagged at state_machine.rs:302-307 stays invisible. A case passing an after that sorts before the prefix would pin the intended semantics.

Happy to draft those cases if useful.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/src/state_machine_test.rs` around lines 240 - 256, Extend
test_scan_prefix_returns_matching_keys with coverage for scan_prefix_bounded’s
after cursor, including an after key that sorts before the requested user:
prefix. Assert that pagination remains correctly bounded to matching prefix
entries and preserves the expected results, pinning the intended clamped-after
behavior.

189-202: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Test name promises a transformation that isn't exercised.

only_long_values returns the value unchanged, so this only proves exclusion. Either assert the returned payload to lock in the "filter output is what's stored in the result" contract, or drop "and_transforms" from the name.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/src/state_machine_test.rs` around lines 189 - 202, Update
test_scan_all_with_filter_excludes_and_transforms to assert the returned entry
payload as well as its key, using a filter output that demonstrates
transformation or verifying the expected transformed value; otherwise rename the
test to remove “and_transforms.”
d-lmdb/src/state_machine.rs (2)

528-534: 🗄️ Data Integrity & Integration | 🔵 Trivial

Snapshot metadata is never persisted — snapshot_metadata() returns None after every restart.

The TODO leaves persist_last_snapshot_metadata as an in-memory-only update, so a restarted node reports no snapshot even when data.mdb exists on disk. Depending on how d-engine uses this, the node may re-request a full snapshot from the leader.

Want me to open an issue to track persisting this into meta_db?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/src/state_machine.rs` around lines 528 - 534, Implement durable
persistence in persist_last_snapshot_metadata by serializing the supplied
SnapshotMetadata and storing it in meta_db under the "snapshot_meta" key before
or alongside update_last_snapshot_metadata. Ensure snapshot_metadata() reads and
deserializes the same key so the metadata survives restarts, while preserving
the existing EngineError propagation.

118-155: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Extract the repeated map_err(|e| EngineError::Fatal(e.to_string())).

It appears ten times in this function. A local fn fatal<E: Display>(e: E) -> EngineError (alongside the existing lmdb_err helper) removes the noise and makes the actual LMDB calls readable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/src/state_machine.rs` around lines 118 - 155, Add a local fatal
error-conversion helper beside the existing lmdb_err helper, accepting any
Display error and returning EngineError::Fatal with its string. Replace every
repeated map_err closure in this function, including the snapshot opening,
transaction, database, iteration, put, clear, and commit operations, with the
helper while preserving existing error propagation.
d-lmdb-server/src/http/mod.rs (2)

20-36: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider graceful shutdown and a request timeout.

axum::serve(listener, app).await has no .with_graceful_shutdown(...) and there's no TimeoutLayer on the stack. On docker stop/rolling restarts, in-flight requests can be cut off abruptly rather than allowed to finish; a slow/stalled client can also hold a connection indefinitely.

♻️ Suggested addition
     let listener = tokio::net::TcpListener::bind(addr).await?;
-    axum::serve(listener, app).await
+    axum::serve(listener, app)
+        .with_graceful_shutdown(shutdown_signal())
+        .await
+}
+
+async fn shutdown_signal() {
+    let _ = tokio::signal::ctrl_c().await;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb-server/src/http/mod.rs` around lines 20 - 36, Update serve to add a
request TimeoutLayer to the existing router middleware stack and run axum::serve
with graceful shutdown triggered by the service’s shutdown signal, preserving
completion of in-flight requests during termination while bounding slow or
stalled requests.

20-36: 🔒 Security & Privacy | 🔵 Trivial

No authentication on any route, including PUT/DELETE.

Every route (/kv/{key}, /status, /primary, /replica) is unauthenticated. Given the PR's stated scope as a visibility/evaluation release rather than a feature-complete product, this is likely intentional for now — but worth calling out explicitly in docs/roadmap so operators don't expose this directly to untrusted networks.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb-server/src/http/mod.rs` around lines 20 - 36, Document in the roadmap
or operator-facing documentation that all routes registered by serve, including
/kv/{key}, /status, /primary, and /replica, currently have no authentication and
must not be exposed directly to untrusted networks; preserve the existing route
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/docker-release.yml:
- Around line 55-62: Update the workflow step identified by “Push by digest” to
stop rebuilding with docker/build-push-action. After the smoke test of
d-lmdb-server:compose, retag the validated local platform image, push that tag,
and export the digest of the pushed image for the existing manifest-creation
flow.
- Around line 83-86: Update the release workflow steps around “Strip 'v' prefix
from release tag” and the Docker build/push commands to avoid interpolating
GitHub expressions into shell source: pass github.event.release.tag_name through
the step environment, derive STRIPPED_VERSION using quoted shell-variable
expansion, and reference the derived value through a safely exported environment
variable rather than `${{ env.STRIPPED_VERSION }}` inside run blocks.

In `@compose/smoke-test.sh`:
- Around line 25-31: Update cleanup_on_fail, which is invoked by fail(), to tear
down the Compose stack with docker compose down -v after collecting the failure
logs. Ensure the teardown runs on assertion failures so containers, ports, and
persisted volumes are removed before fail() exits.

In `@d-lmdb-server/src/main.rs`:
- Line 1: Run cargo fmt --all using the project’s rustfmt configuration,
ensuring the formatting updates are applied to read_http_listen_address,
test_level_eventual, test_level_linearizable, internal_error_category,
test_internal_error_category_configuration, and the test_router route
definitions without changing behavior.

In `@d-lmdb/README.md`:
- Around line 35-38: Update the README quick-start example to call the
path-based DLmdb::open API with "./data" directly instead of passing
DLmdbConfig::new("./data"), while retaining DLmdb::open_from_file("config.toml")
only as the multi-node configuration example.

In `@d-lmdb/src/config.rs`:
- Line 51: Update the map_size_gb conversion in the configuration construction
to use checked multiplication for the GiB-to-bytes factor and fail loudly when
overflow occurs, rather than relying on unchecked arithmetic. Preserve the
existing converted byte value for valid inputs and the surrounding root.lmdb
configuration flow.

In `@d-lmdb/src/error.rs`:
- Around line 27-33: Update the KeyTooLarge and ValueTooLarge error variants to
carry the configured limit alongside the actual size, and format messages from
those values instead of hardcoded limits. Modify validate_key and validate_value
to pass max_key_bytes/max_value_bytes when constructing errors, then update all
matching patterns in the integration tests and other affected callers to handle
the expanded variants.

In `@d-lmdb/src/state_machine.rs`:
- Around line 180-188: Change the expiry-reaping flow around
db.rs::decode_and_reap so reads no longer call delete_local synchronously. Queue
expired keys to a background reaper, batch pending deletions into a single LMDB
write transaction, and retain the existing read response behavior while allowing
reaping to run independently of get/exists/get_multi and apply_chunk.
- Around line 645-648: The u64_from_bytes helper currently masks malformed
persisted metadata by defaulting to zero. Change the decoding path used by new
for last_applied_index and last_applied_term to validate the byte length and
return an error when it is not exactly 8 bytes, propagating that error from new
instead of opening with zero.
- Around line 324-333: Update the range setup in the method containing
`empty_key_guard` so an empty caller-supplied `end` is handled before
constructing `Bound::Excluded(end)` or invoking `self.kv_db.range`. Preserve the
existing empty-result behavior provided by `empty_key_guard`, and keep the
current `start` handling and non-empty range path unchanged.
- Around line 49-51: Remove the unused entry_terms field from the state machine
and delete its initialization, per-entry insertion, and reset/snapshot-clearing
logic. Update the relevant constructor and apply/reset or snapshot-restore paths
while preserving all other state-machine behavior.
- Around line 302-307: Clamp the start bound in the scan-prefix flow before
calling scan_range_core: when after sorts before prefix, use
Bound::Included(prefix); otherwise preserve the existing exclusive after cursor.
Update the logic around next_prefix and scan_range_core so results never include
keys outside the requested prefix, while retaining current limit and filter
behavior.
- Around line 588-592: Update generate_snapshot_data() to compute the checksum
from the written data.mdb contents before constructing SnapshotMetadata,
replacing the hardcoded zero digest with the repository’s established 32-byte
hashing utility and preserving the resulting digest in
SnapshotMetadata.checksum.

In `@d-lmdb/src/storage_engine.rs`:
- Around line 207-219: Update the last_index recomputation around log_db.last()
to use the persisted KEY_PURGE_BOUNDARY index when the log is empty instead of
defaulting to zero. In d-lmdb/src/storage_engine.rs:207-219, preserve the
existing tail-index path and fall back to the boundary value; in
d-lmdb/src/storage_engine.rs:242-248, delete KEY_PURGE_BOUNDARY in the same
write transaction that clears log_db so the reset last_index and persisted
boundary remain synchronized.
- Line 28: Replace the hardcoded WAL_MAP_SIZE constant with a configurable value
exposed through DLmdbConfig alongside map_size_gb, and use that setting when
creating the Raft log environment. Preserve the existing default behavior by
defining an appropriate default map size, and ensure persist_entries receives
the configured capacity instead of the fixed 128 MiB limit.
- Around line 102-123: Move the synchronous LMDB transaction and durable
wtxn.commit work in persist_entries into tokio::task::spawn_blocking, following
the existing d-lmdb pattern. Preserve empty-input handling, entry persistence,
max_index tracking, error conversion, and update last_index only after the
blocking operation completes successfully.

---

Outside diff comments:
In `@d-lmdb-server/config.example.toml`:
- Around line 1-13: Update the configuration example to avoid presenting
incompatible schemas together: document that DLmdbConfig::from_file reads only
[lmdb].data_dir and derives the raft/lmdb subpaths, while [cluster].db_root_dir
and log_dir belong to the server configuration path. Split the examples or
clearly separate the applicable fields so users do not assume the library reads
the flat cluster fields directly.

---

Nitpick comments:
In `@d-lmdb-server/src/http/mod.rs`:
- Around line 20-36: Update serve to add a request TimeoutLayer to the existing
router middleware stack and run axum::serve with graceful shutdown triggered by
the service’s shutdown signal, preserving completion of in-flight requests
during termination while bounding slow or stalled requests.
- Around line 20-36: Document in the roadmap or operator-facing documentation
that all routes registered by serve, including /kv/{key}, /status, /primary, and
/replica, currently have no authentication and must not be exposed directly to
untrusted networks; preserve the existing route behavior.

In `@d-lmdb/src/config.rs`:
- Around line 12-25: Rename DLmdbConfig.map_size_gb to a bytes-based name such
as map_size_bytes, update both configuration constructors and the
state_machine.rs EnvOpenOptions::map_size call to use it, and remove the unused
Deserialize derive and serde default attribute from DLmdbConfig. Preserve the
existing byte values and LmdbFileSection deserialization/default behavior.

In `@d-lmdb/src/db_test.rs`:
- Around line 71-140: Add mirrored tests for decode_for_scan covering raw and
unexpired TTL payload decoding, exact and past expiry, empty payload
preservation, invalid tag bytes, empty input, and truncated TTL headers.
Exercise the public scan APIs backed by decode_for_scan where appropriate, and
verify the scan-specific behavior of dropping corrupt entries while preserving
the expires_at == now boundary semantics.

In `@d-lmdb/src/db.rs`:
- Around line 433-442: Refactor decode_for_scan to accept a captured now_secs
value, such as via a decode_for_scan_at helper, and remove its per-entry
unix_now_secs() call. In the scan loop, capture unix_now_secs() once and pass it
through the existing closure used to decode candidates, ensuring every entry is
evaluated against the same timestamp.

In `@d-lmdb/src/state_machine_test.rs`:
- Around line 240-256: Extend test_scan_prefix_returns_matching_keys with
coverage for scan_prefix_bounded’s after cursor, including an after key that
sorts before the requested user: prefix. Assert that pagination remains
correctly bounded to matching prefix entries and preserves the expected results,
pinning the intended clamped-after behavior.
- Around line 189-202: Update test_scan_all_with_filter_excludes_and_transforms
to assert the returned entry payload as well as its key, using a filter output
that demonstrates transformation or verifying the expected transformed value;
otherwise rename the test to remove “and_transforms.”

In `@d-lmdb/src/state_machine.rs`:
- Around line 528-534: Implement durable persistence in
persist_last_snapshot_metadata by serializing the supplied SnapshotMetadata and
storing it in meta_db under the "snapshot_meta" key before or alongside
update_last_snapshot_metadata. Ensure snapshot_metadata() reads and deserializes
the same key so the metadata survives restarts, while preserving the existing
EngineError propagation.
- Around line 118-155: Add a local fatal error-conversion helper beside the
existing lmdb_err helper, accepting any Display error and returning
EngineError::Fatal with its string. Replace every repeated map_err closure in
this function, including the snapshot opening, transaction, database, iteration,
put, clear, and commit operations, with the helper while preserving existing
error propagation.

In `@d-lmdb/src/storage_engine_test.rs`:
- Around line 27-35: The conformance-suite builders reuse fixed LMDB paths and
do not clean them up. In d-lmdb/src/storage_engine_test.rs lines 27-35, update
the builder’s build method to generate a unique subdirectory per call using an
incrementing counter under temp_dir, while leaving cleanup behavior unchanged.
Apply the same per-call unique-subdirectory change to the builder build method
in d-lmdb/src/state_machine_test.rs lines 29-40, replacing the fixed
temp_dir/lmdb_sm path.

In `@d-lmdb/tests/integration.rs`:
- Around line 96-127: Add integration tests alongside the existing TTL tests
covering end-to-end put-to-read roundtrips through get_linearizable and
get_lease. Verify each method returns the stored user value rather than the raw
wire envelope, using the established database setup and cleanup pattern.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 06246521-4587-4205-8bb2-69ab822567c5

📥 Commits

Reviewing files that changed from the base of the PR and between bd4d7e0 and 23d96c3.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (42)
  • .dockerignore
  • .github/workflows/docker-release.yml
  • .gitignore
  • Cargo.toml
  • Dockerfile
  • README.md
  • compose/haproxy.cfg
  • compose/node1/config.toml
  • compose/node2/config.toml
  • compose/node3/config.toml
  • compose/smoke-test.sh
  • d-lmdb-server/Cargo.toml
  • d-lmdb-server/README.md
  • d-lmdb-server/config.example.toml
  • d-lmdb-server/src/http/error.rs
  • d-lmdb-server/src/http/error_test.rs
  • d-lmdb-server/src/http/health.rs
  • d-lmdb-server/src/http/kv.rs
  • d-lmdb-server/src/http/kv_test.rs
  • d-lmdb-server/src/http/mod.rs
  • d-lmdb-server/src/main.rs
  • d-lmdb/Cargo.toml
  • d-lmdb/README.md
  • d-lmdb/src/config.rs
  • d-lmdb/src/db.rs
  • d-lmdb/src/db_test.rs
  • d-lmdb/src/error.rs
  • d-lmdb/src/lib.rs
  • d-lmdb/src/state_machine.rs
  • d-lmdb/src/state_machine_test.rs
  • d-lmdb/src/storage_engine.rs
  • d-lmdb/src/storage_engine_test.rs
  • d-lmdb/src/time.rs
  • d-lmdb/src/wire/batch_test.rs
  • d-lmdb/src/wire/mod.rs
  • d-lmdb/src/wire/values.rs
  • d-lmdb/src/wire/values_test.rs
  • d-lmdb/tests/integration.rs
  • docker-compose.yml
  • docker-entrypoint.sh
  • examples/single-node/config.toml
  • src/db_test.rs
💤 Files with no reviewable changes (1)
  • src/db_test.rs

Comment thread .github/workflows/docker-release.yml Outdated
Comment thread .github/workflows/docker-release.yml Outdated
Comment thread compose/smoke-test.sh
Comment thread d-lmdb-server/src/main.rs
Comment thread d-lmdb/README.md Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 16

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
d-lmdb-server/config.example.toml (1)

1-13: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Document that the flat config fields are not read directly by the library.

DLmdbConfig::from_file only consumes [lmdb].data_dir, then appends raft and lmdb subpaths from there. [cluster].db_root_dir/log_dir are used by the server config path, so this example misleadingly presents incompatible file schemas in a single TOML. Split the examples/constraints or make the server only consume fields compatible with DLmdbConfig.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb-server/config.example.toml` around lines 1 - 13, Update the
configuration example to avoid presenting incompatible schemas together:
document that DLmdbConfig::from_file reads only [lmdb].data_dir and derives the
raft/lmdb subpaths, while [cluster].db_root_dir and log_dir belong to the server
configuration path. Split the examples or clearly separate the applicable fields
so users do not assume the library reads the flat cluster fields directly.
🧹 Nitpick comments (11)
d-lmdb/tests/integration.rs (1)

96-127: 📐 Maintainability & Code Quality | 🔵 Trivial

Good TTL coverage; the linearizable read path has none.

db_test.rs:71-78 documents that get_linearizable previously returned the raw wire envelope — a bug that unit tests on decode_live_value alone wouldn't have caught, since the defect was in the wiring, not the decoder. A single put → get_linearizable roundtrip here (and one for get_lease) would close that gap end to end.

Happy to add them.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/tests/integration.rs` around lines 96 - 127, Add integration tests
alongside the existing TTL tests covering end-to-end put-to-read roundtrips
through get_linearizable and get_lease. Verify each method returns the stored
user value rather than the raw wire envelope, using the established database
setup and cleanup pattern.
d-lmdb/src/db_test.rs (1)

71-140: 📐 Maintainability & Code Quality | 🔵 Trivial

Thorough coverage of decode_live_value; its scan-path twin has none.

decode_for_scan reimplements the same TTL/tag logic with different failure semantics (drops corrupt entries instead of erroring) and backs all four public scan APIs, so the two can drift silently. The boundary case (expires_at == now) is the one most likely to diverge.

Want me to add mirrored cases for decode_for_scan?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/src/db_test.rs` around lines 71 - 140, Add mirrored tests for
decode_for_scan covering raw and unexpired TTL payload decoding, exact and past
expiry, empty payload preservation, invalid tag bytes, empty input, and
truncated TTL headers. Exercise the public scan APIs backed by decode_for_scan
where appropriate, and verify the scan-specific behavior of dropping corrupt
entries while preserving the expires_at == now boundary semantics.
d-lmdb/src/db.rs (1)

433-442: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

unix_now_secs() is called once per scanned entry.

decode_for_scan is invoked for every candidate in the scan loop, so a large scan makes a clock call per key, and entries are evaluated against slightly different "now" values within a single scan — the boundary case can include one key and exclude its neighbour inconsistently. decode_live_value already takes now_secs as a parameter; do the same here and capture it once per scan via a closure.

♻️ Proposed refactor
-fn decode_for_scan(raw: &[u8]) -> Option<Vec<u8>> {
+fn decode_for_scan_at(
+    raw: &[u8],
+    now_secs: u64,
+) -> Option<Vec<u8>> {
     match Value::decode(raw) {
         Ok(Value::Raw(payload)) => Some(payload.to_vec()),
         Ok(Value::Ttl {
             expires_at,
             payload,
-        }) if expires_at > unix_now_secs() => Some(payload.to_vec()),
+        }) if expires_at > now_secs => Some(payload.to_vec()),
         _ => None,
     }
 }

Call sites become e.g. let now = unix_now_secs(); ... Some(move |raw: &[u8]| decode_for_scan_at(raw, now)).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/src/db.rs` around lines 433 - 442, Refactor decode_for_scan to accept
a captured now_secs value, such as via a decode_for_scan_at helper, and remove
its per-entry unix_now_secs() call. In the scan loop, capture unix_now_secs()
once and pass it through the existing closure used to decode candidates,
ensuring every entry is evaluated against the same timestamp.
d-lmdb/src/storage_engine_test.rs (1)

27-35: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Both conformance-suite builders hand out a fixed path with a no-op cleanup. Each builder holds one TempDir and derives a single fixed subdirectory, so repeated build() calls reopen the same LMDB environment and inherit prior state — and LMDB generally refuses a second in-process open of the same path.

  • d-lmdb/src/storage_engine_test.rs#L27-L35: derive a unique subdirectory per build() call (e.g. an incrementing counter under temp_dir) rather than the fixed temp_dir/raft.
  • d-lmdb/src/state_machine_test.rs#L29-L40: apply the same per-call unique subdirectory instead of the fixed temp_dir/lmdb_sm.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/src/storage_engine_test.rs` around lines 27 - 35, The
conformance-suite builders reuse fixed LMDB paths and do not clean them up. In
d-lmdb/src/storage_engine_test.rs lines 27-35, update the builder’s build method
to generate a unique subdirectory per call using an incrementing counter under
temp_dir, while leaving cleanup behavior unchanged. Apply the same per-call
unique-subdirectory change to the builder build method in
d-lmdb/src/state_machine_test.rs lines 29-40, replacing the fixed
temp_dir/lmdb_sm path.
d-lmdb/src/config.rs (1)

12-25: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

map_size_gb holds bytes, and the Deserialize derive here is unreachable.

state_machine.rs passes config.map_size_gb straight into EnvOpenOptions::map_size(...), and both constructors store bytes (new writes 10 * 1024^3, from_file multiplies by 1024^3). The _gb suffix invites a future caller to pass 10 and get a 10-byte map. Separately, file loading goes through LmdbFileSection, so the Deserialize derive on DLmdbConfig is never exercised — and if it ever were, max_key_bytes/max_value_bytes have no serde defaults while LmdbFileSection does, so the two paths would disagree.

♻️ Suggested rename + drop the unused derive
-#[derive(Deserialize)]
 pub(crate) struct DLmdbConfig {
     /// Root directory for all persisted data (Raft WAL + LMDB state machine)
     pub(crate) data_dir: PathBuf,
     /// LMDB memory-map size in bytes. Must be larger than the total dataset.
     /// Default: 10 GiB — adjust for your expected data volume.
-    #[serde(default = "default_map_size_gb")]
-    pub(crate) map_size_gb: usize,
+    pub(crate) map_size_bytes: usize,

Update the two constructors and state_machine.rs accordingly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/src/config.rs` around lines 12 - 25, Rename DLmdbConfig.map_size_gb to
a bytes-based name such as map_size_bytes, update both configuration
constructors and the state_machine.rs EnvOpenOptions::map_size call to use it,
and remove the unused Deserialize derive and serde default attribute from
DLmdbConfig. Preserve the existing byte values and LmdbFileSection
deserialization/default behavior.
d-lmdb/src/state_machine_test.rs (2)

240-256: 📐 Maintainability & Code Quality | 🔵 Trivial

No coverage for scan_prefix_bounded's after cursor.

The pagination parameter is untested, which is how the unclamped-after behavior I flagged at state_machine.rs:302-307 stays invisible. A case passing an after that sorts before the prefix would pin the intended semantics.

Happy to draft those cases if useful.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/src/state_machine_test.rs` around lines 240 - 256, Extend
test_scan_prefix_returns_matching_keys with coverage for scan_prefix_bounded’s
after cursor, including an after key that sorts before the requested user:
prefix. Assert that pagination remains correctly bounded to matching prefix
entries and preserves the expected results, pinning the intended clamped-after
behavior.

189-202: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Test name promises a transformation that isn't exercised.

only_long_values returns the value unchanged, so this only proves exclusion. Either assert the returned payload to lock in the "filter output is what's stored in the result" contract, or drop "and_transforms" from the name.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/src/state_machine_test.rs` around lines 189 - 202, Update
test_scan_all_with_filter_excludes_and_transforms to assert the returned entry
payload as well as its key, using a filter output that demonstrates
transformation or verifying the expected transformed value; otherwise rename the
test to remove “and_transforms.”
d-lmdb/src/state_machine.rs (2)

528-534: 🗄️ Data Integrity & Integration | 🔵 Trivial

Snapshot metadata is never persisted — snapshot_metadata() returns None after every restart.

The TODO leaves persist_last_snapshot_metadata as an in-memory-only update, so a restarted node reports no snapshot even when data.mdb exists on disk. Depending on how d-engine uses this, the node may re-request a full snapshot from the leader.

Want me to open an issue to track persisting this into meta_db?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/src/state_machine.rs` around lines 528 - 534, Implement durable
persistence in persist_last_snapshot_metadata by serializing the supplied
SnapshotMetadata and storing it in meta_db under the "snapshot_meta" key before
or alongside update_last_snapshot_metadata. Ensure snapshot_metadata() reads and
deserializes the same key so the metadata survives restarts, while preserving
the existing EngineError propagation.

118-155: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Extract the repeated map_err(|e| EngineError::Fatal(e.to_string())).

It appears ten times in this function. A local fn fatal<E: Display>(e: E) -> EngineError (alongside the existing lmdb_err helper) removes the noise and makes the actual LMDB calls readable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/src/state_machine.rs` around lines 118 - 155, Add a local fatal
error-conversion helper beside the existing lmdb_err helper, accepting any
Display error and returning EngineError::Fatal with its string. Replace every
repeated map_err closure in this function, including the snapshot opening,
transaction, database, iteration, put, clear, and commit operations, with the
helper while preserving existing error propagation.
d-lmdb-server/src/http/mod.rs (2)

20-36: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider graceful shutdown and a request timeout.

axum::serve(listener, app).await has no .with_graceful_shutdown(...) and there's no TimeoutLayer on the stack. On docker stop/rolling restarts, in-flight requests can be cut off abruptly rather than allowed to finish; a slow/stalled client can also hold a connection indefinitely.

♻️ Suggested addition
     let listener = tokio::net::TcpListener::bind(addr).await?;
-    axum::serve(listener, app).await
+    axum::serve(listener, app)
+        .with_graceful_shutdown(shutdown_signal())
+        .await
+}
+
+async fn shutdown_signal() {
+    let _ = tokio::signal::ctrl_c().await;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb-server/src/http/mod.rs` around lines 20 - 36, Update serve to add a
request TimeoutLayer to the existing router middleware stack and run axum::serve
with graceful shutdown triggered by the service’s shutdown signal, preserving
completion of in-flight requests during termination while bounding slow or
stalled requests.

20-36: 🔒 Security & Privacy | 🔵 Trivial

No authentication on any route, including PUT/DELETE.

Every route (/kv/{key}, /status, /primary, /replica) is unauthenticated. Given the PR's stated scope as a visibility/evaluation release rather than a feature-complete product, this is likely intentional for now — but worth calling out explicitly in docs/roadmap so operators don't expose this directly to untrusted networks.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb-server/src/http/mod.rs` around lines 20 - 36, Document in the roadmap
or operator-facing documentation that all routes registered by serve, including
/kv/{key}, /status, /primary, and /replica, currently have no authentication and
must not be exposed directly to untrusted networks; preserve the existing route
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/docker-release.yml:
- Around line 55-62: Update the workflow step identified by “Push by digest” to
stop rebuilding with docker/build-push-action. After the smoke test of
d-lmdb-server:compose, retag the validated local platform image, push that tag,
and export the digest of the pushed image for the existing manifest-creation
flow.
- Around line 83-86: Update the release workflow steps around “Strip 'v' prefix
from release tag” and the Docker build/push commands to avoid interpolating
GitHub expressions into shell source: pass github.event.release.tag_name through
the step environment, derive STRIPPED_VERSION using quoted shell-variable
expansion, and reference the derived value through a safely exported environment
variable rather than `${{ env.STRIPPED_VERSION }}` inside run blocks.

In `@compose/smoke-test.sh`:
- Around line 25-31: Update cleanup_on_fail, which is invoked by fail(), to tear
down the Compose stack with docker compose down -v after collecting the failure
logs. Ensure the teardown runs on assertion failures so containers, ports, and
persisted volumes are removed before fail() exits.

In `@d-lmdb-server/src/main.rs`:
- Line 1: Run cargo fmt --all using the project’s rustfmt configuration,
ensuring the formatting updates are applied to read_http_listen_address,
test_level_eventual, test_level_linearizable, internal_error_category,
test_internal_error_category_configuration, and the test_router route
definitions without changing behavior.

In `@d-lmdb/README.md`:
- Around line 35-38: Update the README quick-start example to call the
path-based DLmdb::open API with "./data" directly instead of passing
DLmdbConfig::new("./data"), while retaining DLmdb::open_from_file("config.toml")
only as the multi-node configuration example.

In `@d-lmdb/src/config.rs`:
- Line 51: Update the map_size_gb conversion in the configuration construction
to use checked multiplication for the GiB-to-bytes factor and fail loudly when
overflow occurs, rather than relying on unchecked arithmetic. Preserve the
existing converted byte value for valid inputs and the surrounding root.lmdb
configuration flow.

In `@d-lmdb/src/error.rs`:
- Around line 27-33: Update the KeyTooLarge and ValueTooLarge error variants to
carry the configured limit alongside the actual size, and format messages from
those values instead of hardcoded limits. Modify validate_key and validate_value
to pass max_key_bytes/max_value_bytes when constructing errors, then update all
matching patterns in the integration tests and other affected callers to handle
the expanded variants.

In `@d-lmdb/src/state_machine.rs`:
- Around line 180-188: Change the expiry-reaping flow around
db.rs::decode_and_reap so reads no longer call delete_local synchronously. Queue
expired keys to a background reaper, batch pending deletions into a single LMDB
write transaction, and retain the existing read response behavior while allowing
reaping to run independently of get/exists/get_multi and apply_chunk.
- Around line 645-648: The u64_from_bytes helper currently masks malformed
persisted metadata by defaulting to zero. Change the decoding path used by new
for last_applied_index and last_applied_term to validate the byte length and
return an error when it is not exactly 8 bytes, propagating that error from new
instead of opening with zero.
- Around line 324-333: Update the range setup in the method containing
`empty_key_guard` so an empty caller-supplied `end` is handled before
constructing `Bound::Excluded(end)` or invoking `self.kv_db.range`. Preserve the
existing empty-result behavior provided by `empty_key_guard`, and keep the
current `start` handling and non-empty range path unchanged.
- Around line 49-51: Remove the unused entry_terms field from the state machine
and delete its initialization, per-entry insertion, and reset/snapshot-clearing
logic. Update the relevant constructor and apply/reset or snapshot-restore paths
while preserving all other state-machine behavior.
- Around line 302-307: Clamp the start bound in the scan-prefix flow before
calling scan_range_core: when after sorts before prefix, use
Bound::Included(prefix); otherwise preserve the existing exclusive after cursor.
Update the logic around next_prefix and scan_range_core so results never include
keys outside the requested prefix, while retaining current limit and filter
behavior.
- Around line 588-592: Update generate_snapshot_data() to compute the checksum
from the written data.mdb contents before constructing SnapshotMetadata,
replacing the hardcoded zero digest with the repository’s established 32-byte
hashing utility and preserving the resulting digest in
SnapshotMetadata.checksum.

In `@d-lmdb/src/storage_engine.rs`:
- Around line 207-219: Update the last_index recomputation around log_db.last()
to use the persisted KEY_PURGE_BOUNDARY index when the log is empty instead of
defaulting to zero. In d-lmdb/src/storage_engine.rs:207-219, preserve the
existing tail-index path and fall back to the boundary value; in
d-lmdb/src/storage_engine.rs:242-248, delete KEY_PURGE_BOUNDARY in the same
write transaction that clears log_db so the reset last_index and persisted
boundary remain synchronized.
- Line 28: Replace the hardcoded WAL_MAP_SIZE constant with a configurable value
exposed through DLmdbConfig alongside map_size_gb, and use that setting when
creating the Raft log environment. Preserve the existing default behavior by
defining an appropriate default map size, and ensure persist_entries receives
the configured capacity instead of the fixed 128 MiB limit.
- Around line 102-123: Move the synchronous LMDB transaction and durable
wtxn.commit work in persist_entries into tokio::task::spawn_blocking, following
the existing d-lmdb pattern. Preserve empty-input handling, entry persistence,
max_index tracking, error conversion, and update last_index only after the
blocking operation completes successfully.

---

Outside diff comments:
In `@d-lmdb-server/config.example.toml`:
- Around line 1-13: Update the configuration example to avoid presenting
incompatible schemas together: document that DLmdbConfig::from_file reads only
[lmdb].data_dir and derives the raft/lmdb subpaths, while [cluster].db_root_dir
and log_dir belong to the server configuration path. Split the examples or
clearly separate the applicable fields so users do not assume the library reads
the flat cluster fields directly.

---

Nitpick comments:
In `@d-lmdb-server/src/http/mod.rs`:
- Around line 20-36: Update serve to add a request TimeoutLayer to the existing
router middleware stack and run axum::serve with graceful shutdown triggered by
the service’s shutdown signal, preserving completion of in-flight requests
during termination while bounding slow or stalled requests.
- Around line 20-36: Document in the roadmap or operator-facing documentation
that all routes registered by serve, including /kv/{key}, /status, /primary, and
/replica, currently have no authentication and must not be exposed directly to
untrusted networks; preserve the existing route behavior.

In `@d-lmdb/src/config.rs`:
- Around line 12-25: Rename DLmdbConfig.map_size_gb to a bytes-based name such
as map_size_bytes, update both configuration constructors and the
state_machine.rs EnvOpenOptions::map_size call to use it, and remove the unused
Deserialize derive and serde default attribute from DLmdbConfig. Preserve the
existing byte values and LmdbFileSection deserialization/default behavior.

In `@d-lmdb/src/db_test.rs`:
- Around line 71-140: Add mirrored tests for decode_for_scan covering raw and
unexpired TTL payload decoding, exact and past expiry, empty payload
preservation, invalid tag bytes, empty input, and truncated TTL headers.
Exercise the public scan APIs backed by decode_for_scan where appropriate, and
verify the scan-specific behavior of dropping corrupt entries while preserving
the expires_at == now boundary semantics.

In `@d-lmdb/src/db.rs`:
- Around line 433-442: Refactor decode_for_scan to accept a captured now_secs
value, such as via a decode_for_scan_at helper, and remove its per-entry
unix_now_secs() call. In the scan loop, capture unix_now_secs() once and pass it
through the existing closure used to decode candidates, ensuring every entry is
evaluated against the same timestamp.

In `@d-lmdb/src/state_machine_test.rs`:
- Around line 240-256: Extend test_scan_prefix_returns_matching_keys with
coverage for scan_prefix_bounded’s after cursor, including an after key that
sorts before the requested user: prefix. Assert that pagination remains
correctly bounded to matching prefix entries and preserves the expected results,
pinning the intended clamped-after behavior.
- Around line 189-202: Update test_scan_all_with_filter_excludes_and_transforms
to assert the returned entry payload as well as its key, using a filter output
that demonstrates transformation or verifying the expected transformed value;
otherwise rename the test to remove “and_transforms.”

In `@d-lmdb/src/state_machine.rs`:
- Around line 528-534: Implement durable persistence in
persist_last_snapshot_metadata by serializing the supplied SnapshotMetadata and
storing it in meta_db under the "snapshot_meta" key before or alongside
update_last_snapshot_metadata. Ensure snapshot_metadata() reads and deserializes
the same key so the metadata survives restarts, while preserving the existing
EngineError propagation.
- Around line 118-155: Add a local fatal error-conversion helper beside the
existing lmdb_err helper, accepting any Display error and returning
EngineError::Fatal with its string. Replace every repeated map_err closure in
this function, including the snapshot opening, transaction, database, iteration,
put, clear, and commit operations, with the helper while preserving existing
error propagation.

In `@d-lmdb/src/storage_engine_test.rs`:
- Around line 27-35: The conformance-suite builders reuse fixed LMDB paths and
do not clean them up. In d-lmdb/src/storage_engine_test.rs lines 27-35, update
the builder’s build method to generate a unique subdirectory per call using an
incrementing counter under temp_dir, while leaving cleanup behavior unchanged.
Apply the same per-call unique-subdirectory change to the builder build method
in d-lmdb/src/state_machine_test.rs lines 29-40, replacing the fixed
temp_dir/lmdb_sm path.

In `@d-lmdb/tests/integration.rs`:
- Around line 96-127: Add integration tests alongside the existing TTL tests
covering end-to-end put-to-read roundtrips through get_linearizable and
get_lease. Verify each method returns the stored user value rather than the raw
wire envelope, using the established database setup and cleanup pattern.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 06246521-4587-4205-8bb2-69ab822567c5

📥 Commits

Reviewing files that changed from the base of the PR and between bd4d7e0 and 23d96c3.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (42)
  • .dockerignore
  • .github/workflows/docker-release.yml
  • .gitignore
  • Cargo.toml
  • Dockerfile
  • README.md
  • compose/haproxy.cfg
  • compose/node1/config.toml
  • compose/node2/config.toml
  • compose/node3/config.toml
  • compose/smoke-test.sh
  • d-lmdb-server/Cargo.toml
  • d-lmdb-server/README.md
  • d-lmdb-server/config.example.toml
  • d-lmdb-server/src/http/error.rs
  • d-lmdb-server/src/http/error_test.rs
  • d-lmdb-server/src/http/health.rs
  • d-lmdb-server/src/http/kv.rs
  • d-lmdb-server/src/http/kv_test.rs
  • d-lmdb-server/src/http/mod.rs
  • d-lmdb-server/src/main.rs
  • d-lmdb/Cargo.toml
  • d-lmdb/README.md
  • d-lmdb/src/config.rs
  • d-lmdb/src/db.rs
  • d-lmdb/src/db_test.rs
  • d-lmdb/src/error.rs
  • d-lmdb/src/lib.rs
  • d-lmdb/src/state_machine.rs
  • d-lmdb/src/state_machine_test.rs
  • d-lmdb/src/storage_engine.rs
  • d-lmdb/src/storage_engine_test.rs
  • d-lmdb/src/time.rs
  • d-lmdb/src/wire/batch_test.rs
  • d-lmdb/src/wire/mod.rs
  • d-lmdb/src/wire/values.rs
  • d-lmdb/src/wire/values_test.rs
  • d-lmdb/tests/integration.rs
  • docker-compose.yml
  • docker-entrypoint.sh
  • examples/single-node/config.toml
  • src/db_test.rs
💤 Files with no reviewable changes (1)
  • src/db_test.rs
🛑 Comments failed to post (11)
d-lmdb/src/config.rs (1)

51-51: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Unchecked GiB→bytes multiplication.

A mistyped map_size_gb wraps in release builds and panics in debug; on a 32-bit target anything ≥ 4 overflows. Fail loudly instead.

🛡️ Proposed fix
-            map_size_gb: root.lmdb.map_size_gb * 1024 * 1024 * 1024,
+            map_size_gb: root
+                .lmdb
+                .map_size_gb
+                .checked_mul(1024 * 1024 * 1024)
+                .ok_or_else(|| {
+                    Error::Storage("lmdb.map_size_gb is too large for this platform".into())
+                })?,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

            map_size_gb: root
                .lmdb
                .map_size_gb
                .checked_mul(1024 * 1024 * 1024)
                .ok_or_else(|| {
                    Error::Storage("lmdb.map_size_gb is too large for this platform".into())
                })?,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/src/config.rs` at line 51, Update the map_size_gb conversion in the
configuration construction to use checked multiplication for the GiB-to-bytes
factor and fail loudly when overflow occurs, rather than relying on unchecked
arithmetic. Preserve the existing converted byte value for valid inputs and the
surrounding root.lmdb configuration flow.
d-lmdb/src/error.rs (1)

27-33: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Error text hardcodes limits that are configurable.

max_key_bytes/max_value_bytes come from DLmdbConfig, but these messages always claim 512 / 1MB. d-lmdb-server/src/http/error.rs:114-118 returns err.to_string() verbatim as the 400 body, so an operator who raises the limit ships a misleading message to clients. Carry the limit in the variant.

🐛 Proposed fix
-    /// The supplied key exceeds the 512-byte limit.
-    #[error("key too large: {0} bytes (max 512)")]
-    KeyTooLarge(usize),
+    /// The supplied key exceeds the configured limit.
+    #[error("key too large: {actual} bytes (max {max})")]
+    KeyTooLarge { actual: usize, max: usize },
 
-    /// The supplied value exceeds the 1MB limit.
-    #[error("value too large: {0} bytes (max 1MB)")]
-    ValueTooLarge(usize),
+    /// The supplied value exceeds the configured limit.
+    #[error("value too large: {actual} bytes (max {max})")]
+    ValueTooLarge { actual: usize, max: usize },

Note this touches validate_key/validate_value in db.rs and the matches in d-lmdb/tests/integration.rs:71-92.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    /// The supplied key exceeds the configured limit.
    #[error("key too large: {actual} bytes (max {max})")]
    KeyTooLarge { actual: usize, max: usize },

    /// The supplied value exceeds the configured limit.
    #[error("value too large: {actual} bytes (max {max})")]
    ValueTooLarge { actual: usize, max: usize },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/src/error.rs` around lines 27 - 33, Update the KeyTooLarge and
ValueTooLarge error variants to carry the configured limit alongside the actual
size, and format messages from those values instead of hardcoded limits. Modify
validate_key and validate_value to pass max_key_bytes/max_value_bytes when
constructing errors, then update all matching patterns in the integration tests
and other affected callers to handle the expanded variants.
d-lmdb/src/state_machine.rs (6)

49-51: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Any reader of entry_terms outside the insert/clear sites?
rg -nP -C3 '\bentry_terms\b' --type=rust

Repository: deventlab/d-lmdb

Length of output: 154


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files:"
git ls-files | rg 'd-lmdb/src/state_machine.rs|Cargo\.toml$|Cargo\.lock$' || true

echo
echo "state_machine.rs outline if available:"
ast-grep outline d-lmdb/src/state_machine.rs --view expanded || true

echo
echo "Occurrences of entry_terms with line numbers:"
rg -n -C4 '\bentry_terms\b' . || true

echo
echo "Relevant state_machine.rs sections:"
wc -l d-lmdb/src/state_machine.rs
sed -n '1,120p' d-lmdb/src/state_machine.rs
sed -n '430,470p' d-lmdb/src/state_machine.rs

Repository: deventlab/d-lmdb

Length of output: 11118


Drop the unused entry_terms cache.

entry_terms is initialized, populated on each applied entry, and only cleared by reset or snapshot restore; nothing reads it. Keep it until a real reader requires term lookups, otherwise remove both the field and the insert to avoid unbounded per-entry in-memory growth.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/src/state_machine.rs` around lines 49 - 51, Remove the unused
entry_terms field from the state machine and delete its initialization,
per-entry insertion, and reset/snapshot-clearing logic. Update the relevant
constructor and apply/reset or snapshot-restore paths while preserving all other
state-machine behavior.

180-188: 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Expiry reaping takes the LMDB write lock on the read path.

delete_local opens a write transaction, and LMDB permits exactly one writer at a time. It's called synchronously from db.rs::decode_and_reap, which serves get/exists/get_multi — and those run directly on a tokio worker in d-lmdb-server/src/http/kv.rs:66-80 with no spawn_blocking. A read burst over expired keys therefore serializes reads against each other and against the Raft apply loop's apply_chunk write txn, with an fsync per key.

Consider queuing expired keys to a background reaper that batches deletions into one txn, rather than reaping inline per read.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/src/state_machine.rs` around lines 180 - 188, Change the
expiry-reaping flow around db.rs::decode_and_reap so reads no longer call
delete_local synchronously. Queue expired keys to a background reaper, batch
pending deletions into a single LMDB write transaction, and retain the existing
read response behavior while allowing reaping to run independently of
get/exists/get_multi and apply_chunk.

302-307: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

after isn't clamped to the prefix range.

If a caller passes an after cursor that sorts before prefix, the scan starts there and returns every key in [after, next_prefix(prefix)) — including keys that don't share the prefix. after is caller-supplied through the public DLmdb::scan_prefix, so this is reachable with a stale or wrong cursor.

🐛 Proposed fix
         let end = next_prefix(prefix);
         let start = match after {
-            Some(a) => Bound::Excluded(a),
+            // Clamp the cursor into the prefix range; a cursor before the
+            // prefix must not widen the scan.
+            Some(a) if a >= prefix => Bound::Excluded(a),
+            Some(_) => Bound::Included(prefix),
             None => Bound::Included(prefix),
         };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

        let end = next_prefix(prefix);
        let start = match after {
            // Clamp the cursor into the prefix range; a cursor before the
            // prefix must not widen the scan.
            Some(a) if a >= prefix => Bound::Excluded(a),
            Some(_) => Bound::Included(prefix),
            None => Bound::Included(prefix),
        };
        self.scan_range_core(start, end.as_deref(), limit, filter)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/src/state_machine.rs` around lines 302 - 307, Clamp the start bound in
the scan-prefix flow before calling scan_range_core: when after sorts before
prefix, use Bound::Included(prefix); otherwise preserve the existing exclusive
after cursor. Update the logic around next_prefix and scan_range_core so results
never include keys outside the requested prefix, while retaining current limit
and filter behavior.

324-333: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Empty end is unguarded here, unlike start.

empty_key_guard's doc says every caller-supplied bound handed to LMDB must be checked, but only start is. end goes straight into Bound::Excluded(end) — a zero-length end hits the same MDB_BAD_VALSIZE this guard exists to avoid. Note the forward path is safe because scan_range_core compares end in Rust rather than passing it to heed; this is the one place it reaches LMDB.

🐛 Proposed fix
-        if start.is_empty() {
+        if start.is_empty() || end.is_empty() {
             return Ok(self.empty_key_guard());
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

        if start.is_empty() || end.is_empty() {
            return Ok(self.empty_key_guard());
        }

        let rtxn = self.env.read_txn().map_err(lmdb_err)?;
        let revision = self.last_applied_index.load(Ordering::SeqCst);
        let cap = limit.unwrap_or(usize::MAX);

        let range = (Bound::Included(start), Bound::Excluded(end));
        let iter = self.kv_db.range(&rtxn, &range).map_err(lmdb_err)?;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/src/state_machine.rs` around lines 324 - 333, Update the range setup
in the method containing `empty_key_guard` so an empty caller-supplied `end` is
handled before constructing `Bound::Excluded(end)` or invoking
`self.kv_db.range`. Preserve the existing empty-result behavior provided by
`empty_key_guard`, and keep the current `start` handling and non-empty range
path unchanged.

588-592: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Does d-engine validate SnapshotMetadata.checksum anywhere?
rg -nP -C4 '\bchecksum\b' --type=rust -g '!target/**'

Repository: deventlab/d-lmdb

Length of output: 154


🏁 Script executed:

#!/bin/bash
set -u

echo "== files =="
git ls-files | sed -n '1,200p'

echo "== candidates for state_machine.rs and SnapshotMetadata =="
fd -a 'state_machine\.rs$|snapshot' . | sed 's#^\./##' | sed -n '1,120p'

echo "== checksum occurrences all files =="
rg -n -C3 '\bchecksum\b' -g '!target/**' || true

echo "== SnapshotMetadata occurrences =="
rg -n -C3 '\bSnapshotMetadata\b' -g '!target/**' || true

Repository: deventlab/d-lmdb

Length of output: 1943


🏁 Script executed:

#!/bin/bash
set -u

echo "== state_machine.rs outline around SnapshotMetadata / checksum =="
ast-grep outline src/state_machine.rs --match 'SnapshotMetadata' --view expanded || true
sed -n '540,620p' src/state_machine.rs

echo "== all identifier references containing checksum/check =="
rg -n -C2 '[Cc]hecksum|[Cc]heck|digest|sha|md5|blake|snap_meta|SnapshotMetadata' d-lmdb/src d-lmdb-server/src Cargo.toml README.md || true

Repository: deventlab/d-lmdb

Length of output: 9414


🏁 Script executed:

#!/bin/bash
set -u

echo "== d-engine dependency sources =="
rg -n 'd-engine|d_engine|snapshot|Checksum|checksum|Checksum' Cargo.toml Cargo.lock README.md d-lmdb-server/src d-lmdb/src || true

echo "== focused state_machine section =="
sed -n '528,598p' d-lmdb/src/state_machine.rs

echo "== focused d-lmdb-server snapshot handling/search =="
rg -n -C3 'Snapshot|snapshot|Snap|checksum|Checksum|install|install_snapshot|verify' d-lmdb-server/src Cargo.toml Cargo.lock || true

Repository: deventlab/d-lmdb

Length of output: 50373


🌐 Web query:

d-engine server_storage SnapshotMetadata checksum

💡 Result:

In the d-engine Raft storage system, the SnapshotMetadata structure—defined within the d_engine_proto::server::storage module—includes a checksum field used for integrity verification of snapshots [1]. The checksum field is typically represented as a Bytes type and stores a CRC32 or SHA2 hash [1]. This value is essential for ensuring that the snapshot data remains uncorrupted during storage or transmission between cluster nodes [1]. The metadata management lifecycle in d-engine involves three primary methods within the StateMachine trait [1]: 1. update_last_snapshot_metadata: Updates the SnapshotMetadata in memory [1][2][3]. 2. snapshot_metadata: Retrieves the current SnapshotMetadata from memory [1][2][3]. 3. persist_last_snapshot_metadata: Writes the SnapshotMetadata to durable storage, a process that typically involves disk I/O [1][2][3]. By tracking the checksum alongside other metadata—such as the last_included_index, last_included_term, and snapshot_size—the system can validate the integrity of snapshot files before they are applied to the state machine [1].

Citations:


🌐 Web query:

github.com/DEventLab/d-engine SnapshotMetadata checksum verify SnapshotMetadata checksum

💡 Result:

In the d-engine project (deventlab/d-engine), SnapshotMetadata is a data structure defined in the d-engine-proto crate used to track Raft log position and integrity information for snapshots [1]. The integrity verification process relies on a checksum field within the SnapshotMetadata [1]. According to the project's documentation, the verification process for a snapshot generally includes the following steps: 1. Metadata Validation: The snapshot's integrity is verified using the checksum stored in the SnapshotMetadata before the snapshot is applied to the state machine [1]. 2. Checksum Algorithm: The system utilizes CRC32 or SHA2 hash algorithms to calculate and verify the checksum of the snapshot data [1]. 3. Verification Flow: - The snapshot metadata and its version are validated first [1]. - The compressed file format (typically a gzip tar archive) is verified [1]. - The snapshot is decompressed into a temporary directory [1]. - The checksum of the decompressed data is calculated and compared against the checksum stored in the SnapshotMetadata to ensure integrity [1]. This mechanism ensures that the snapshot has not been corrupted during storage or transfer before it is used to initialize or update the state machine database [1]. Any failure in checksum verification or file validation prevents the application of the snapshot, protecting the system from inconsistent state [1].

Citations:


Compute a real snapshot checksum for data.mdb.

generate_snapshot_data() currently returns a hardcoded 32 zero-byte digest for every snapshot. Since SnapshotMetadata.checksum is part of d-engine’s snapshot integrity contract, these snapshots can pass snapshot installs despite corruption; compute a real digest over the written data.mdb before returning it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/src/state_machine.rs` around lines 588 - 592, Update
generate_snapshot_data() to compute the checksum from the written data.mdb
contents before constructing SnapshotMetadata, replacing the hardcoded zero
digest with the repository’s established 32-byte hashing utility and preserving
the resulting digest in SnapshotMetadata.checksum.

645-648: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

u64_from_bytes silently returns 0 on a malformed value.

This decodes the persisted last_applied_index/last_applied_term at open time (lines 77-79). A truncated or corrupt meta entry quietly becomes "index 0", i.e. the node claims it has applied nothing and replays from the start rather than failing loudly. Prefer returning an error from new on a bad length.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/src/state_machine.rs` around lines 645 - 648, The u64_from_bytes
helper currently masks malformed persisted metadata by defaulting to zero.
Change the decoding path used by new for last_applied_index and
last_applied_term to validate the byte length and return an error when it is not
exactly 8 bytes, propagating that error from new instead of opening with zero.
d-lmdb/src/storage_engine.rs (3)

28-28: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Raft WAL map size is a hardcoded 128 MiB with no config knob.

The state-machine env takes its map size from DLmdbConfig (10 GiB default), but the log env is fixed. The log grows until a snapshot triggers purge; a node that lags, or a write burst of ~1 MiB values before the first snapshot, exhausts 128 MiB and every persist_entries starts failing with MDB_MAP_FULL — which surfaces as an opaque DbError. Expose this alongside map_size_gb.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/src/storage_engine.rs` at line 28, Replace the hardcoded WAL_MAP_SIZE
constant with a configurable value exposed through DLmdbConfig alongside
map_size_gb, and use that setting when creating the Raft log environment.
Preserve the existing default behavior by defining an appropriate default map
size, and ensure persist_entries receives the configured capacity instead of the
fixed 128 MiB limit.

102-123: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# How does d-engine invoke LogStore::persist_entries — blocking pool or runtime worker?
rg -nP -C5 'persist_entries' --type=rust -g '!target/**'

Repository: deventlab/d-lmdb

Length of output: 154


Run the durable LMDB commit off the async runtime.

persist_entries is async but performs a synchronous write txn followed by wtxn.commit() with is_write_durable() returning true, so the LMDB flush/fsync work runs on the Tokio worker used by d-engine. This is on the Raft append/replication path and can stall the runtime under load; d-lmdb already uses spawn_blocking elsewhere for blocking disk work, so apply the same pattern here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/src/storage_engine.rs` around lines 102 - 123, Move the synchronous
LMDB transaction and durable wtxn.commit work in persist_entries into
tokio::task::spawn_blocking, following the existing d-lmdb pattern. Preserve
empty-input handling, entry persistence, max_index tracking, error conversion,
and update last_index only after the blocking operation completes successfully.

207-219: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

last_index and the persisted purge boundary are maintained independently, so log-emptying operations desynchronize them. Both sites assume an empty log_db means "index 0", but after a purge the true last index lives only in KEY_PURGE_BOUNDARY.

  • d-lmdb/src/storage_engine.rs#L207-L219: when log_db.last() returns None, fall back to the persisted purge boundary's index instead of 0.
  • d-lmdb/src/storage_engine.rs#L242-L248: delete KEY_PURGE_BOUNDARY in the same write txn that clears log_db, so the reset-to-zero last_index and the boundary agree.
📍 Affects 1 file
  • d-lmdb/src/storage_engine.rs#L207-L219 (this comment)
  • d-lmdb/src/storage_engine.rs#L242-L248
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@d-lmdb/src/storage_engine.rs` around lines 207 - 219, Update the last_index
recomputation around log_db.last() to use the persisted KEY_PURGE_BOUNDARY index
when the log is empty instead of defaulting to zero. In
d-lmdb/src/storage_engine.rs:207-219, preserve the existing tail-index path and
fall back to the boundary value; in d-lmdb/src/storage_engine.rs:242-248, delete
KEY_PURGE_BOUNDARY in the same write transaction that clears log_db so the reset
last_index and persisted boundary remain synchronized.

@codecov-commenter

codecov-commenter commented Jul 28, 2026 •

Copy link
Copy Markdown

- 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
@JoshuaChi
JoshuaChi merged commit f0b5753 into main Jul 29, 2026
5 checks passed
@JoshuaChi
JoshuaChi deleted the feature/2-docker-image branch July 29, 2026 05:11
@coderabbitai coderabbitai Bot mentioned this pull request Jul 29, 2026
5 tasks done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Release d-lmdb v0.1 as a standalone Docker image (HTTP + embedded Raft)

2 participants