-
Notifications
You must be signed in to change notification settings - Fork 0
feat: aggregate load suite and bus idle/batch fixes #202
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -44,6 +44,93 @@ compose-up: | |
| compose-down: | ||
| $(DOCKER_COMPOSE) down $(COMPOSE_DOWN_FLAGS) | ||
|
|
||
| LOAD_MANIFEST ?= tests/load/Cargo.toml | ||
| LOAD_REPO ?= memory | ||
| LOAD_BIND ?= 127.0.0.1:8790 | ||
| LOAD_SCENARIO ?= unique-create | ||
| LOAD_CONCURRENCY ?= 32 | ||
| LOAD_DURATION ?= 15s | ||
| LOAD_WARMUP ?= 2s | ||
| LOAD_DATABASE_URL ?= $(DATABASE_URL) | ||
| LOAD_SQLITE_PATH ?= target/load.sqlite | ||
|
|
||
| LOAD_FEATURES ?= kafka,rabbitmq | ||
| LOAD_FILTER ?= | ||
| LOAD_SUITE_FLAGS ?= | ||
| LOAD_SNAPSHOTS ?= | ||
|
|
||
| .PHONY: load-host load-client load-run load-matrix load-test load-suite | ||
|
|
||
| ## Opt-in load harness (not part of `make test`). See tests/load/src/bin/*.rs --help. | ||
| load-host: | ||
| $(CARGO) run --manifest-path $(LOAD_MANIFEST) --release --bin load-host -- \ | ||
| --repo $(LOAD_REPO) --bind $(LOAD_BIND) \ | ||
| --database-url $(LOAD_DATABASE_URL) --sqlite-path $(LOAD_SQLITE_PATH) \ | ||
| $(if $(LOAD_SNAPSHOTS),--snapshots $(LOAD_SNAPSHOTS),) | ||
|
|
||
| load-client: | ||
| $(CARGO) run --manifest-path $(LOAD_MANIFEST) --release --bin load-client -- \ | ||
| --url http://$(LOAD_BIND) --scenario $(LOAD_SCENARIO) \ | ||
| --concurrency $(LOAD_CONCURRENCY) --duration $(LOAD_DURATION) \ | ||
| --warmup $(LOAD_WARMUP) --repo $(LOAD_REPO) \ | ||
| $(if $(LOAD_SNAPSHOTS),--snapshots $(LOAD_SNAPSHOTS),) | ||
|
|
||
| ## Build, start host, wait for /health, run client, stop host. | ||
| load-run: | ||
| @set -eu; \ | ||
| $(CARGO) build --manifest-path $(LOAD_MANIFEST) --release --bins; \ | ||
| host_bin="tests/load/target/release/load-host"; \ | ||
| client_bin="tests/load/target/release/load-client"; \ | ||
| if [ ! -x "$$host_bin" ]; then host_bin="target/release/load-host"; fi; \ | ||
| if [ ! -x "$$client_bin" ]; then client_bin="target/release/load-client"; fi; \ | ||
| host_args="--repo $(LOAD_REPO) --bind $(LOAD_BIND) --sqlite-path $(LOAD_SQLITE_PATH)"; \ | ||
| if [ -n "$(LOAD_DATABASE_URL)" ]; then host_args="$$host_args --database-url $(LOAD_DATABASE_URL)"; fi; \ | ||
| if [ -n "$(LOAD_SNAPSHOTS)" ]; then host_args="$$host_args --snapshots $(LOAD_SNAPSHOTS)"; fi; \ | ||
| $$host_bin $$host_args & host_pid=$$!; \ | ||
| cleanup() { kill $$host_pid >/dev/null 2>&1 || true; wait $$host_pid >/dev/null 2>&1 || true; }; \ | ||
| trap cleanup EXIT INT TERM; \ | ||
| ready=0; \ | ||
| for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do \ | ||
| if curl -sf "http://$(LOAD_BIND)/health" >/dev/null; then ready=1; break; fi; \ | ||
| sleep 0.25; \ | ||
| done; \ | ||
| if [ "$$ready" != "1" ]; then echo "load-host did not become healthy at http://$(LOAD_BIND)/health" >&2; exit 1; fi; \ | ||
| client_args="--url http://$(LOAD_BIND) --scenario $(LOAD_SCENARIO) \ | ||
| --concurrency $(LOAD_CONCURRENCY) --duration $(LOAD_DURATION) \ | ||
| --warmup $(LOAD_WARMUP) --repo $(LOAD_REPO)"; \ | ||
| if [ -n "$(LOAD_SNAPSHOTS)" ]; then client_args="$$client_args --snapshots $(LOAD_SNAPSHOTS)"; fi; \ | ||
| $$client_bin $$client_args | ||
|
|
||
| ## Compare memory, sqlite, and postgres (postgres needs `make compose-up` or a live DATABASE_URL). | ||
| load-matrix: | ||
| @set -eu; \ | ||
| for repo in memory sqlite postgres; do \ | ||
| echo "======== $$repo / $(LOAD_SCENARIO) ========"; \ | ||
| $(MAKE) load-run LOAD_REPO=$$repo LOAD_SCENARIO=$(LOAD_SCENARIO) \ | ||
| LOAD_CONCURRENCY=$(LOAD_CONCURRENCY) LOAD_DURATION=$(LOAD_DURATION) \ | ||
| LOAD_WARMUP=$(LOAD_WARMUP) LOAD_BIND=$(LOAD_BIND); \ | ||
| done | ||
|
|
||
| load-test: | ||
| $(CARGO) test --manifest-path $(LOAD_MANIFEST) | ||
|
|
||
| ## Full Counter suite: every dispatch, bus (incl. kafka/rabbitmq), lock, snapshot, scenario. | ||
| ## make compose-up && make load-suite | ||
| ## make load-suite LOAD_FILTER=direct LOAD_DURATION=3s | ||
| ## make load-run LOAD_SNAPSHOTS=10 | ||
| load-suite: | ||
| DATABASE_URL="$(LOAD_DATABASE_URL)" \ | ||
| NATS_URL="$(NATS_URL)" \ | ||
| KAFKA_BROKERS="$(KAFKA_BROKERS)" \ | ||
| AMQP_URL="$(AMQP_URL)" \ | ||
|
Comment on lines
+122
to
+125
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Inspect env detection for broker URLs in the load suite.
fd -t f -e rs . tests/load/src --exec rg -n -C4 'NATS_URL|KAFKA_BROKERS|AMQP_URL|env::var'Repository: hops-ops/distributed Length of output: 3445 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- Makefile recipe ---'
sed -n '108,132p' Makefile
printf '%s\n' '--- load-suite filtering and bus startup ---'
sed -n '240,305p' tests/load/src/main.rs
sed -n '430,505p' tests/load/src/main.rs
printf '%s\n' '--- all broker environment handling in load sources ---'
rg -n -C3 'NATS_URL|KAFKA_BROKERS|AMQP_URL|include_external|external buses|is_empty' tests/load/src
printf '%s\n' '--- verifier: empty values pass the current configured checks ---'
python3 - <<'PY'
import os
checks = {
"NATS_URL": os.environ.get("NATS_URL", ""),
"KAFKA_BROKERS": os.environ.get("KAFKA_BROKERS", ""),
"AMQP_URL": os.environ.get("AMQP_URL", ""),
}
for name, value in checks.items():
print(f"{name}: value={value!r}, Rust var().is_err() equivalent={value is None}")
PYRepository: hops-ops/distributed Length of output: 1285 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- load source files ---'
fd -t f . tests/load/src
printf '%s\n' '--- broker handling locations ---'
rg -n -C5 'NATS_URL|KAFKA_BROKERS|AMQP_URL|include_external|is_empty' tests/load/src
printf '%s\n' '--- relevant Rust declarations ---'
rg -n 'fn (should_skip|start|connect)|enum BusKind|struct .*Config|include_external' tests/load/srcRepository: hops-ops/distributed Length of output: 12892 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- suite execution path ---'
sed -n '300,430p' tests/load/src/suite.rs
sed -n '400,515p' tests/load/src/suite.rs
printf '%s\n' '--- bus implementations ---'
rg -n -C5 'pub (async )?fn connect|struct NatsBus|struct KafkaBus|struct RabbitBus|NatsBus|KafkaBus|RabbitBus' --glob '*.rs' .
printf '%s\n' '--- read-only verifier for Make and Rust environment semantics ---'
python3 - <<'PY'
from pathlib import Path
import re
makefile = Path("Makefile").read_text()
recipe = makefile[makefile.index("load-suite:"):makefile.index("\n\n", makefile.index("load-suite:"))]
for name in ("NATS_URL", "KAFKA_BROKERS", "AMQP_URL"):
assert re.search(rf'^{name}="\\$\\({name}\\)"', recipe, re.MULTILINE), name
# `NAME="$(NAME)"` exports NAME even when the Make variable expands to empty.
rust_is_err_for_empty = False # std::env::var(NAME) returns Ok("") when NAME exists.
print(f"{name}: recipe exports empty value; is_err()={rust_is_err_for_empty}")
PYRepository: hops-ops/distributed Length of output: 50378 Treat empty broker variables as unset.
🤖 Prompt for AI Agents |
||
| $(CARGO) run --manifest-path $(LOAD_MANIFEST) --release \ | ||
| $(if $(LOAD_FEATURES),--features $(LOAD_FEATURES),) \ | ||
| --bin load-suite -- \ | ||
| --duration $(LOAD_DURATION) --warmup $(LOAD_WARMUP) \ | ||
| --concurrency $(LOAD_CONCURRENCY) \ | ||
| $(if $(LOAD_FILTER),--filter $(LOAD_FILTER),) \ | ||
| $(LOAD_SUITE_FLAGS) | ||
|
|
||
| .PHONY: contracts-check | ||
|
|
||
| ## Read-only aggregate contract lifecycle check (never writes tracked files). | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,8 @@ | |
| use std::collections::{HashMap, VecDeque}; | ||
| use std::sync::{Arc, Mutex}; | ||
|
|
||
| use tokio::sync::Notify; | ||
|
|
||
| use super::source::{MessageSource, ReceivedMessage}; | ||
| use super::{run_source, Bus, BusConsumer, MessageRouter, RunOptions, TransportError}; | ||
| use super::{Message, OrderedDelivery}; | ||
|
|
@@ -35,6 +37,7 @@ pub struct InMemoryBus { | |
| queues: Queues, | ||
| topics: Topics, | ||
| source_epoch: ProjectionEpoch, | ||
| wake: Arc<Notify>, | ||
| } | ||
|
|
||
| impl Default for InMemoryBus { | ||
|
|
@@ -44,6 +47,7 @@ impl Default for InMemoryBus { | |
| topics: Topics::default(), | ||
| source_epoch: ProjectionEpoch::new(format!("instance-{}", uuid::Uuid::now_v7())) | ||
| .expect("an in-memory bus UUID is a valid projection source epoch"), | ||
| wake: Arc::new(Notify::new()), | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -60,6 +64,7 @@ impl InMemoryBus { | |
| .entry(message.name().to_string()) | ||
| .or_default() | ||
| .push_back(message); | ||
| self.wake.notify_waiters(); | ||
| Ok(()) | ||
| } | ||
|
|
||
|
|
@@ -79,6 +84,7 @@ impl InMemoryBus { | |
| .entry(message.name().to_string()) | ||
| .or_default() | ||
| .push(message); | ||
| self.wake.notify_waiters(); | ||
| Ok(()) | ||
| } | ||
|
|
||
|
|
@@ -138,6 +144,7 @@ impl BusConsumer for InMemoryBus { | |
| let source = QueueSource { | ||
| queues: self.queues.clone(), | ||
| names, | ||
| wake: Arc::clone(&self.wake), | ||
| }; | ||
| run_source(router, source, options).await | ||
| } | ||
|
|
@@ -153,6 +160,7 @@ impl BusConsumer for InMemoryBus { | |
| names, | ||
| cursors: TopicCursors::default(), | ||
| source_epoch: self.source_epoch.clone(), | ||
| wake: Arc::clone(&self.wake), | ||
| }; | ||
| run_source(router, source, options).await | ||
| } | ||
|
|
@@ -162,6 +170,7 @@ impl BusConsumer for InMemoryBus { | |
| struct QueueSource { | ||
| queues: Queues, | ||
| names: Vec<String>, | ||
| wake: Arc<Notify>, | ||
| } | ||
|
|
||
| impl MessageSource for QueueSource { | ||
|
|
@@ -184,6 +193,11 @@ impl MessageSource for QueueSource { | |
| } | ||
| Ok(None) | ||
| } | ||
|
|
||
| async fn wait(&mut self) -> Result<(), TransportError> { | ||
| self.wake.notified().await; | ||
| Ok(()) | ||
| } | ||
|
Comment on lines
+197
to
+200
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
rg -n -C 4 'notify_waiters\(|notified\(\)|async fn wait|wait_when_idle' \
src/bus/in_memory_bus.rs src/bus/runner/receive_loop.rsRepository: hops-ops/distributed Length of output: 2037 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- source structure ---'
ast-grep outline src/bus/in_memory_bus.rs
printf '%s\n' '--- in-memory bus ---'
cat -n src/bus/in_memory_bus.rs | sed -n '1,280p'
printf '%s\n' '--- receive loop references ---'
rg -n -C 8 'wait_when_idle|recv\(\)|wait\(\)' src/bus/runner/receive_loop.rs src/bus
printf '%s\n' '--- Notify dependency and tests ---'
rg -n -C 5 'tokio|Notify|wait_when_idle|InMemory|TopicSource|QueueSource' Cargo.toml Cargo.lock src tests 2>/dev/null | head -n 500Repository: hops-ops/distributed Length of output: 50376 🌐 Web query:
💡 Result: In the Tokio library, the Citations:
Prevent lost wakeups in both in-memory sources. When 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| /// Fan-out source over the named retained logs: each `TopicSource` has its own | ||
|
|
@@ -193,6 +207,7 @@ struct TopicSource { | |
| names: Vec<String>, | ||
| cursors: TopicCursors, | ||
| source_epoch: ProjectionEpoch, | ||
| wake: Arc<Notify>, | ||
| } | ||
|
|
||
| impl MessageSource for TopicSource { | ||
|
|
@@ -238,6 +253,11 @@ impl MessageSource for TopicSource { | |
| } | ||
| Ok(None) | ||
| } | ||
|
|
||
| async fn wait(&mut self) -> Result<(), TransportError> { | ||
| self.wake.notified().await; | ||
| Ok(()) | ||
| } | ||
| } | ||
|
|
||
| struct TopicSettlement { | ||
|
|
@@ -375,10 +395,12 @@ mod tests { | |
| let mut a = QueueSource { | ||
| queues: bus.queues.clone(), | ||
| names: vec!["work".to_string()], | ||
| wake: Arc::clone(&bus.wake), | ||
| }; | ||
| let mut b = QueueSource { | ||
| queues: bus.queues.clone(), | ||
| names: vec!["work".to_string()], | ||
| wake: Arc::clone(&bus.wake), | ||
| }; | ||
| let mut got = Vec::new(); | ||
| // Alternate; each pop removes the message (competing). | ||
|
|
@@ -437,6 +459,7 @@ mod tests { | |
| names: vec!["evt".into()], | ||
| cursors: TopicCursors::default(), | ||
| source_epoch: bus.source_epoch.clone(), | ||
| wake: Arc::clone(&bus.wake), | ||
| }; | ||
|
|
||
| let first = block_on(source.recv()).unwrap().unwrap(); | ||
|
|
@@ -482,6 +505,7 @@ mod tests { | |
| names: vec!["evt".into()], | ||
| cursors: TopicCursors::default(), | ||
| source_epoch: bus.source_epoch.clone(), | ||
| wake: Arc::clone(&bus.wake), | ||
| }; | ||
| let received = block_on(source.recv()).unwrap().unwrap(); | ||
| assert_eq!(received.message().id(), Some("e0")); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Guard
--database-urlinload-hostwhen the value is empty.LOAD_DATABASE_URLdefaults to$(DATABASE_URL), which is often unset. The recipe then expands to--database-url --sqlite-path target/load.sqlite.parse_argsintests/load/src/bin/load-host.rstakes the next token as the value, soconfig.database_urlbecomes"--sqlite-path"and the SQLite path argument is consumed. Theload-runtarget already guards this with[ -n ... ]; apply the same guard here and inload-client.🛠️ Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents