Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ transaction logic as sandboxed WebAssembly, ~5.7M tx/s. Rust, edition 2024, MSRV

> **Status nuance (read this):** The public `README.md` describes a *single-node* engine and marks
> cluster/raft as "planned." The code is ahead — the `cluster`, `raft`, `client`, and `control`
> crates implement Raft consensus + replication and are under **active development**. Recovery is
> mid-redesign on branch `feature/redesign-recover-process` (ADR-019/020/021 + ActiveSnapshot).
> crates implement Raft consensus + replication and are under **active development**. The
> `ActiveSnapshot`-based recovery redesign (ADR-019/020/021) is **merged** and lives in
> `crates/ledger/src/recover.rs`.
> Treat README's "single node" claims as marketing-lag, not ground truth — check the code.

## Mental model
Expand All @@ -30,10 +31,11 @@ transaction logic as sandboxed WebAssembly, ~5.7M tx/s. Rust, edition 2024, MSRV

`Deposit`, `Withdrawal`, `Transfer`, and `Function` (invoke a registered WASM module as a first-class
atomic op — the only extension point). Accounts are `u64`, balances `i64`, account `0` is the system
source/sink. WASM host API is deliberately narrow (`credit`, `debit`, `get_balance`) and fully
deterministic so followers can replay entries without re-running code. See `docs/wasm-runtime.md`.
source/sink. WASM host API is a small set of deterministic verbs — balance ops (`credit`/`debit`/`get_balance`),
account-layout/flag ops, and typed KV/constant ops (ADR-022/023/026) — so followers can replay entries
without re-running code. See `docs/wasm-runtime.md`.

## Repository map (8-crate Cargo workspace)
## Repository map (9-crate Cargo workspace)

| Crate | Path | Role |
|---|---|---|
Expand All @@ -45,6 +47,7 @@ deterministic so followers can replay entries without re-running code. See `docs
| `client` | `crates/client` | gRPC client library. |
| `control` | `crates/control` | Control plane (web/gRPC) for multi-node scenarios; drives the `ui/`. Owns the scenario primitives + catalogue (`scenario`/`scenarios` modules). |
| `ctl` | `crates/ctl` | Offline CLI (`roda-ctl`): pack/unpack/validate WAL segments. |
| `roda-wasm-abi` | `crates/roda-wasm-abi` | Guest-side WASM SDK/ABI: `execute!`/`register!` macros, typed `Params`, `key!` builder, safe host-verb wrappers — how WASM modules are written (ADR-026). |

`cluster` is the default workspace member, so `cargo run` starts `roda-server`.

Expand Down Expand Up @@ -111,10 +114,11 @@ Docker quick start: `docker run -p 50051:50051 -v $(pwd)/data:/app/data tislib/r

Read in order: `README.md` → `docs/01-concepts.md` → `docs/02-api.md` → `docs/03-architecture.md`
→ `docs/internal.md` → `docs/wasm-runtime.md`. Perf numbers: `docs/load.md`.
(Note: README links `docs/04-internals.md`, but the file on disk is `docs/internal.md`.)

