Skip to content

Commit a44ec83

Browse files
terraboopsclaude
andcommitted
feat: stress/soak/chaos tests for SurrealDB projection cache
Stress tests (mem://, no infra needed): - Memory soak, rebuild soak, read latency scaling, write throughput - Concurrent writers (8 threads, 200 writes each) - Concurrent read/write consistency Chaos tests (kind + SurrealDB, run via tests/infra/setup-chaos.sh): - WS connectivity, concurrent multi-pod writers - Pod kill and recovery, split-brain stale detection - Blackboard write survives projection failure - Rapid reconnect, large payload over WS Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f6a0e1e commit a44ec83

6 files changed

Lines changed: 538 additions & 0 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ markers = [
7171
"smoke: quick sanity checks (template parsing, import verification)",
7272
"regression: tests that guard against specific past bugs",
7373
"browser: Playwright browser tests (run with --browser flag)",
74+
"slow: stress/soak tests that take >30 seconds",
75+
"chaos: tests requiring kind cluster + Docker",
7476
]
7577

7678
[tool.ruff]

tests/infra/kind-config.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
kind: Cluster
2+
apiVersion: kind.x-k8s.io/v1alpha4
3+
name: trellis-chaos
4+
nodes:
5+
- role: control-plane
6+
- role: worker
7+
- role: worker

tests/infra/setup-chaos.sh

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
#!/usr/bin/env bash
2+
# Setup and run chaos engineering tests for trellis projection cache.
3+
# Prerequisites: kind, kubectl, Docker Desktop
4+
set -euo pipefail
5+
6+
CLUSTER_NAME="trellis-chaos"
7+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
8+
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
9+
FORWARD_PORT=18000
10+
11+
cleanup() {
12+
echo "Cleaning up..."
13+
kill "$PORT_FORWARD_PID" 2>/dev/null || true
14+
if [ "${KEEP_CLUSTER:-}" != "1" ]; then
15+
kind delete cluster --name "$CLUSTER_NAME" 2>/dev/null || true
16+
fi
17+
}
18+
trap cleanup EXIT
19+
20+
# Create kind cluster
21+
if ! kind get clusters 2>/dev/null | grep -q "^${CLUSTER_NAME}$"; then
22+
echo "Creating kind cluster: $CLUSTER_NAME"
23+
kind create cluster --config "$SCRIPT_DIR/kind-config.yaml"
24+
else
25+
echo "Cluster $CLUSTER_NAME already exists"
26+
fi
27+
28+
# Deploy SurrealDB
29+
echo "Deploying SurrealDB..."
30+
kubectl apply -f "$SCRIPT_DIR/surrealdb.yaml"
31+
32+
echo "Waiting for SurrealDB readiness..."
33+
kubectl rollout status deployment/surrealdb --timeout=120s
34+
35+
# Port forward
36+
echo "Setting up port forward on :$FORWARD_PORT..."
37+
kubectl port-forward svc/surrealdb "$FORWARD_PORT:8000" &
38+
PORT_FORWARD_PID=$!
39+
sleep 3
40+
41+
# Verify connectivity
42+
if curl -sf "http://localhost:$FORWARD_PORT/health" > /dev/null 2>&1 || \
43+
curl -sf "http://localhost:$FORWARD_PORT/version" > /dev/null 2>&1; then
44+
echo "SurrealDB is reachable on localhost:$FORWARD_PORT"
45+
else
46+
echo "Warning: SurrealDB health check failed, proceeding anyway"
47+
fi
48+
49+
# Run chaos tests
50+
echo "Running chaos tests..."
51+
cd "$PROJECT_ROOT"
52+
SURREALDB_URL="ws://localhost:$FORWARD_PORT" \
53+
uv run python -m pytest tests/test_chaos.py -m chaos -v --tb=short "$@"
54+
55+
echo "Chaos tests complete."
56+
57+
# Keep cluster if requested
58+
if [ "${KEEP_CLUSTER:-}" = "1" ]; then
59+
echo "Keeping cluster $CLUSTER_NAME (set KEEP_CLUSTER=0 to auto-delete)"
60+
fi

tests/infra/surrealdb.yaml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
apiVersion: apps/v1
2+
kind: Deployment
3+
metadata:
4+
name: surrealdb
5+
labels:
6+
app: surrealdb
7+
spec:
8+
replicas: 1
9+
selector:
10+
matchLabels:
11+
app: surrealdb
12+
template:
13+
metadata:
14+
labels:
15+
app: surrealdb
16+
spec:
17+
containers:
18+
- name: surrealdb
19+
image: surrealdb/surrealdb:v2.0
20+
args: ["start", "--bind", "0.0.0.0:8000", "memory"]
21+
ports:
22+
- containerPort: 8000
23+
readinessProbe:
24+
tcpSocket:
25+
port: 8000
26+
initialDelaySeconds: 3
27+
periodSeconds: 5
28+
livenessProbe:
29+
tcpSocket:
30+
port: 8000
31+
initialDelaySeconds: 5
32+
periodSeconds: 10
33+
resources:
34+
requests:
35+
memory: "256Mi"
36+
cpu: "250m"
37+
limits:
38+
memory: "1Gi"
39+
cpu: "1"
40+
---
41+
apiVersion: v1
42+
kind: Service
43+
metadata:
44+
name: surrealdb
45+
spec:
46+
selector:
47+
app: surrealdb
48+
ports:
49+
- port: 8000
50+
targetPort: 8000
51+
type: ClusterIP

tests/test_chaos.py

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
"""Chaos engineering tests for the SurrealDB projection cache.
2+
3+
These tests require a kind cluster with SurrealDB deployed.
4+
Run with: tests/infra/setup-chaos.sh
5+
6+
Set SURREALDB_URL env var to point to the forwarded SurrealDB instance.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import json
12+
import os
13+
import subprocess
14+
import threading
15+
import time
16+
17+
import pytest
18+
19+
from trellis.core.blackboard import Blackboard
20+
from trellis.core.projection import ProjectionStore
21+
22+
SURREALDB_URL = os.environ.get("SURREALDB_URL", "")
23+
SKIP_REASON = "Set SURREALDB_URL to run chaos tests (e.g. ws://localhost:18000)"
24+
25+
26+
def _kubectl(*args: str, check: bool = True) -> subprocess.CompletedProcess:
27+
return subprocess.run(
28+
["kubectl", *args], capture_output=True, text=True, check=check, timeout=30
29+
)
30+
31+
32+
def _wait_for_surrealdb(timeout: float = 120):
33+
"""Wait for SurrealDB pod to be ready."""
34+
deadline = time.monotonic() + timeout
35+
while time.monotonic() < deadline:
36+
result = _kubectl(
37+
"get",
38+
"pods",
39+
"-l",
40+
"app=surrealdb",
41+
"-o",
42+
"jsonpath={.items[0].status.phase}",
43+
check=False,
44+
)
45+
if result.stdout.strip() == "Running":
46+
return
47+
time.sleep(2)
48+
raise TimeoutError("SurrealDB pod did not become ready")
49+
50+
51+
@pytest.fixture
52+
def ws_store():
53+
"""ProjectionStore connected to the K8s SurrealDB via WebSocket."""
54+
store = ProjectionStore()
55+
store.connect(SURREALDB_URL)
56+
yield store
57+
store.close()
58+
59+
60+
@pytest.fixture
61+
def bb_tmp(tmp_path):
62+
"""Temporary blackboard for chaos tests."""
63+
ideas_dir = tmp_path / "ideas"
64+
template = ideas_dir / "_template"
65+
template.mkdir(parents=True)
66+
(template / "status.json").write_text(json.dumps({"phase": "submitted"}))
67+
return Blackboard(ideas_dir)
68+
69+
70+
def _populate_ideas(store: ProjectionStore, count: int = 100) -> list[str]:
71+
"""Populate the store with test ideas."""
72+
ids = []
73+
for i in range(count):
74+
idea_id = f"chaos-{i}"
75+
store.upsert_idea(
76+
idea_id,
77+
{
78+
"title": f"Chaos Test {i}",
79+
"phase": "ideation",
80+
"total_cost_usd": i * 0.1,
81+
},
82+
)
83+
ids.append(idea_id)
84+
return ids
85+
86+
87+
@pytest.mark.chaos
88+
@pytest.mark.skipif(not SURREALDB_URL, reason=SKIP_REASON)
89+
class TestProjectionChaos:
90+
"""Chaos tests against a real SurrealDB instance in K8s."""
91+
92+
def test_ws_connection_and_basic_ops(self, ws_store):
93+
"""Verify basic connectivity to SurrealDB over WebSocket."""
94+
ws_store.upsert_idea("ws-test", {"title": "WS Test", "phase": "submitted"})
95+
idea = ws_store.get_idea("ws-test")
96+
assert idea is not None
97+
assert idea["title"] == "WS Test"
98+
99+
def test_concurrent_pod_writers(self, ws_store):
100+
"""Multiple threads writing to the same SurrealDB — simulates multi-pod."""
101+
num_threads = 8
102+
writes_per = 100
103+
errors = []
104+
105+
def writer(tid):
106+
try:
107+
for i in range(writes_per):
108+
ws_store.upsert_idea(
109+
f"mpw-{tid}-{i}",
110+
{"title": f"Pod {tid} idea {i}", "phase": "ideation"},
111+
)
112+
except Exception as e:
113+
errors.append((tid, str(e)))
114+
115+
threads = [threading.Thread(target=writer, args=(t,)) for t in range(num_threads)]
116+
for t in threads:
117+
t.start()
118+
for t in threads:
119+
t.join(timeout=60)
120+
121+
assert not errors, f"Writer errors: {errors}"
122+
ideas = ws_store.get_ideas_for_home()
123+
assert len(ideas) >= num_threads * writes_per
124+
125+
def test_surrealdb_pod_kill_and_recover(self, ws_store):
126+
"""Kill SurrealDB pod and verify recovery after restart."""
127+
# Write data
128+
_populate_ideas(ws_store, 50)
129+
assert len(ws_store.get_ideas_for_home()) >= 50
130+
131+
# Kill the pod
132+
_kubectl("delete", "pod", "-l", "app=surrealdb", "--grace-period=0", "--force", check=False)
133+
134+
# Writes should fail (or succeed if pod hasn't died yet — either is acceptable)
135+
time.sleep(2)
136+
try:
137+
ws_store.upsert_idea("after-kill", {"title": "After kill", "phase": "submitted"})
138+
except Exception:
139+
pass # expected
140+
141+
# Wait for pod restart
142+
_wait_for_surrealdb(timeout=60)
143+
time.sleep(5) # grace period for port-forward to reconnect
144+
145+
# Reconnect
146+
ws_store.close()
147+
ws_store.connect(SURREALDB_URL)
148+
149+
# Data is gone (mem:// mode) — verify empty
150+
ideas = ws_store.get_ideas_for_home()
151+
assert len(ideas) == 0, f"Expected 0 ideas after pod kill, got {len(ideas)}"
152+
153+
def test_split_brain_stale_projection(self, ws_store, bb_tmp):
154+
"""Projection becomes stale when filesystem is updated directly."""
155+
idea_id = "stale-test"
156+
157+
# Write via blackboard (which would normally update projection)
158+
bb_tmp.base_dir.mkdir(parents=True, exist_ok=True)
159+
idea_dir = bb_tmp.base_dir / idea_id
160+
idea_dir.mkdir(exist_ok=True)
161+
(idea_dir / "status.json").write_text(
162+
json.dumps(
163+
{
164+
"title": "Stale Test",
165+
"phase": "ideation",
166+
"total_cost_usd": 1.0,
167+
}
168+
)
169+
)
170+
171+
# Also write to projection
172+
ws_store.upsert_idea(
173+
idea_id, {"title": "Stale Test", "phase": "ideation", "total_cost_usd": 1.0}
174+
)
175+
176+
# Simulate Pod B updating filesystem directly (bypassing projection)
177+
(idea_dir / "status.json").write_text(
178+
json.dumps(
179+
{
180+
"title": "Stale Test",
181+
"phase": "released",
182+
"total_cost_usd": 5.0,
183+
}
184+
)
185+
)
186+
187+
# Projection is now stale
188+
idea = ws_store.get_idea(idea_id)
189+
assert idea["phase"] == "ideation", "Projection should still show old phase"
190+
191+
# invalidate_idea re-reads from filesystem
192+
ws_store.invalidate_idea(idea_id, bb_tmp)
193+
idea = ws_store.get_idea(idea_id)
194+
assert idea["phase"] == "released", (
195+
"Projection should show updated phase after invalidation"
196+
)
197+
198+
def test_blackboard_write_survives_projection_failure(self, bb_tmp):
199+
"""Blackboard writes must succeed even if projection is broken."""
200+
# Create a store with a broken connection
201+
bb_tmp.projection = ProjectionStore() # not connected — all ops are no-ops
202+
203+
idea_id = "bb-survive"
204+
idea_dir = bb_tmp.base_dir / idea_id
205+
idea_dir.mkdir(parents=True, exist_ok=True)
206+
(idea_dir / "status.json").write_text(
207+
json.dumps(
208+
{
209+
"title": "Survive",
210+
"phase": "submitted",
211+
}
212+
)
213+
)
214+
215+
# This should NOT raise even though projection is broken
216+
bb_tmp.update_status(idea_id, phase="released")
217+
218+
status = bb_tmp.get_status(idea_id)
219+
assert status["phase"] == "released"
220+
221+
222+
@pytest.mark.chaos
223+
@pytest.mark.skipif(not SURREALDB_URL, reason=SKIP_REASON)
224+
class TestProjectionWsReliability:
225+
"""WebSocket-specific reliability tests."""
226+
227+
def test_rapid_reconnect(self):
228+
"""Rapid connect/disconnect cycles should not leak resources."""
229+
for _ in range(20):
230+
store = ProjectionStore()
231+
store.connect(SURREALDB_URL)
232+
store.upsert_idea("reconnect", {"title": "Reconnect", "phase": "submitted"})
233+
store.close()
234+
235+
def test_large_payload_over_ws(self, ws_store):
236+
"""Large status dicts should transfer cleanly over WebSocket."""
237+
big_history = [
238+
{"from": f"phase-{i}", "to": f"phase-{i + 1}", "ts": f"2026-01-{i:02d}"}
239+
for i in range(10000)
240+
]
241+
ws_store.upsert_idea(
242+
"big-payload",
243+
{
244+
"title": "Big Payload",
245+
"phase": "released",
246+
"phase_history": big_history,
247+
"total_cost_usd": 999.99,
248+
},
249+
)
250+
idea = ws_store.get_idea("big-payload")
251+
assert idea is not None
252+
assert idea["phase"] == "released"

0 commit comments

Comments
 (0)