WordPress Primary-Backup Throughput Investigation
This document records the full investigation into XDN Primary-Backup (PB) throughput with WordPress on CloudLab. CLAUDE.md keeps a condensed summary; this file holds the complete log (hypotheses eliminated, working fixes, and code changes).
Setup
- Start the XDN cluster with
gpServer.sh, usingconf/gigapaxos.xdn.wordpress-pb.cloudlab.properties. - Always
forceclearthe cluster when encountering an error. - Change the logging level in the investigated class for better clarity.
- Deploy the service with the
xdncommand usingxdn-cli/examples/wordpress.yaml. - WordPress uses a primary-backup replica group: a single primary and several backups. Only the primary node runs the containerized service.
Performance Baselines (CloudLab 3-way replication, WordPress XML-RPC editPost)
- XDN PB: ~1500 rps peak throughput (~1200 rps at low latency ≤16ms avg)
- OpenEBS: ~800 rps peak throughput
- MySQL Semi-Sync Replication: ~500 rps peak throughput
PB Request Pipeline
Client → HTTP frontend (Netty) → writePool dispatch → PBReplicaCoordinator
→ PBM queue → PBM worker thread → XdnGigapaxosApp.execute(XdnHttpRequestBatch)
→ forwardHttpRequestBatchToContainerizedService → WordPress container (via FUSE)
→ doneQueue → capture thread → captureStateDiff → Paxos propose → commit callback
→ async response to client
Key Benchmark Scripts
eval/investigate_wp_bottleneck.py— Per-rate isolation benchmark (forceclear+redeploy between rate points)eval/investigate_wordpress_pb.py— Base module with cluster management,GP_JVM_ARGStuningeval/get_latency_at_rate.go— Go load generator with Poisson arrivals- Workload: XML-RPC
wp.editPostediting 200 pre-seeded posts in round-robin
PB Throughput Investigation (2026-03-11) — RESOLVED
Goal: XDN PB must beat OpenEBS (~800 rps) and MySQL semi-sync (~500 rps).
Result: XDN PB achieves ~1500 rps (peak) and ~1200 rps at low latency (≤16ms avg), beating OpenEBS by ~2x and semi-sync by ~3x.
Root Cause
WordPress Apache sends Connection: close in HTTP responses. XDN's HTTP proxy (HttpActiveReplica) forwarded this header unchanged to the Go load-test client, causing TCP connection teardown after every request. Under sustained load, ephemeral ports accumulated in TIME_WAIT state (~18,608 observed), causing "cannot assign requested address" errors and a hard ~500 rps ceiling. This was not an XDN-internal bottleneck — it affected every code path equally (PBM, DDE, BYPASS_COORDINATOR).
Working Fixes
-
Connection header override (root cause fix) —
HttpActiveReplica.javalines 1251-1261: The proxy now overrides the container'sConnectionheader with the client's keep-alive preference. Without this, containers that sendConnection: closeforce the client to tear down TCP connections after every request. -
ByteBuf copy() instead of retainedDuplicate() —
XdnGigapaxosApp.javaline 2368:retainedDuplicate()shares refCnt with the original ByteBuf. WhenXdnHttpForwarderClientretries (MAX_RETRIES=1), eachexecuteOnce()consumes net -1 from the shared refCnt, causingIllegalReferenceCountException. Fix: usecopy()for independent refCnt. -
Fully async dispatch —
HttpActiveReplica.java: Replaced blockingfuture.get()on writePool threads with a fully async pattern. Key components: ResponseContextrecord (line 412): captures all state needed for async responsetimeoutScheduler(line 401): single daemon thread for timeout enforcementsendAsyncResponsehelper (line 451): thread-safe response writing viaAtomicBooleangate (exactly-once semantics)- Channel close listener: releases ByteBufs if client disconnects before response
- writePool threads now return in microseconds (just dispatch), making thread exhaustion impossible
Hypotheses Eliminated (No Impact on Throughput)
All hypotheses below did NOT improve throughput beyond ~500 rps. The ~500 rps ceiling persisted because the real bottleneck was the Connection: close header causing ephemeral port exhaustion (see Root Cause above).
| # | Hypothesis | Test Method | Result |
|---|---|---|---|
| 1 | WordPress/MySQL container is slow | ab direct container test at c=32 |
1237 rps through FUSE — NOT bottleneck |
| 2 | FUSE filesystem overhead | ab test goes through FUSE mount |
Same 1237 rps — NOT bottleneck |
| 3 | HTTP forwarder pool size | Pool is 128 connections (XDN_HTTP_MAX_POOL_SIZE=128) |
NOT bottleneck |
| 4 | Capture thread saturation | doneQueueRemaining avg=0, capacity 859 rps |
NOT bottleneck |
| 5 | Too few PBM workers | 32 workers × 20ms/req = 1600 rps capacity, only ~10-13 active | NOT bottleneck |
| 6 | ForkJoinPool indirection | Fixed batch-of-1 fast path in forwardHttpRequestBatchToContainerizedService |
No improvement |
| 7 | Excessive INFO logging | Changed 8 hot-path logs from INFO→FINE (~1875 lines/s saved) | No improvement |
| 8 | Frontend HTTP batcher | Disabled HTTP_AR_FRONTEND_BATCH_ENABLED=false |
No improvement |
| 9 | Apache MaxRequestWorkers limit | Only 11 active workers at 500 rps (limit=150) | NOT bottleneck |
| 10 | Go client connection limits | MaxIdleConnsPerHost=1024 |
NOT bottleneck |
| 11 | Paxos consensus overhead | PB_SKIP_REPLICATION=true (bypass captureStateDiff + propose) |
Still 500 rps! |
| 12 | Virtual thread pinning | Switched PBM workers from virtual→platform threads | Still 500 rps! |
| 13 | PBM coordinator pipeline overhead | PB_BYPASS_COORDINATOR=true (bypass PBM entirely, use async dispatch) |
Still ~489 rps at 1000 rps offered |
| 14 | FUSELOG recorder overhead | Switched to RSYNC recorder (no FUSE) | Still ~500 rps |
| 15 | DDE bypass as sustained solution | ___DDE header appeared to hit ~1000 rps in 15s tests, but sustained 60s runs also cap at ~500 rps |
Same root cause (Connection: close) |
Why DDE Appeared to Work (But Didn't on Sustained Load)
The ___DDE debug header bypasses PBReplicaCoordinator entirely. In short-duration tests (15s), it appeared to achieve ~1000 rps because TIME_WAIT port accumulation hadn't saturated ephemeral ports yet. On sustained 60s tests, DDE also capped at ~500 rps — confirming the bottleneck was TCP connection management, not any XDN internal pipeline.
Debug Flags
-DPB_SKIP_REPLICATION=true— Capture thread skips captureStateDiff + Paxos propose, fires callbacks immediately-DPB_N_PARALLEL_WORKERS=32— Number of PBM worker threads-DPB_CAPTURE_ACCUMULATION_MS=1— Capture thread accumulation window-DPB_BYPASS_COORDINATOR=true— Bypasses PBM pipeline entirely, dispatches to app directly via async path___DDEHTTP header — Bypasses coordinator entirely, direct execute (legacy debug path)
Code Changes Made During Investigation
HttpActiveReplica.java: Connection header override (root cause fix); fully async dispatch (ResponseContext, sendAsyncResponse, timeoutScheduler); BYPASS_COORDINATOR debug flagXdnGigapaxosApp.java: ByteBufcopy()fix; fast-path for single-request batches (skip FJP)PrimaryBackupManager.java:PB_SKIP_REPLICATIONbypass flag; changed 7 INFO logs to FINE; workers virtual→platform threadsFuselogStateDiffRecorder.java: Changed "receiving stateDiff" log to FINE