Skip to content

Commit c9eb11a

Browse files
feat(mentor): Traefik sticky cookies for replica affinity
Pin user/workspace traffic to the replica holding the in-JVM PiProcessHandle so SSE turns don't pay a sandbox cold-start each reconnect. Also stamp every response with X-Hephaestus-Replica so the affinity is observable from curl or DevTools. - Traefik labels on https-application-server: sticky cookie __Secure-hep_workspace_aff, Path=/api/workspaces (matches the actual mentor URL /api/workspaces/{slug}/mentor/chat), maxAge=300 aligned with hephaestus.mentor.idle-ttl-seconds. Healthcheck labels so Traefik rewrites the affinity cookie on replica death. - Bump Traefik v3.2 -> v3.4: sticky.cookie.path landed in v3.3 (PR traefik/traefik#11166), the label is silently dropped on v3.2. - ReplicaIdentityFilter (OncePerRequestFilter, HIGHEST_PRECEDENCE) emits X-Hephaestus-Replica from $HOSTNAME with InetAddress fallback for non-Docker dev. CORS exposes the header via the same constant so SecurityConfig and the filter can't drift. - Preview compose is single-replica by design and intentionally omits the labels. - Contributor doc at docs/contributor/unified-pi-runtime.mdx covers the affinity, known limitations, and how to disable. Fixes #1077 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f8fdb02 commit c9eb11a

8 files changed

Lines changed: 114 additions & 2 deletions

File tree

docker/compose.app.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,18 @@ services:
166166
- "traefik.http.services.https-application-server.loadbalancer.server.port=8080"
167167
- "traefik.http.routers.http-application-server.priority=5"
168168
- "traefik.http.routers.https-application-server.priority=10"
169+
# Replica affinity for workspace traffic (mentor lives here) — see docs/contributor/unified-pi-runtime.mdx.
170+
# maxAge=300 matches hephaestus.mentor.idle-ttl-seconds; cookie.path requires Traefik >=3.3.
171+
- "traefik.http.services.https-application-server.loadbalancer.sticky.cookie=true"
172+
- "traefik.http.services.https-application-server.loadbalancer.sticky.cookie.name=__Secure-hep_workspace_aff"
173+
- "traefik.http.services.https-application-server.loadbalancer.sticky.cookie.httpOnly=true"
174+
- "traefik.http.services.https-application-server.loadbalancer.sticky.cookie.secure=true"
175+
- "traefik.http.services.https-application-server.loadbalancer.sticky.cookie.sameSite=lax"
176+
- "traefik.http.services.https-application-server.loadbalancer.sticky.cookie.path=/api/workspaces"
177+
- "traefik.http.services.https-application-server.loadbalancer.sticky.cookie.maxAge=300"
178+
- "traefik.http.services.https-application-server.loadbalancer.healthcheck.path=/actuator/health/liveness"
179+
- "traefik.http.services.https-application-server.loadbalancer.healthcheck.interval=10s"
180+
- "traefik.http.services.https-application-server.loadbalancer.healthcheck.timeout=3s"
169181
healthcheck:
170182
test: "wget -qO- http://localhost:8080/actuator/health || exit 1"
171183
interval: 5s

docker/compose.proxy.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
services:
22
reverse-proxy:
3-
image: traefik:v3.2
3+
image: traefik:v3.4
44
restart: unless-stopped
55
networks:
66
- shared-network

docker/preview/compose.app.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,10 @@ services:
178178
# Application Server - Spring Boot backend
179179
# Main app domain (set once): https://api.hephaestus.example.com
180180
# Previews auto-generate: https://{{pr_id}}.api.hephaestus.example.com
181+
#
182+
# Replica affinity: previews are single-replica by design, so the mentor sticky-cookie
183+
# labels from docker/compose.app.yaml are intentionally omitted here — they would be a
184+
# no-op. See docs/contributor/unified-pi-runtime.mdx.
181185
# ---------------------------------------------------------------------------
182186
application-server:
183187
build:
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
id: unified-pi-runtime
3+
sidebar_position: 3
4+
title: Unified Pi Runtime
5+
description: Mentor replica affinity and how to disable it for debugging.
6+
---
7+
8+
## Why mentor traffic is sticky
9+
10+
The mentor SSE endpoint opens a long-lived `docker exec -i` against the user's Pi container (`PiProcessHandle`). The stdin/stdout pipes live in JVM memory; the conversation itself is persisted to Postgres (`chat_thread.session_jsonl`, BYTEA), so any replica can serve any turn — but a replica without the live pipes must rebuild the sandbox first, paying a cold-start cost tracked by `InteractiveSandboxMetrics.attachDuration`.
11+
12+
Traefik pins workspace traffic to the originating replica via a cookie scoped to `/api/workspaces` — narrower than the full `/api` router (auth and public endpoints stay round-robin), broad enough to cover the actual mentor URL `/api/workspaces/{slug}/mentor/chat`. Labels live on the `https-application-server` service in `docker/compose.app.yaml`. Inspect with `curl -i`:
13+
14+
- Cookie: `__Secure-hep_workspace_aff``Secure`, `HttpOnly`, `SameSite=Lax`, `maxAge` matches `hephaestus.mentor.idle-ttl-seconds` (300s default) so the cookie expires when the sandbox is reaped.
15+
- Response header: `X-Hephaestus-Replica: <container-id-prefix>` — emitted by `ReplicaIdentityFilter` from `$HOSTNAME`, CORS-exposed for the webapp.
16+
17+
Labels are HTTPS-only; the HTTP router redirects to HTTPS, so pinning it would just double-issue cookies. The `cookie.path` attribute requires Traefik >= 3.3 (the proxy is pinned to v3.4 in `docker/compose.proxy.yaml`). Previews (`docker/preview/compose.app.yaml`) are single-replica and omit the labels.
18+
19+
## Known limitations
20+
21+
- **Two browsers, same user.** Two browsers can pin to two replicas, each spawning its own `(userId, workspaceId)` sandbox — `InteractiveSandboxRegistry` is per-JVM.
22+
- **Rolling deploys.** Each pinned user whose replica restarts pays one cold start. The `SseEmitter` timeout is 10 minutes (`MentorChatController.EMITTER_TIMEOUT_MS`); drain by refusing new turns and letting in-flight emitters complete.
23+
24+
## Disabling affinity for debugging
25+
26+
Delete the `traefik.http.services.https-application-server.loadbalancer.sticky.*` labels from `docker/compose.app.yaml` and redeploy. Existing sessions stay pinned until the cookie expires; new sessions round-robin. Expect transient 5xx on reconnects that land on a cold replica.

