diff --git a/.jules/bolt.md b/.jules/bolt.md index 5049e2f8..99f64a9b 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -219,3 +219,7 @@ go tool pprof mem.prof Remember: You're Bolt, making switchAILocal lightning fast. But speed without correctness is useless. Measure, optimize, verify. **If you can't find a clear performance win today, stop and do not create a PR.** + +## 2026-08-20 - Pool bytes.Buffer in HTTP Middleware +**Learning:** Naive buffer allocation in HTTP middleware (`ResponseWriterWrapper` and `ResponseRewriter`) causes massive heap allocations and high GC pressure during streaming and large non-streaming requests. Returning them to a `sync.Pool` without `.Cap()` checks causes long-term memory leaks when processing occasional massive payloads. +**Action:** Always recycle `bytes.Buffer` in middleware via `sync.Pool`. Ensure the buffer is explicitly released *after* the request is fully complete (e.g. at the handler level, not inside `Flush()`), and enforce a `.Cap()` check (e.g., `<= 128*1024`) before returning the buffer to the pool to prevent memory bloat. diff --git a/internal/api/middleware/request_logging.go b/internal/api/middleware/request_logging.go index 7593f759..d007896b 100644 --- a/internal/api/middleware/request_logging.go +++ b/internal/api/middleware/request_logging.go @@ -65,6 +65,9 @@ func RequestLoggingMiddleware(logger logging.RequestLogger) gin.HandlerFunc { // Error is logged but we continue serving the response _ = err } + + // Release the buffer back to the pool + wrapper.Release() } } diff --git a/internal/api/middleware/response_writer.go b/internal/api/middleware/response_writer.go index 52ef09b2..90a6d2c2 100644 --- a/internal/api/middleware/response_writer.go +++ b/internal/api/middleware/response_writer.go @@ -11,6 +11,7 @@ import ( "bytes" "net/http" "strings" + "sync" "github.com/gin-gonic/gin" "github.com/traylinx/switchAILocal/internal/interfaces" @@ -28,6 +29,13 @@ type RequestInfo struct { // ResponseWriterWrapper wraps the standard gin.ResponseWriter to intercept and log response data. // It is designed to handle both standard and streaming responses, ensuring that logging operations do not block the client response. + +var responseBodyBufferPool = sync.Pool{ + New: func() interface{} { + return &bytes.Buffer{} + }, +} + type ResponseWriterWrapper struct { gin.ResponseWriter body *bytes.Buffer // body is a buffer to store the response body for non-streaming responses. @@ -55,7 +63,7 @@ type ResponseWriterWrapper struct { func NewResponseWriterWrapper(w gin.ResponseWriter, logger logging.RequestLogger, requestInfo *RequestInfo) *ResponseWriterWrapper { return &ResponseWriterWrapper{ ResponseWriter: w, - body: &bytes.Buffer{}, + body: responseBodyBufferPool.Get().(*bytes.Buffer), logger: logger, requestInfo: requestInfo, headers: make(map[string][]string), @@ -304,6 +312,18 @@ func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error { return w.logRequest(finalStatusCode, w.cloneHeaders(), w.body.Bytes(), w.extractAPIRequest(c), w.extractAPIResponse(c), slicesAPIResponseError, forceLog) } +// Release returns the internal buffer to the sync.Pool to prevent memory bloat. +// This must be called after the request is completely finished and logged. +func (w *ResponseWriterWrapper) Release() { + if w.body != nil { + if w.body.Cap() <= 128*1024 { + w.body.Reset() + responseBodyBufferPool.Put(w.body) + } + w.body = nil + } +} + func (w *ResponseWriterWrapper) cloneHeaders() map[string][]string { w.ensureHeadersCaptured()