Skip to content

fix(loki.process): Eliminate per-stream goroutines in multiline stage#6036

Open
kgeckhart wants to merge 3 commits intomainfrom
kgeckhart/fix-loki-process-multiline-goroutines
Open

fix(loki.process): Eliminate per-stream goroutines in multiline stage#6036
kgeckhart wants to merge 3 commits intomainfrom
kgeckhart/fix-loki-process-multiline-goroutines

Conversation

@kgeckhart
Copy link
Copy Markdown
Contributor

Brief description of Pull Request

Replace per-stream goroutines in the loki.process multiline stage with a single goroutine and a shared time.Timer, reducing allocations by up to 76% and latency by up to 39% under benchmark.

Pull Request Details

The multiline stage previously spawned one goroutine per active log stream to handle max_wait_time timeouts via time.After. In environments with many label combinations this creates O(N) goroutines and allocations that grow with cardinality.

The new implementation uses a single goroutine with a shared timer set to the earliest per-stream deadline. When it fires, all expired streams are flushed and the timer is rescheduled to the next deadline. Streams are removed from the map after a timeout flush, preventing unbounded map growth for idle streams.

All debug log calls are now wrapped in if Debug { ... } guards, consistent with the drop, regex, and labels stages, to avoid fmt.Sprintf and buffer.String() more overhead when debug logging is disabled.

Benchmarks

                          │   main   │            this PR            │
                          │  sec/op  │   sec/op     vs base          │
MultilineStage/debug=false  2.131µ ±1%   1.310µ ±1%  -38.55% (p=0.000 n=10)
MultilineStage/debug=true   2.131µ ±1%   1.610µ ±1%  -24.45% (p=0.000 n=10)

                          │   main   │            this PR            │
                          │  B/op    │   B/op       vs base          │
MultilineStage/debug=false  2112.0 ±0%    808.0 ±0%  -61.74% (p=0.000 n=10)
MultilineStage/debug=true  2.062Ki ±0%  1.310Ki ±0%  -36.51% (p=0.000 n=10)

                          │   main   │            this PR            │
                          │ allocs/op │  allocs/op  vs base          │
MultilineStage/debug=false  21.000 ±0%    5.000 ±0%  -76.19% (p=0.000 n=10)
MultilineStage/debug=true    21.00 ±0%   12.00 ±0%  -42.86% (p=0.000 n=10)

Notes to the Reviewer

Two intentional behavioural changes:

  1. Stale-block flush on entry receipt: Previously a timed-out block was only flushed when the time.After goroutine fired. The new code also flushes inline in processEntry when a new entry arrives for a stream that has been idle past max_wait_time. The outcome is the same — the block is flushed before the new entry is processed — but the trigger differs.

  2. Cross-stream output ordering is now deterministic: The old goroutine-per-stream model had multiple goroutines writing concurrently to out, so ordering across streams was non-deterministic. The new model serializes all output through a single goroutine, so entries are emitted in arrival order.

PR Checklist

  • Tests updated

@kgeckhart kgeckhart requested a review from a team as a code owner April 10, 2026 17:11
@kgeckhart kgeckhart changed the title fix(loki.process): eliminate per-stream goroutines in multiline stage fix(loki.process): Eliminate per-stream goroutines in multiline stage Apr 10, 2026
@kgeckhart kgeckhart requested review from Copilot and removed request for a team April 10, 2026 17:21
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR optimizes the loki.process multiline stage by removing per-stream goroutines/time.After usage and replacing it with a single Run-loop goroutine that manages per-stream state and a shared timer to flush on max_wait_time, with additional debug-log guarding to reduce overhead when debug is disabled.

Changes:

  • Reworked multiline stage execution to track per-stream state in a shared map and flush via one shared time.Timer.
  • Added synchronous processEntry/flushState flow and stream cleanup logic to prevent idle-stream state buildup.
  • Expanded tests and added a benchmark covering debug on/off performance.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
internal/component/loki/process/stages/multiline.go Replaces per-stream goroutines with a single timer-driven loop and shared per-stream state map.
internal/component/loki/process/stages/multiline_test.go Adds new test cases and a benchmark for multiline behavior and performance.

Comment on lines +164 to +171
// Arm the timer for any stream that now has the earliest deadline,
// including streams where currentLines==0 (just hit max_lines) so
// the timer fires to remove them if they subsequently go idle.
if key := e.Labels.FastFingerprint(); m.streams[key] != nil {
if dl := m.streams[key].lastSeen.Add(m.cfg.MaxWaitTime); nearestDeadline.IsZero() || dl.Before(nearestDeadline) {
armTimer(dl)
}
}
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

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

The timer is only re-armed when an entry creates an earlier deadline. If the stream that currently defines nearestDeadline receives new entries (pushing its deadline later), the timer won't be extended and will still fire at the old deadline, causing periodic full-map scans even when no streams are actually expiring. Consider tracking which stream owns nearestDeadline (or rescanning when the owning stream is updated) so the timer can be pushed out when the earliest deadline moves later.

Copilot uses AI. Check for mistakes.
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.

I don't think this is worth it to fix

out <- m.flushState(state)
}
}
m.streams = nil
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

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

timer is created for the Run loop but never stopped on the early return when in closes. Stopping it before returning avoids keeping a runtime timer around unnecessarily until it fires/gets GC'd.

Suggested change
m.streams = nil
m.streams = nil
timer.Stop()

Copilot uses AI. Check for mistakes.
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.

I don't think this is worth it to fix

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants