Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions containers/l7_self_metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ var (
// inferred. Stages, in order:
//
// stream_created client HEADERS decoded, request object created
// stream_evicted a request still waiting for its response dropped to
// make room: the connection had too many such requests,
// nearly always ones whose response was lost
// response_status :status seen on the response
// end_stream END_STREAM flag seen (a frame flag, not HPACK)
// completed both of the above -> request emitted
Expand Down
33 changes: 29 additions & 4 deletions ebpftracer/l7/http2.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ const (
maxPendingHeaderBlockSize = 64 * 1024

// Max concurrent HTTP/2 streams tracked per connection.
// Prevents unbounded memory growth when responses never complete (orphan streams).
// Prevents unbounded memory growth when responses never complete (orphan
// streams); at the limit the oldest stream makes way (evictOldestRequest).
maxActiveRequests = 100
)

Expand Down Expand Up @@ -218,6 +219,29 @@ func (p *Http2Parser) resetDecoder(method Method) {
}
}

// evictOldestRequest makes room for a new stream by dropping the request
// that has waited longest for its response.
//
// The streams that fill the table are mostly ones whose response the parser
// will never see: it was in a read cut short by truncation, or in an event
// lost before it. They are only collected after http2DecoderGcInterval.
// Refusing new streams until then dropped every request on a busy
// connection for minutes, silently; the oldest stream is the one least
// likely to still complete.
func (p *Http2Parser) evictOldestRequest() {
var oldestId uint32
var oldest *Http2Request
for id, r := range p.activeRequests {
if oldest == nil || r.kernelTime < oldest.kernelTime {
oldestId, oldest = id, r
}
}
Comment on lines +234 to +238

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The cache eviction logic in evictOldestRequest currently iterates over all active requests in p.activeRequests to find the oldest one, which is an O(n) operation. Since this runs on a critical path (L7 event-processing/eBPF event handling), holding write locks during this traversal can stall event handling. Please optimize this eviction logic to O(log n) or better (for example, by maintaining a min-heap or a doubly-linked list of requests ordered by time) to minimize the time write locks are held.

References
  1. Ensure cache eviction logic running on critical paths (such as L7 event-processing or eBPF event handling) is optimized (e.g., O(n log n) or better) to minimize the time write locks are held and prevent stalling event handling.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping the scan. It runs only when a connection already holds maxActiveRequests (100) requests waiting for responses, it is bounded by that 100, and it costs about a microsecond. A heap or ordered index would add bookkeeping to every stream create and complete, the hot path, to speed up a path that only runs at the limit. HTTP/2 stream IDs and kernel times also both increase, so the oldest entry is easy to find if this ever shows up in a profile.

if oldest != nil {
delete(p.activeRequests, oldestId)
p.stage("stream_evicted")
}
}

// dropPendingHeaders discards a header block still waiting for CONTINUATION
// frames. Its insertions never reach the table, so the table is reset too.
func (p *Http2Parser) dropPendingHeaders(method Method, pending **pendingHeaderBlock) {
Expand Down Expand Up @@ -297,15 +321,16 @@ func (p *Http2Parser) decodeHeaderBlock(
switch method {
case MethodHttp2ClientFrames:
req := p.activeRequests[streamId]
if req == nil && len(p.activeRequests) < maxActiveRequests {
if req == nil {
if len(p.activeRequests) >= maxActiveRequests {
p.evictOldestRequest()
}
req = &Http2Request{
kernelTime: kernelTime,
}
p.activeRequests[streamId] = req
p.stage("stream_created")
}
// With too many active streams req stays nil: the block is still
// decoded, to keep the dynamic table in sync.
emit = func(name, value string) {
switch name {
case ":method":
Expand Down
42 changes: 42 additions & 0 deletions ebpftracer/l7/http2_hpack_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,45 @@ func TestHttp2ParserAcceptsTableSizeUpdateInLaterBlock(t *testing.T) {
t.Errorf("hpack_error = %d, hpack_partial = %d, want 0", stages["hpack_error"], stages["hpack_partial"])
}
}

func responseFrame(streamID uint32) []byte {
var buf bytes.Buffer
_ = hpack.NewEncoder(&buf).WriteField(hpack.HeaderField{Name: ":status", Value: "200"})
return frame(http2.FrameHeaders, http2FlagEndHeaders|http2FlagEndStream, streamID, buf.Bytes())
}

// Requests whose responses are lost (cut from a truncated read) stay active
// until the stream GC, minutes later. Once maxActiveRequests of them pile up,
// new streams used to be refused, so every request on the connection was
// dropped until the GC ran. The oldest waiting request now makes room.
func TestHttp2ParserEvictsOldestStreamAtCap(t *testing.T) {
stages := countStages(t)
p := NewHttp2Parser()
orphans := maxActiveRequests + 20
for i := 0; i < orphans; i++ {
p.Parse(MethodHttp2ClientFrames, headersFrame(streamID(i), "/orphan"), uint64(i), 0)
}
completed := 0
for i := orphans; i < orphans+50; i++ {
p.Parse(MethodHttp2ClientFrames, headersFrame(streamID(i), "/live"), uint64(i), 0)
for _, r := range p.Parse(MethodHttp2ServerFrames, responseFrame(streamID(i)), uint64(i), 0) {
if r.Path == "/live" {
completed++
}
}
}
if completed != 50 {
t.Errorf("completed %d of 50 requests made after the orphans filled the table", completed)
}
if len(p.activeRequests) > maxActiveRequests {
t.Errorf("%d active requests, cap is %d", len(p.activeRequests), maxActiveRequests)
}
if p.activeRequests[streamID(0)] != nil || p.activeRequests[streamID(orphans-1)] == nil {
t.Error("evicted the wrong streams: the oldest must go first")
}
// 20 orphans past the cap, then one for the first live request; each
// live request completes and frees its own slot.
if stages["stream_evicted"] != 21 {
t.Errorf("stream_evicted = %d, want 21", stages["stream_evicted"])
}
}
Loading