Skip to content

Commit 8447a0c

Browse files
Minipadaclaude
andcommitted
test(dc_bridge,e2e): prove the pipeline under degraded network conditions (#366)
Extends the Forwarder's mock ingest peer with a test-only acknowledgement policy (delay, never-ack probability, throttled drain) covering unacked window depth, backpressure classification, and resend-under-delay at the existing unit-test seam -- no container needed. Adds a sibling E2E scenario, run_degraded.sh, that shapes the path to Postgres/RustFS with tc netem (via a netns-joining helper container, verified to work unprivileged under rootless podman) behind a mandatory shaping pre-check, induces a link-goes-away-without-the-process-dying fault distinct from the existing outage scenario, and reuses verify_zero_loss.py unmodified (only an additive --conditions-file flag). Named network profiles live in one declarative place (network_profiles.py) shared by the scenario and, later, #323's harness. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TryRM4gK1GQE8uCDE4iPhX Signed-off-by: David Bensoussan <d.bensoussan@proton.me>
1 parent 433cb49 commit 8447a0c

11 files changed

Lines changed: 1582 additions & 0 deletions

dc_bridge/test/forwarder_test.cpp

Lines changed: 458 additions & 0 deletions
Large diffs are not rendered by default.

progress.txt

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7691,3 +7691,82 @@ the one-way `qrcodes_waypoint_follower.py` patrol never actually returns to the
76917691
above is demonstrated by manual driving, not the autonomous demo — the issue's acceptance
76927692
criteria ask for the capability and documented rates, not a scripted docking sequence nav2's own
76937693
`docking_server` config still calls a "never docks" placeholder for.
7694+
7695+
## #366 - Prove the pipeline under degraded network conditions: delayed acks and a shaped Destination link
7696+
7697+
Every DC reliability claim to date was proven over intra-container loopback: microsecond RTT,
7698+
zero loss, unbounded bandwidth, and a fault model where a Destination is either up or a stopped
7699+
process. Real robots reach their Destinations over WiFi and a site uplink. This closes that gap
7700+
at the two seams the PRD identified, without changing anything about the pipeline being measured.
7701+
7702+
**Forwarder acknowledgement-policy mock peer (`dc_bridge/test/forwarder_test.cpp`).** The
7703+
existing mock shipper ingest peer gains a test-only `AckPolicy` (ack delay, a probability of
7704+
never acknowledging, a throttled drain rate) and a `run_ack_policy_peer()` driver built on a real
7705+
`msgpack::unpacker` — same parsing discipline as Forwarder's own `drain_acks()`, so a drain rate
7706+
that splits one frame across several `recv()` calls still parses correctly. Five new gtest cases:
7707+
unacked window depth stays small (not the shipped 10000-record bound) at a realistic 100 Hz/50ms
7708+
rate-delay combination; the same bound is exceeded by rate-vs-delay alone (not just a peer that
7709+
never acks at all) and still converges once the burst ends; a slow-draining peer is classified as
7710+
Backpressure with the connection retained, distinguishing it from a peer that's gone (Io, drops
7711+
the connection); a resend past `ack_timeout` duplicates the chunk on the wire and the window still
7712+
clears once the delayed original ack lands; an out-of-order ack evicts exactly the record it names
7713+
(pinned via a 22-char placeholder chunk id — `generate_chunk_id()` base64-encodes 16 bytes without
7714+
padding, always that length regardless of content).
7715+
7716+
Two real bugs surfaced building this, both in the *test* harness, not Forwarder: the initial
7717+
`run_ack_policy_peer` used a blocking `recv()` with no timeout, so once a test's client went idle
7718+
the peer thread never re-checked its `stop` flag and `srv.join()` hung forever (fixed with a
7719+
100ms `SO_RCVTIMEO`); and three tests used `ASSERT_*` ahead of `stop.store(true); srv.join()`,
7720+
so a failing assertion returned early without joining a still-running `std::thread`, which
7721+
`std::terminate()`s the whole test binary on destruction — fixed by demoting those to `EXPECT_*`
7722+
with guarded follow-on checks, so one failing assertion reports as one failing test instead of
7723+
aborting the entire suite. Built and run 5x against a scoped rebuild (dc_common + dc_bridge
7724+
mounted over `localhost/dc-workspace:latest`, since a full `colcon build` wasn't warranted for a
7725+
test-only change) — 17/17 passing, no flakiness, after `prek`'s `clang-format` reformatted the new
7726+
code.
7727+
7728+
**Network profile definitions (`tools/e2e/scripts/network_profiles.py`).** One declarative place
7729+
for named, fixed conditions — `loopback` (unshaped), `good-wifi`, `poor-wifi`, `site-uplink` — each
7730+
a `delay_ms`/`jitter_ms`/`loss_pct`/optional `rate_kbit`, with a CLI (`--shell` for a bash `eval`,
7731+
`--tc-args` for a `tc qdisc ... netem` argument list, `--json`). An unknown name is a `ValueError`
7732+
naming the known profiles, never a silent default. 10 pytest cases
7733+
(`tools/e2e/test/test_network_profiles.py`).
7734+
7735+
**`tools/e2e/scripts/run_degraded.sh` — the E2E sibling scenario.** Reuses `run.sh`'s image,
7736+
reference workload shape, and `verify_zero_loss.py` unmodified, but Postgres/RustFS run on their
7737+
own bridge network (`dc_e2e_deg_net`) instead of `--network host`, since nothing can be shaped
7738+
without shaping the host itself under `--network host`. `params/e2e_degraded_params.yaml` is
7739+
byte-for-byte `e2e_params.yaml` except the two Destination hosts (container-name DNS instead of
7740+
`127.0.0.1`) — the Bridge-to-Shipper socket stays same-container loopback and out of scope, per
7741+
the PRD.
7742+
7743+
Traffic shaping was the PRD's stated single largest technical risk and was established first,
7744+
empirically, before anything was built on it: rootless podman + `--cap-add=NET_ADMIN` on a
7745+
short-lived helper container that joins a target's network namespace (`--network
7746+
container:<name>`) can add a `tc netem` qdisc to that target's `eth0` with **no capability
7747+
required on the target container itself** — verified with disposable alpine containers (a peer's
7748+
measured RTT rose from <1ms to ~128ms after `netem delay 100ms 20ms loss 5%` was applied to the
7749+
target's own interface from the joining helper). `run_degraded.sh` applies this via a
7750+
`SHAPER_IMAGE` (alpine + iproute2, built once and cached) and `set_qdisc`/`clear_qdisc` helpers
7751+
that track per-container qdisc state to choose `tc qdisc add` vs `change`. A shaping pre-check
7752+
(`scripts/measure_rtt.py` — stdlib TCP connect-time sampling, since the Postgres/RustFS images
7753+
ship neither `ping` nor `iputils`) measures the actual round trip before any workload runs and
7754+
hard-fails the run if it isn't consistent with the requested profile, so a degraded run can never
7755+
silently pass as loopback. A LINK fault (`tc netem loss 100%` on both Destinations, then restored)
7756+
is induced partway through the run — the network disappearing without either process dying, a
7757+
fault type distinct from `run.sh`'s container stop/restart, which resets Forwarder state and so
7758+
tests something else. Conditions (profile + measured pre-check RTT) are written to
7759+
`tools/e2e/.run/conditions.json`; `verify_zero_loss.py` gained one additive `--conditions-file`
7760+
flag that folds that file into its report unchanged — no existing verification logic touched.
7761+
7762+
Not wired into `ci.yaml`, matching the existing precedent for `run_retention.sh`/`run_incident.sh`
7763+
(neither is CI-gating either) and the PRD's own "Out of Scope": whether this gates merges is left
7764+
for once its runtime and stability are measured. The full container-network E2E path (bridge
7765+
network + shaped Postgres/RustFS + the real DC stack under a degraded profile) was not run
7766+
end-to-end here — the shaping *mechanism* was verified empirically in isolation (see above) and
7767+
the script was built directly on that verified mechanism, but a full `run_degraded.sh` execution
7768+
needs a full workspace image build (tens of minutes) that wasn't run as part of this session; that
7769+
remains for a first real run to confirm.
7770+
7771+
`tools/e2e/README.md` documents the new scenario and its layout entries; `Containerfile.e2e` gained
7772+
`COPY` lines for `measure_rtt.py` (already needed by `run_degraded.sh`'s readiness/pre-check steps).

tools/e2e/Containerfile.e2e

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ COPY params/e2e_params.yaml /opt/e2e/e2e_params.yaml
1919
COPY params/e2e_passthrough_sink.toml /opt/e2e/e2e_passthrough_sink.toml
2020
COPY params/e2e_mcap_sink.toml /opt/e2e/e2e_mcap_sink.toml
2121
COPY scripts/mcap_summary.py /opt/e2e/mcap_summary.py
22+
# run_degraded.sh (#366): stdlib-only, run as one-off containers on its shaped bridge
23+
# network for readiness polling and the shaping pre-check (see measure_rtt.py's header).
24+
COPY scripts/measure_rtt.py /opt/e2e/measure_rtt.py
2225
COPY scripts/entrypoint.sh /opt/e2e/entrypoint.sh
2326
RUN chmod +x /opt/e2e/entrypoint.sh
2427

tools/e2e/README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,43 @@ broadcast node: that topic is the contract Measurements subscribe to, `dc_trigge
182182
its own tests for minting the event, and the broadcast node is not in `dc_bringup`'s
183183
launch yet. Not run by `ci.yaml`, same as the retention scenario.
184184