docs/sidebars.contributor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const sidebars: SidebarsConfig = {
2525
{
2626
type: 'category',
2727
label: 'AI Development',
28-
items: ['ai-agent-workflow', 'ai-code-review'],
28+
items: ['ai-agent-workflow', 'ai-code-review', 'unified-pi-runtime'],
2929
},
3030
],
3131
};

server/application-server/src/main/java/de/tum/in/www1/hephaestus/SecurityConfig.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import de.tum.in.www1.hephaestus.config.CorsProperties;
44
import de.tum.in.www1.hephaestus.feature.FeatureFlag;
5+
import de.tum.in.www1.hephaestus.observability.ReplicaIdentityFilter;
56
import java.util.Collection;
67
import java.util.List;
78
import java.util.Map;
@@ -142,6 +143,7 @@ CorsConfigurationSource corsConfigurationSource() {
142143
configuration.setAllowedHeaders(
143144
List.of("Authorization", "Content-Type", "Accept", "X-Requested-With", "Origin")
144145
);
146+
configuration.setExposedHeaders(List.of(ReplicaIdentityFilter.HEADER_NAME));
145147
configuration.setAllowCredentials(true);
146148
configuration.setMaxAge(3600L);
147149
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package de.tum.in.www1.hephaestus.observability;
2+
3+
import jakarta.annotation.PostConstruct;
4+
import jakarta.servlet.FilterChain;
5+
import jakarta.servlet.ServletException;
6+
import jakarta.servlet.http.HttpServletRequest;
7+
import jakarta.servlet.http.HttpServletResponse;
8+
import java.io.IOException;
9+
import java.net.InetAddress;
10+
import java.net.UnknownHostException;
11+
import org.slf4j.Logger;
12+
import org.slf4j.LoggerFactory;
13+
import org.springframework.core.Ordered;
14+
import org.springframework.core.annotation.Order;
15+
import org.springframework.stereotype.Component;
16+
import org.springframework.web.filter.OncePerRequestFilter;
17+
18+
/** Stamps every response with the serving replica id — see docs/contributor/unified-pi-runtime.mdx. */
19+
@Component
20+
@Order(Ordered.HIGHEST_PRECEDENCE)
21+
public class ReplicaIdentityFilter extends OncePerRequestFilter {
22+
23+
public static final String HEADER_NAME = "X-Hephaestus-Replica";
24+
25+
private static final Logger log = LoggerFactory.getLogger(ReplicaIdentityFilter.class);
26+
private static final String REPLICA_ID = resolveReplicaId();
27+
28+
@Override
29+
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
30+
throws ServletException, IOException {
31+
response.setHeader(HEADER_NAME, REPLICA_ID);
32+
chain.doFilter(request, response);
33+
}
34+
35+
@PostConstruct
36+
void announceReplica() {
37+
log.info("Replica id: {}", REPLICA_ID);
38+
}
39+
40+
// HOSTNAME is the container id under Docker; InetAddress fallback is dev-only.
41+
private static String resolveReplicaId() {
42+
String env = System.getenv("HOSTNAME");
43+
if (env != null && !env.isBlank()) return env;
44+
try {
45+
return InetAddress.getLocalHost().getHostName();
46+
} catch (UnknownHostException e) {
47+
return "unknown";
48+
}
49+
}
50+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package de.tum.in.www1.hephaestus.observability;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import de.tum.in.www1.hephaestus.testconfig.BaseUnitTest;
6+
import org.junit.jupiter.api.Test;
7+
import org.springframework.mock.web.MockHttpServletRequest;
8+
import org.springframework.mock.web.MockHttpServletResponse;
9+
10+
class ReplicaIdentityFilterTest extends BaseUnitTest {
11+
12+
@Test
13+
void setsReplicaHeader() throws Exception {
14+
var response = new MockHttpServletResponse();
15+
new ReplicaIdentityFilter().doFilter(new MockHttpServletRequest(), response, (req, res) -> {});
16+
assertThat(response.getHeader(ReplicaIdentityFilter.HEADER_NAME)).isNotBlank();
17+
}
18+
}

0 commit comments

Comments
 (0)