ADRs live in `docs/adr/` (`docs/adr/README.md` indexes them, though the index table lags behind disk —
ADRs 021/022 exist but aren't listed). Status ≠ implementation state; the most load-bearing
ADRs live in `docs/adr/` (`docs/adr/README.md` indexes them, though that index lists two **phantom**
rows — ADR-024 `0024-temporal-index.md` / ADR-025 `0025-point-in-time.md` — that don't exist on disk
(numbering jumps 0023→0026→0027); `docs/adr.md` is a stale duplicate stopping at ADR-010). Status ≠
implementation state; the most load-bearing
**implemented** ones:

- **001** entries-based execution · **002** Vec balance storage · **006** WAL/snapshot/seal durability
Expand Down
9 changes: 8 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 36 additions & 6 deletions crates/control/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,12 @@ async fn main() -> anyhow::Result<()> {
let events = Arc::new(EventStore::new());
let shutdown = CancellationToken::new();

// Wire SIGINT to the cancellation token.
// Wire SIGINT/SIGTERM to the cancellation token so `kill` (containers
// send SIGTERM) tears the cluster down via RAII instead of orphaning it.
let signal_shutdown = shutdown.clone();
tokio::spawn(async move {
if let Err(e) = tokio::signal::ctrl_c().await {
tracing::warn!("failed to install SIGINT handler: {e}");
return;
}
info!("received SIGINT; initiating shutdown");
wait_for_shutdown_signal().await;
info!("received shutdown signal; initiating shutdown");
signal_shutdown.cancel();
});

Expand All @@ -97,6 +95,38 @@ async fn main() -> anyhow::Result<()> {
}
}

/// Resolve on the first SIGINT or SIGTERM so shutdown is cooperative
/// whether the process is Ctrl+C'd or `kill`ed.
#[cfg(unix)]
async fn wait_for_shutdown_signal() {
use tokio::signal::unix::{SignalKind, signal};
let mut term = match signal(SignalKind::terminate()) {
Ok(s) => s,
Err(e) => {
tracing::warn!("failed to install SIGTERM handler: {e}");
return wait_for_ctrl_c().await;
}
};
tokio::select! {
_ = wait_for_ctrl_c() => {}
_ = term.recv() => {}
}
}

#[cfg(not(unix))]
async fn wait_for_shutdown_signal() {
wait_for_ctrl_c().await;
}

/// Never resolve if the handler can't be installed, so a failure can't
/// masquerade as a shutdown request.
async fn wait_for_ctrl_c() {
if let Err(e) = tokio::signal::ctrl_c().await {
tracing::warn!("failed to install SIGINT handler: {e}");
std::future::pending::<()>().await;
}
}

/// Sane defaults so a blank cluster has something coherent to start
/// from. Mirrors the cluster's TOML rendering defaults at
/// `provisioner/process.rs::render_config_toml`.
Expand Down
27 changes: 11 additions & 16 deletions crates/ledger/src/index.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
//! In-memory transaction and account index — ADR-008 / ADR-022.
//! In-memory transaction index — ADR-008 / ADR-022.
//!
//! Three pre-allocated, fixed-size structures; zero heap allocation after
//! Two pre-allocated, fixed-size buffers; zero heap allocation after
//! construction, no cold start on segment rotation:
//!
//! `circle1` — maps `tx_id → (TxMetadata, location in circle2)` (direct-mapped)
//! `circle2` — `IndexedTxEntry` storage: each follower's raw `WalEntry`
//! stored as-is, plus the per-account `prev_link` chain
//! `account_heads` — maps `account_id → latest circle2 index` (direct-mapped)
//! `circle1` — maps `tx_id → (TxMetadata, location in circle2)` (direct-mapped)
//! `circle2` — `IndexedTxEntry` storage: each follower's raw `WalEntry` as-is
//!
//! Both circle sizes must be powers of two so modulo reduces to a bitmask.
//! Both sizes must be powers of two so modulo reduces to a bitmask. Account
//! history is served by a backward WAL scan, not this index (see `ledger.rs`).

use bytemuck::Zeroable;
use storage::EntryBuf;
Expand Down Expand Up @@ -42,11 +41,8 @@ impl Default for TxSlot {
// ── IndexedTxEntry (circle2) ──────────────────────────────────────────────────

/// One slot in `circle2`: a follower's raw `WalEntry`, stored as-is, wrapped
/// with the bookkeeping the index needs.
///
/// - `tx_id` groups the slot with its transaction and detects eviction.
/// - `prev_link` is the 1-based per-account history chain pointer — set only
/// for `WalEntry::Entry` followers; `0` otherwise and at chain ends.
/// with the bookkeeping the index needs. `tx_id` groups the slot with its
/// transaction and detects eviction.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct IndexedTxEntry {
pub record: WalEntry,
Expand Down Expand Up @@ -108,9 +104,8 @@ impl TransactionIndexer {
}
}

/// Index a committed transaction: its `TxMetadata` and all followers
/// (stored as raw `WalEntry`). `WalEntry::Entry` followers are chained into
/// the per-account `prev_link` history; all other followers are stored too.
/// Index a committed transaction: its `TxMetadata` and all followers,
/// each stored as a raw `WalEntry` in `circle2`.
pub fn insert_transaction(&mut self, meta: &TxMetadata, followers: &[WalEntry]) {
self.insert_with(meta, followers.len(), followers.iter().copied());
}
Expand Down Expand Up @@ -189,7 +184,7 @@ mod tests {
use storage::entities::{EntryKind, FailReason, WalEntryKind};

fn small() -> TransactionIndexer {
// circle1=16, circle2=64, account_heads=16 — small enough for fast tests
// circle1=16, circle2=64 — small enough for fast tests
TransactionIndexer::new(16, 64)
}

Expand Down
7 changes: 3 additions & 4 deletions crates/ledger/src/transactor/wasm_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -451,10 +451,9 @@ impl WasmRuntime {
}
}

/// Build the shared [`Linker`] with the three host imports
/// (`ledger.credit` / `ledger.debit` / `ledger.get_balance`) routed
/// directly to the transactor state. Called exactly once per
/// `WasmRuntime`.
/// Build the shared [`Linker`] with the `ledger` host verbs — balances,
/// account flags, and KV/constants (ADR-022/023) — routed to the
/// transactor state. Called once per `WasmRuntime`.
fn build_host_linker(engine: &Engine) -> Linker<WasmStoreData> {
let mut linker: Linker<WasmStoreData> = Linker::new(engine);

Expand Down
Loading
Loading