185+
## Degraded-network scenario (#366)
186+
187+
A fourth scenario, proving the zero-loss guarantee above holds under stated network
188+
conditions instead of only over intra-container loopback:
189+
190+
```sh
191+
./tools/e2e/scripts/run_degraded.sh # loopback profile (baseline)
192+
DC_E2E_PROFILE=poor-wifi ./tools/e2e/scripts/run_degraded.sh
193+
```
194+
195+
Same `dc-e2e` image and reference workload as `run.sh` (`params/e2e_degraded_params.yaml`
196+
is byte-for-byte `params/e2e_params.yaml` except for the two Destination hosts — see that
197+
file's header), but Postgres and RustFS run on their own bridge network instead of
198+
`--network host`, so the path to them can be shaped without shaping the host itself. A
199+
named profile (`scripts/network_profiles.py`: `loopback`, `good-wifi`, `poor-wifi`,
200+
`site-uplink` — one declarative place, so two runs of the same name are comparable) is
201+
applied as a `tc netem` qdisc on Postgres's and RustFS's own interfaces, via a short-lived
202+
helper container that joins their network namespace (`--network container:<name>
203+
--cap-add=NET_ADMIN`) rather than granting either image any capability itself. A shaping
204+
pre-check measures the actual TCP connect-time round trip before any workload runs
205+
(`scripts/measure_rtt.py`) and hard-fails the run if the measurement isn't consistent with
206+
the requested profile — a degraded run must never pass silently as loopback because
207+
shaping failed to apply. Partway through the run, a LINK fault (`tc netem loss 100%` on
208+
both Destinations, then restored) proves the network going away is handled distinctly from
209+
`run.sh`'s container stop/restart — nothing here resets Forwarder state. The network
210+
conditions a run used, plus the measured pre-check round trip, are written to
211+
`tools/e2e/.run/conditions.json` and folded into `verify_zero_loss.py`'s report — a result
212+
is never separable from the conditions that produced it.
213+
214+
Not wired into `ci.yaml`, same as the retention and incident scenarios below: whether it
215+
gates merges is left for once its runtime and stability are measured (#366's own "Out of
216+
Scope"). The Forwarder's own unacked-window-depth, backpressure-classification, and
217+
resend-under-delay behaviour under acknowledgement delay is covered separately and much
218+
more cheaply at `dc_bridge/test/forwarder_test.cpp`'s existing mock-ingest-peer seam (an
219+
`AckPolicy` describing when/whether a chunk gets acknowledged) — no container needed for
220+
that; see that file for the corresponding unit tests.
221+
185222
## Layout
186223

187224
- `Containerfile` — builds the full DC workspace (every `dc_*` package, all C++ since
@@ -260,6 +297,14 @@ launch yet. Not run by `ci.yaml`, same as the retention scenario.
260297
`dc-e2e` image.
261298
- `params/e2e_incident_params.yaml` / `scripts/run_incident.sh` — the incident-capture
262299
scenario (#291) described above, likewise reusing the same `dc-e2e` image.
300+
- `params/e2e_degraded_params.yaml` / `scripts/run_degraded.sh` — the degraded-network
301+
scenario (#366) described above; a fourth sibling harness, reusing the same `dc-e2e`
302+
image and `verify_zero_loss.py`, but its own bridge network so the path to the
303+
Destinations can be shaped.
304+
- `scripts/network_profiles.py` — the named network conditions (#366) `run_degraded.sh`
305+
applies, in one declarative place also meant for #323's limits harness to reuse.
306+
- `scripts/measure_rtt.py` — stdlib TCP connect-time sampler (#366), used both for
307+
Destination readiness polling and the shaping pre-check `run_degraded.sh` never skips.
263308

264309
## `.dockerignore`
265310

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
# SPDX-FileCopyrightText: 2022-2026 David Bensoussan
2+
# SPDX-License-Identifier: MPL-2.0
3+
4+
# The degraded-network scenario's (#366) params: byte-for-byte params/e2e_params.yaml
5+
# except for the two Destination hosts, which name containers on run_degraded.sh's own
6+
# bridge network (dc_e2e_deg_net) instead of 127.0.0.1 — the shaped participants can't
7+
# share run.sh's --network host, or nothing could be shaped without shaping the host
8+
# itself (see run_degraded.sh's header). vector_forward_host and tcp_health.host stay
9+
# 127.0.0.1: that socket is the Bridge-to-Shipper path, which never leaves the
10+
# container's own network namespace (the Bridge forks and supervises the Shipper as a
11+
# child process) and is out of scope for this PRD — see #366's "Out of Scope".
12+
13+
measurement_server:
14+
ros__parameters:
15+
save_local_base_path: "$HOME/.dc/e2e/data/%Y/%M/%D/%H"
16+
all_base_path: "e2e"
17+
measurement_plugins: ["memory", "os", "storage", "uptime", "tcp_health", "dummy", "synth00", "synth01", "synth02", "synth03", "synth04", "synth05", "synth06", "synth07", "synth08", "synth09", "synth10", "synth11", "synth12", "synth13", "camera"]
18+
19+
memory:
20+
plugin: "dc_measurements/Memory"
21+
polling_interval: 200
22+
group_key: "memory"
23+
24+
os:
25+
plugin: "dc_measurements/OS"
26+
polling_interval: 1000
27+
group_key: "os"
28+
29+
storage:
30+
plugin: "dc_measurements/Storage"
31+
polling_interval: 1000
32+
path: "/tmp"
33+
group_key: "storage"
34+
35+
uptime:
36+
plugin: "dc_measurements/Uptime"
37+
polling_interval: 1000
38+
group_key: "uptime"
39+
40+
tcp_health:
41+
plugin: "dc_measurements/TCPHealth"
42+
polling_interval: 1000
43+
name: "vector_forward"
44+
host: "127.0.0.1"
45+
port: 24224
46+
group_key: "tcp_health"
47+
48+
dummy:
49+
plugin: "dc_measurements/Dummy"
50+
polling_interval: 1000
51+
group_key: "dummy"
52+
53+
camera:
54+
plugin: "dc_measurements/Camera"
55+
polling_interval: 15000
56+
cam_name: "e2e_camera"
57+
cam_topic: "/dc/e2e/camera/image_raw"
58+
draw_det_barcodes: false
59+
save_raw_img: true
60+
save_rotated_img: false
61+
save_detections_img: false
62+
save_raw_base64: false
63+
save_rotated_base64: false
64+
save_inspected_base64: false
65+
remote_keys: ["rustfs"]
66+
remote_prefixes: ["camera"]
67+
group_key: "camera"
68+
69+
synth00:
70+
plugin: "dc_measurements/StringStamped"
71+
enable_validator: false
72+
polling_interval: 1000
73+
topic: "/dc/e2e/synth/synth00"
74+
timer_based: true
75+
76+
synth01:
77+
plugin: "dc_measurements/StringStamped"
78+
enable_validator: false
79+
polling_interval: 1000
80+
topic: "/dc/e2e/synth/synth01"
81+
timer_based: true
82+
83+
synth02:
84+
plugin: "dc_measurements/StringStamped"
85+
enable_validator: false
86+
polling_interval: 1000
87+
topic: "/dc/e2e/synth/synth02"
88+
timer_based: true
89+
90+
synth03:
91+
plugin: "dc_measurements/StringStamped"
92+
enable_validator: false
93+
polling_interval: 1000
94+
topic: "/dc/e2e/synth/synth03"
95+
timer_based: true
96+
97+
synth04:
98+
plugin: "dc_measurements/StringStamped"
99+
enable_validator: false
100+
polling_interval: 1000
101+
topic: "/dc/e2e/synth/synth04"
102+
timer_based: true
103+
104+
synth05:
105+
plugin: "dc_measurements/StringStamped"
106+
enable_validator: false
107+
polling_interval: 1000
108+
topic: "/dc/e2e/synth/synth05"
109+
timer_based: true
110+
111+
synth06:
112+
plugin: "dc_measurements/StringStamped"
113+
enable_validator: false
114+
polling_interval: 1000
115+
topic: "/dc/e2e/synth/synth06"
116+
timer_based: true
117+
118+
synth07:
119+
plugin: "dc_measurements/StringStamped"
120+
enable_validator: false
121+
polling_interval: 1000
122+
topic: "/dc/e2e/synth/synth07"
123+
timer_based: true
124+
125+
synth08:
126+
plugin: "dc_measurements/StringStamped"
127+
enable_validator: false
128+
polling_interval: 1000
129+
topic: "/dc/e2e/synth/synth08"
130+
timer_based: true
131+
132+
synth09:
133+
plugin: "dc_measurements/StringStamped"
134+
enable_validator: false
135+
polling_interval: 1000
136+
topic: "/dc/e2e/synth/synth09"
137+
timer_based: true
138+
139+
synth10:
140+
plugin: "dc_measurements/StringStamped"
141+
enable_validator: false
142+
polling_interval: 1000
143+
topic: "/dc/e2e/synth/synth10"
144+
timer_based: true
145+
146+
synth11:
147+
plugin: "dc_measurements/StringStamped"
148+
enable_validator: false
149+
polling_interval: 1000
150+
topic: "/dc/e2e/synth/synth11"
151+
timer_based: true
152+
153+
synth12:
154+
plugin: "dc_measurements/StringStamped"
155+
enable_validator: false
156+
polling_interval: 1000
157+
topic: "/dc/e2e/synth/synth12"
158+
timer_based: true
159+
160+
synth13:
161+
plugin: "dc_measurements/StringStamped"
162+
enable_validator: false
163+
polling_interval: 1000
164+
topic: "/dc/e2e/synth/synth13"
165+
timer_based: true
166+
167+
dc_bridge:
168+
ros__parameters:
169+
shipper:
170+
data_dir: "$HOME/.dc/e2e/buffer"
171+
destinations: ["pgsql_records", "pgsql_files", "rustfs", "raw_file"]
172+
pgsql_records:
173+
type: postgres
174+
receives: records
175+
inputs: ["/dc/measurement/memory", "/dc/measurement/os", "/dc/measurement/storage", "/dc/measurement/uptime", "/dc/measurement/tcp_health", "/dc/measurement/dummy", "/dc/measurement/synth00", "/dc/measurement/synth01", "/dc/measurement/synth02", "/dc/measurement/synth03", "/dc/measurement/synth04", "/dc/measurement/synth05", "/dc/measurement/synth06", "/dc/measurement/synth07", "/dc/measurement/synth08", "/dc/measurement/synth09", "/dc/measurement/synth10", "/dc/measurement/synth11", "/dc/measurement/synth12", "/dc/measurement/synth13"]
176+
# dc_e2e_deg_postgres, not 127.0.0.1: run_degraded.sh's shaped bridge network
177+
# resolves it via container-name DNS (podman's aardvark-dns on user-defined
178+
# networks) — see this file's header.
179+
host: "dc_e2e_deg_postgres"
180+
port: 5432
181+
user: "dc"
182+
password: "password"
183+
database: "dc"
184+
table: "dc_records"
185+
time_key: "date"
186+
187+
pgsql_files:
188+
type: postgres
189+
receives: records
190+
host: "dc_e2e_deg_postgres"
191+
port: 5432
192+
user: "dc"
193+
password: "password"
194+
database: "dc"
195+
table: "dc_files"
196+
time_key: "date"
197+
198+
rustfs:
199+
type: s3
200+
receives: files
201+
inputs: ["/dc/measurement/camera"]
202+
bucket: "dc-e2e"
203+
endpoint: "http://dc_e2e_deg_rustfs:9000"
204+
region: "us-east-1"
205+
access_key_id: "rustfsadmin"
206+
secret_access_key: "rustfsadmin"
207+
force_path_style: true
208+
209+
raw_file:
210+
type: file
211+
receives: records
212+
path: "/root/.dc/e2e/data/raw/records.ndjson"
213+
time_key: "date"
214+
215+
raw:
216+
enabled: true
217+
destination: "raw_file"
218+
include: ["^/dc/e2e/synth/synth00$"]
219+
exclude: ["^/rosout$", "^/parameter_events$", "^/dc/measurement/", "^/dc/group/"]
220+
exclude_types: ["^sensor_msgs/msg/(Image|CompressedImage|PointCloud2)$"]
221+
rescan_interval_secs: 1.0
222+
max_rate_hz: 10.0
223+
224+
files:
225+
delete_when_sent: false
226+
metadata_destination: "pgsql_files"
227+
228+
custom_config_files: ["/opt/e2e/e2e_passthrough_sink.toml", "/opt/e2e/e2e_mcap_sink.toml"]
229+
230+
# Same-container loopback (Bridge-to-Shipper) — not shaped, see this file's header.
231+
vector_forward_host: "127.0.0.1"
232+
vector_forward_port: 24224
233+
234+
lifecycle_manager_dc:
235+
ros__parameters:
236+
node_names: ["measurement_server"]
237+
transitions: [configure, activate]

0 commit comments

Comments
 (0)