Class ChatStreamHandler
POST /api/v2/chat/stream — Server-Sent Events streaming RAG chat.
Envelope exception: SSE wire format is incompatible with the
{"response": {...}} JSON envelope used by every other v2 endpoint
except /api/v2/documents/all (NDJSON). Plan 4 deliberately keeps the
same SSE framing as v1 (event: + data: <json>\n\n) so the
static theme JS can share a single SSE parser across v1 and v2. The exception
is documented in §Risks (Risk 2).
All SSE event data keys use snake_case:
event: phase—{phase, status: start|complete, message?, keywords?, hit_count?}event: chunk—{content}event: sources—{sources: [{rank, title, url, doc_id, snippet, url_link, go_url}]}event: done—{session_id, html_content?}event: retry—{phase, operation, attempt, max_attempts, sleep_ms, cause?}event: waiting—{phase, reason, elapsed_ms, timeout_ms}event: fallback—{phase, reason, original_query?, new_query?}event: warning—{phase, code, detail?}event: error—{phase?, message, error_code}
Error reporting before the LLM is invoked (method check, feature gate,
body parse, rate limit) uses V2EnvelopeWriter.writeError(jakarta.servlet.http.HttpServletResponse, org.codelibs.fess.api.v2.V2ErrorCode, java.lang.String) so the HTTP status
and Content-Type: application/json are correct. Only after all gates pass are
SSE headers set; subsequent LLM-level errors are reported via event: error
SSE events, consistent with v1 behaviour the static theme JS parser depends on.
-
Constructor Summary
Constructors -
Method Summary
Modifier and TypeMethodDescriptionvoiddestroy()Shuts down the shared keep-alive pool on container shutdown (PreDestroy).protected voidemitSafely(PrintWriter writer, String event, Map<String, Object> data) WrapssendSseEvent(java.io.PrintWriter, java.lang.String, java.util.Map<java.lang.String, java.lang.Object>)so a failure inside the phase callback (typically an IOException from a disconnected client) does not propagate and abort the surroundingstreamChatEnhancedcall.protected voidemitSafely(PrintWriter writer, String event, Map<String, Object> data, Object writeLock) Lock-aware variant ofemitSafely(PrintWriter, String, Map)that serializes the write on a shared monitor — used so phase events cannot interleave with concurrent keep-alive pings inside an event/data frame.protected StringgetRateLimitKey(jakarta.servlet.http.HttpServletRequest req) Resolves the chat rate-limit key (server-validated username for authenticated callers, else the proxy-aware client IP).protected StringgetUserId(jakarta.servlet.http.HttpServletRequest req) Resolves the effective chat user id.voidhandle(jakarta.servlet.http.HttpServletRequest req, jakarta.servlet.http.HttpServletResponse res) Processes onePOST /api/v2/chat/streamrequest.voidinit()Initializes the shared keep-alive scheduler pool.protected ChatPhaseCallbacknewPhaseCallback(PrintWriter writer) Builds aChatPhaseCallbackthat emits SSE events with snake_case keys.protected ChatPhaseCallbacknewPhaseCallback(PrintWriter writer, boolean[] errorEmittedHolder) Backward-compatible overload that synthesises a private write lock.protected ChatPhaseCallbacknewPhaseCallback(PrintWriter writer, boolean[] errorEmittedHolder, Object writeLock) Builds aChatPhaseCallbackthat emits SSE events with snake_case keys.protected voidHelper to add a key/value pair to a map only when the value is non-null.protected longresolveKeepaliveIntervalMs(org.codelibs.fess.mylasta.direction.FessConfig fessConfig) Resolves the keep-alive interval from configuration with a safe default.protected voidsendSseEvent(PrintWriter writer, String event, Map<String, Object> data) Writes one SSE event using v1's wire format:event: <name>\ndata: <json>\n\n.protected voidsetSseHeaders(jakarta.servlet.http.HttpServletResponse res) Same SSE headers as v1.protected ScheduledFuture<?> startKeepalivePinger(PrintWriter writer, Object writeLock, long intervalMs) Schedules a periodic keep-alive ping task on the sharedsharedKeepalivePool.protected voidstopKeepalivePinger(ScheduledFuture<?> future) Cancels a keep-alive ping future previously returned bystartKeepalivePinger(PrintWriter, Object, long).
-
Constructor Details
-
ChatStreamHandler
public ChatStreamHandler()Default constructor used by the DI container. The handler holds no per-request state and is safe to share across concurrent requests.init()must be called (by the DI container viaPostConstruct) before any request is processed.
-
-
Method Details
-
init
@PostConstruct public void init()Initializes the shared keep-alive scheduler pool. Called by Lasta Di after the component is bound (PostConstruct).The pool uses daemon threads named
fess-sse-keepalive-N(1-based) so they are visible in thread dumps and never prevent JVM shutdown. -
destroy
@PreDestroy public void destroy()Shuts down the shared keep-alive pool on container shutdown (PreDestroy).Waits up to 1 second for in-flight tasks to complete; if they have not finished by then,
ExecutorService.shutdownNow()is called to interrupt waiting threads. Individual per-request futures are cancelled viaFuture.cancel(boolean)before this point, so only the pool lifecycle itself needs termination here. -
handle
public void handle(jakarta.servlet.http.HttpServletRequest req, jakarta.servlet.http.HttpServletResponse res) throws IOException Processes onePOST /api/v2/chat/streamrequest.All pre-stream validation (method, feature gate, body parse, rate limit) runs before any SSE headers are committed so that failures can be reported as a normal JSON error envelope. Once every gate passes the response is switched to
text/event-streamand the LLM is invoked; subsequent failures surface via anevent: errorSSE event rather than an HTTP error code.- Parameters:
req- the incoming HTTP requestres- the HTTP response to write to- Throws:
IOException- if writing the envelope or reading the body fails
-
getUserId
Resolves the effective chat user id. Exposed as a seam so unit tests can override the user identity directly.SystemHelper/UserInfoHelperare smart-deploy components, so stubbing them viaComponentUtil.registeris not reliable once the shared test container has resolved the real ones; overriding this method avoids that fragility.- Parameters:
req- the incoming HTTP request- Returns:
- the user identifier (never null)
-
getRateLimitKey
Resolves the chat rate-limit key (server-validated username for authenticated callers, else the proxy-aware client IP). Exposed as a seam so unit tests can pin the throttle key deterministically; the underlyingSystemHelper/RateLimitHelperare smart-deploy components that are unreliable to stub viaComponentUtil.registeronce the shared test container has resolved the real ones (same rationale asgetUserId(jakarta.servlet.http.HttpServletRequest)).- Parameters:
req- the incoming HTTP request- Returns:
- the rate-limit key (never null/blank)
-
resolveKeepaliveIntervalMs
protected long resolveKeepaliveIntervalMs(org.codelibs.fess.mylasta.direction.FessConfig fessConfig) Resolves the keep-alive interval from configuration with a safe default. Returns the configured value when valid, or15000ms when the config key is missing, malformed, or negative. A value of0disables the pinger.- Parameters:
fessConfig- active configuration- Returns:
- interval in milliseconds;
0disables the pinger
-
startKeepalivePinger
protected ScheduledFuture<?> startKeepalivePinger(PrintWriter writer, Object writeLock, long intervalMs) Schedules a periodic keep-alive ping task on the sharedsharedKeepalivePool. Emits": keepalive\n\n"(SSE comment line — ignored by EventSource clients) on a fixed delay. ReturnsnullwhenintervalMsis<=0so the pinger is fully disabled.All emissions are guarded by
writeLockwhich is also held by the request thread while writing event/data pairs, so the pinger can never interleave inside an event frame.The returned
ScheduledFuturemust be cancelled (not the pool) when the request completes — callstopKeepalivePinger(ScheduledFuture).- Parameters:
writer- servlet writerwriteLock- shared monitor serializing writes towriterintervalMs- ping interval;<=0disables pinging- Returns:
- a future representing the scheduled ping task, or
nullwhen disabled
-
stopKeepalivePinger
Cancels a keep-alive ping future previously returned bystartKeepalivePinger(PrintWriter, Object, long). The shared pool is NOT shut down — only this request's scheduled task is cancelled. Safe to call withnull.- Parameters:
future- the ping future to cancel; may be null
-
setSseHeaders
protected void setSseHeaders(jakarta.servlet.http.HttpServletResponse res) Same SSE headers as v1. Delegates toSseResponseHelper.applySseHeaders(jakarta.servlet.http.HttpServletResponse)so the baseline (including the criticalX-Accel-Buffering: noheader that disables nginx response buffering) stays in sync across v1 and v2.- Parameters:
res- the HTTP response to set headers on
-
newPhaseCallback
Builds aChatPhaseCallbackthat emits SSE events with snake_case keys.- Parameters:
writer- the per-request servlet writer to emit events to- Returns:
- a callback bound to the writer
-
newPhaseCallback
Backward-compatible overload that synthesises a private write lock. PrefernewPhaseCallback(PrintWriter, boolean[], Object)from the request thread so the lock is shared with the keep-alive pinger.- Parameters:
writer- the per-request servlet writer to emit events toerrorEmittedHolder- single-element boolean array; element 0 is set to true after the first onError emission- Returns:
- a callback bound to the writer
-
newPhaseCallback
protected ChatPhaseCallback newPhaseCallback(PrintWriter writer, boolean[] errorEmittedHolder, Object writeLock) Builds aChatPhaseCallbackthat emits SSE events with snake_case keys. The suppliederrorEmittedHolderflag is set whenever onError is invoked so the surrounding handler can avoid double-emitting an error event from its outer catch. All emissions are serialized onwriteLockso they cannot interleave with concurrent keep-alive pings.- Parameters:
writer- the per-request servlet writer to emit events toerrorEmittedHolder- single-element boolean array; element 0 is set to true after the first onError emissionwriteLock- shared monitor serializing writes towriter- Returns:
- a callback bound to the writer
-
emitSafely
WrapssendSseEvent(java.io.PrintWriter, java.lang.String, java.util.Map<java.lang.String, java.lang.Object>)so a failure inside the phase callback (typically an IOException from a disconnected client) does not propagate and abort the surroundingstreamChatEnhancedcall.- Parameters:
writer- servlet writerevent- SSE event namedata- event payload to serialize as JSON
-
emitSafely
protected void emitSafely(PrintWriter writer, String event, Map<String, Object> data, Object writeLock) Lock-aware variant ofemitSafely(PrintWriter, String, Map)that serializes the write on a shared monitor — used so phase events cannot interleave with concurrent keep-alive pings inside an event/data frame.- Parameters:
writer- servlet writerevent- SSE event namedata- event payload to serialize as JSONwriteLock- shared monitor serializing writes towriter
-
putIfNotNull
Helper to add a key/value pair to a map only when the value is non-null.- Parameters:
data- target mapkey- keyvalue- value to add when non-null
-
sendSseEvent
Writes one SSE event using v1's wire format:event: <name>\ndata: <json>\n\n.- Parameters:
writer- servlet writerevent- SSE event namedata- event payload to serialize as JSON
-