Local dev tooling for Raft + QueuedTransactor refactor - #1414
Conversation
| } | ||
|
|
||
| fn next_wide_fanout(&self, idx: u64) -> Option<Op> { | ||
| let creates_so_far = self.ledgers.len() as u64; |
There was a problem hiding this comment.
Create cap counts landed ledgers (ledgers.len()), but the create decision fires on every idx multiple. Under create latency + concurrency, many creates dispatch before any lands → overshoots --target-ledger-count. Count dispatched creates via an atomic instead.
| // next target is conservative but useful: it stops a single | ||
| // election from sinking every request in flight. | ||
| let outcome = if matches!(outcome, Outcome::LeaderChange) { | ||
| self.record_outcome(idx, outcome); |
There was a problem hiding this comment.
First-attempt LeaderChange is recorded only into target health; only the retry outcome reaches metrics. A leader-change that succeeds on retry is booked as Success, so the per-outcome breakdown undercounts leader-change — the signal this harness exists to measure.
| IdempotencyMode::Anonymous | IdempotencyMode::Unique => idx, | ||
| IdempotencyMode::Pooled => idx % self.tuning.idempotency_pool_size.max(1), | ||
| }; | ||
| let subject_id = format!("http://load.fluree/{}/s{}", self.run_id, body_seed); |
There was a problem hiding this comment.
Pooled key and body derive from global idx only, never the target ledger. In wide-fanout/multitenant the same (key, body) hits different ledgers → cross-ledger false dedup, or the "first pool_size ops per ledger fill the cache" claim breaks. Scope the slot/body by ledger.
| if s.len() <= max { | ||
| s.to_string() | ||
| } else { | ||
| format!("{}…", &s[..max - 1]) |
There was a problem hiding this comment.
&s[..max - 1] is a byte slice — a user-supplied --seeded-ledger name >40 bytes with a multi-byte char on the boundary panics at end-of-run. Use char-safe truncation.
| if !counts { | ||
| return; | ||
| } | ||
| let prev = health.consecutive_failures.fetch_add(1, Ordering::Relaxed); |
There was a problem hiding this comment.
consecutive_failures is never reset after the blacklist window expires, so a recovered target re-blacklists on a single failure instead of threshold consecutive ones — asymmetric with the "consecutive" contract.
| // separates them. | ||
| let body = resp.text().await.unwrap_or_default(); | ||
| let lower = body.to_ascii_lowercase(); | ||
| if lower.contains("not the leader") || lower.contains("leader") { |
There was a problem hiding this comment.
contains("leader") is checked before the overload branch, so a 503 body mentioning both is misclassified as LeaderChange and retried. ("not the leader" is also already subsumed by "leader".)
| }; | ||
| let op_kind = op.kind; | ||
| let ledger = op.ledger.clone(); | ||
| issued.fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
There was a problem hiding this comment.
issued is incremented before dispatch, and check-then-fetch_add across workers overshoots the cap by up to concurrency; a shutdown mid-select! can also count an op that never ran, so issued can exceed recorded total.
| for i in $(seq 1 "$node_count"); do | ||
| local url | ||
| url="$(node_host_public_url "$i")/health" | ||
| while ! curl -fsS -m 2 "$url" >/dev/null 2>&1; do |
There was a problem hiding this comment.
Readiness is checked only on the public /health listener, but bootstrap dials the raft listener (9090). A node whose public port is up before its raft listener/alias is ready can fail add-learner. Verify raft readiness too before bootstrap.
| # alongside node_id and blocking, not nested under `addrs`. | ||
| local add_body | ||
| add_body="{\"node_id\":$i,\"raft_addr\":\"$(node_peer_raft_url "$i")\",\"client_addr\":\"$(node_peer_client_url "$i")\",\"blocking\":true}" | ||
| curl -fsS -X POST -H 'Content-Type: application/json' \ |
There was a problem hiding this comment.
No -m timeout and no surrounding deadline (unlike the /health loop). With "blocking":true, a not-yet-ready or hung raft peer makes up hang indefinitely with no diagnostic rather than erroring. Same for the initialize/change-membership calls.
| local promote_body | ||
| promote_body="{\"members\":[$members_json],\"retain\":false}" | ||
| curl -fsS -X POST -H 'Content-Type: application/json' \ | ||
| -d "$promote_body" "$leader_raft_url/cluster/change-membership" >/dev/null |
There was a problem hiding this comment.
change-membership result is not verified — if the voter set fails to converge, up still reports success. A post-bootstrap /cluster/status sanity check would surface a broken quorum here rather than later.
| --tail) | ||
| [ $# -ge 2 ] || die "--tail requires an argument" | ||
| tail_lines="$2"; shift 2 ;; | ||
| [1-9]|[1-9][0-9]) |
There was a problem hiding this comment.
Node-id pattern caps at 99. --nodes allows ≥100, so logs 100 falls through to *) and dies with "Unknown argument". Use a numeric check like the other node-indexed commands.
Adds a Docker-based local deployment orchestrator (
scripts/local/stack) and an HTTP load harness (scripts/local/load/) for exercising the Raft consensus path — bringing up a single-node monolithic or N-node Raft cluster, injecting faults, driving configurable read/write workloads, and correlating metrics with consensus events. Also lands one consensus-layer refactor: a helper (QueuedTransactor::enqueue_and_await) that consolidates the five-way scaffolding acrossCommittermethods and locks four previously-per-method invariants in one place.What's in this PR
1.
fluree-db-consensus: extractenqueue_and_awaitfluree-db-consensus/src/raft/queued_transactor.rs— net -56 LOC (+114 / -170).Each of the five
Committermethods (transact,revert,merge,rebase,push) was hand-rolling the same ~30-line pipeline: build theQueuedRequestenvelope →serde_json::to_vec→content_store.put→ derivecanonical_body_cid→ sampleapplied_at_millis→ buildQueueSubmission→submit_and_await. Extracting this intoenqueue_and_awaitcollapses the middle of each method to a single call and structurally enforces four invariants that were previously enforced by code proximity:body_cidis computed from the same envelope bytes that producedrequest_cid(so the state machine's body-hash dedup stays honest).applied_at_millisis sampled once per submission (not once per derived field, which could mis-dedup on a slow build).idempotency_cache_keyis always built from the canonicalformat_ledger_id(name, branch)form (not a hand-rolledformat!).retry_eligiblemirrorsidempotency_key.is_some()(a divergence here would either spam Raft with non-idempotent retries or fail to retry idempotent ones).Per-op preprocessing (raw-txn upload resolution on
transact, per-commit CAS uploads onpush, merge-target resolution onmerge) stays at the call site — only the envelope-to-outcome plumbing is shared. Call sites now read as straight-line code: build the envelope, callenqueue_and_await, match the outcome. No closures, no inverted control flow.Behavior preserved:
--features raftstill pass.queued_transactor::tests(status-code mapping for snapshot-installed / branch-reset / purged / dropped / poisoned) still pass.single_node_round_tripintegration tests (5) still pass.cargo fmt --checkandcargo clippyboth clean.2.
scripts/local/stack— Docker-based deployment orchestratorSingle bash entry point at
scripts/local/stackwith subcommand dispatch. ~1080 lines.Deployment modes:
--mode monolithic(default): one Fluree server, no consensus.--mode raft --nodes N(default 3): full Raft cluster with automatic bootstrap via/cluster/initialize+/cluster/add-learner+/cluster/change-membership.Storage modes:
--storage ephemeral(default): docker named volumes, wiped ondown.--storage persistent: bind-mount to./data/, survivesdown --keep-data.Raft-mode-specific: nodes share a single
shared-datadocker volume mounted at/var/lib/fluree/data, while raft log state stays per-node (fluree-N-raftat/var/lib/fluree/raft). This mirrors the production topology where a Raft cluster sits over shared object storage — followers must be able to read commit envelopes the leader-owned worker wrote. Volume layout is annotated in the generator with the rationale.Commands:
up [flags]down [--keep-data]statuslogs [N] [-f] [--tail LINES]load [args...]kill <N>(SIGKILL)restart <N>pause <N>(SIGSTOP — unresponsive but alive)unpause <N>(SIGCONT)partition <N>(docker network disconnect — split-brain)heal <N>(docker network connect --alias)Configuration is picked up from env vars:
DEFAULT_RAFT_NODES,PUBLIC_PORT_BASE,RAFT_PORT_BASE,FLUREE_LOCAL_STORAGE. All can also be overridden viaupflags.Mode is remembered in
compose.generated.ymlas a header comment (# Mode: raft,# Nodes: 3); every subsequent command reads it viaread_modeand adapts (e.g.statusskips the/cluster/statussection in monolithic mode;partition/healexplicitly reject with"partition" only applies to --mode raft (current: monolithic)"). Future consensus modes (bft, paxos, etc.) can be added by adding a new--modevalue and a per-modeemit_servicebranch — the command surface stays the same.3.
scripts/local/load/— HTTP load harness (new crate)Standalone Rust crate at
scripts/local/load/(excluded from the workspace so it doesn't pollutecargo build/cargo testfrom root). ~2100 LOC across ~10 source files.Same tool against either backend — the URL list is the only thing that changes:
--addrs http://localhost:8091--addrs http://localhost:8091,http://localhost:8092,http://localhost:8093--addrs https://fluree-lb.internalWorkload shapes:
single-poundCreateLedgerat t=0, then transact-only. Baseline single-queue ceiling.create-onlyCreateLedgerstream.Command::CreateLedgerapply throughput in isolation.transact-only--seeded-ledgernames. Pre-seeded steady state.query-only--seeded-ledgernames. Local read path (no consensus), read availability during chaos.mixed-rw--mixed-write-every, default 5). Read/write concurrency on the same ledger.wide-fanoutmultitenantCreateLedger, rest transact. Multi-tenant onboarding, ledger-count scaling.Idempotency modes (
--idempotency-mode, orthogonal to workload):anonymous(default)uniqueCachingCommitterrecords outcomes + state machine writesApplyRecords, but nothing dedupspooled--idempotency-pool-size(default 100); body deterministic-per-slotCluster-aware routing:
--addrslist.503 leader-change, retry against the next address once.--blacklist-window(default 5s) cool-off before it's eligible again.client-error) doesn't trigger the cool-off — it's a request-side signal, not a target-health one.Metrics:
success,idempotency-hit,leader-change,overloaded,timeout,network-error,client-error,server-error.--watch-clusterannotation:failover: now polling <url>line.fluree-db-consensus/src/raft/ownership.rs— how many currently-known ledger main-branch owners would reassign ("14/47 known ledger main-branch owners reassigned"). Mirror is unit-tested for behavioral parity with the consensus crate.Plumbed into the stack script as
./stack load [...]— auto-populates--addrsfrom the running compose file and builds the tool on first invocation.How to try it
./stack helpfor the full command list;./stack help <command>for per-command details;./stack load --tool-helpfor the load tool's own--help.What's intentionally not in this PR
Named honestly so they're on the follow-up radar:
stack verifymode (consistency assertions during / after chaos: one leader at all times, followers converge to bounded lag, receipts match applied state). The tool right now is a load-and-chaos harness; it's not yet a validator.stack scenario <name>(deterministic chaos timelines via TOML for bug reproduction).add-learner/change-membershipduring load).CreateBranch/Push/Revert/Merge/Rebaseworkloads. Load harness op vocabulary isCreateLedger+Transact+Query.X-Fluree-Min-Tbounded-wait consistency mode on the load harness.stack compare).