-
Notifications
You must be signed in to change notification settings - Fork 494
proposal: change architecture for loki pipelines #4940
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
10a7b3f
88a0cba
8485668
bde5122
3b607e1
25e6c36
21a3c8a
951a719
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,170 @@ | ||
| # Proposal: Reliable Loki pipelines | ||
|
|
||
| * Author: Karl Persson (@kalleep) | ||
| * Last updated: 2025-11-30 | ||
| * Discussion link: https://github.com/grafana/alloy/pull/4940 | ||
|
|
||
| ## Abstract | ||
|
|
||
| Alloy's Loki pipelines currently use channels, which limits throughput due to head-of-line blocking and can cause silent log drops during config reloads or shutdowns. | ||
|
|
||
| This proposal introduces a function-based pipeline using a `Consumer` or `Appender` interface, replacing the channel-based design. | ||
|
|
||
| Source components will call functions directly on downstream components, enabling parallel processing and returning errors that sources can use for retry logic or proper HTTP error responses. | ||
|
|
||
| ## Problem | ||
|
|
||
| Loki pipelines in Alloy are built using (unbuffered) channels, a design inherited from promtail. | ||
|
|
||
| This comes with two big limitations: | ||
| 1. Throughput of each component is limited due to head-of-line blocking, where pushing to the next channel may not be possible in the presence of a slow component. An example of this is usage of [secretfilter](https://github.com/grafana/alloy/issues/3694). | ||
| 2. Because there is no way to signal back to the source, components can silently drop logs during config reload or shutdown and there is no way to detect that. | ||
|
|
||
| Consider the following simplified config: | ||
| ``` | ||
| loki.source.file "logs" { | ||
| targets = targets | ||
| forward_to = [loki.process.logs.receiver] | ||
| } | ||
|
|
||
| loki.process "logs" { | ||
| forward_to = [loki.write.loki.receiver] | ||
| } | ||
|
|
||
| loki.write "loki" {} | ||
| ``` | ||
|
|
||
| `loki.source.file` will tail all files from targets and compete to send on the channel exposed by `loki.process`. Only one entry will be processed by each stage configured in `loki.process`. If a reload happens or if Alloy is shutting down, logs could be silently dropped. | ||
|
|
||
| There is also no way to abort entries in the pipeline. This is problematic when using components such as `loki.source.api` where caller could cancel request due to e.g. timeouts. | ||
|
|
||
| ## Proposal 0: Do nothing | ||
| This architecture works in most cases, it will be hard to use slow components such as `secretfilter` because a lot of the time it's too slow. | ||
| It's also hard to use Alloy as a gateway for loki pipelines with e.g. `loki.source.api` due to the limitations listed above. | ||
|
|
||
| ## Proposal 1: Chain function calls | ||
|
|
||
| Loki pipelines are the only ones using channels for passing data between components. Prometheus, Pyroscope and otelcol are all using this pattern where each component just calls functions on the next. | ||
|
|
||
| They all have slightly different interfaces but basically work the same. Each component exports its own interface like Appender for Prometheus or Consumer for Otel. | ||
|
|
||
| We could adopt the same pattern for loki pipelines as well with the following interface: | ||
|
|
||
| ```go | ||
| type Consumer interface { | ||
| Consume(ctx context.Context, entries []Entry) error | ||
| } | ||
| ``` | ||
|
|
||
| Adopting this pattern for loki pipelines would change it from a channel-based pipeline to a function-based pipeline. This would give us two things: | ||
| 1. Increased throughput because several sources such as many files or http requests can now call the next component in the pipeline at the same time. | ||
| 2. A way to return signals back to the source so we can handle things like giving a proper error response or determine if the position file should be updated. | ||
|
|
||
| Solving the issues listed above. | ||
|
|
||
| A batch of entries should be considered successfully consumed when they are queued up for sending. We could try to extend this to when it was successfully sent over the wire, but that could be considered an improvement at a later stage. | ||
|
|
||
| Pros: | ||
| * Increase throughput of log pipelines. | ||
| * A way to signal back to the source | ||
| Cons: | ||
| * We need to rewrite all loki components with this new pattern and make them safe to call in parallel. | ||
| * We go from an iterator-like pipeline to passing slices around. Every component would have to iterate over this slice and we need to make sure it's safe to mutate because of fan-out. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah yeah, that's true for mutating components. I think we will need to copy data to prevent mutating (other options like overlays are not really a thing we have in Go). So if we need to copy, we have two main options:
Option 2 is much more performant as majority of entries won't need to be mutated I'd guess. So when we get a slice and requires no changes, we forward it as-is. But if it needs changes, we need to create a new slice, reuse all the entries in it that don't need mutating, but create copies for those that need mutating. I think that can work, but we will need to be careful.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes exactly. The second option you is most likely better for performance but we also always need to handle it properly. The first should be the "safest". You could optimize this a bit.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Btw I think all of our components have potential to mutate. The only one that don't is |
||
|
|
||
| ## Proposal 2: Appendable | ||
|
|
||
| The prometheus pipeline uses [Appendable](https://github.com/prometheus/prometheus/blob/main/storage/interface.go#L62). | ||
| Appendable only has one method `Appender` that will return an implementation of [Appender](https://github.com/prometheus/prometheus/blob/main/storage/interface.go#L270). | ||
|
|
||
| We could adopt this pattern for loki pipelines by having: | ||
| ```go | ||
| type Appendable interface { | ||
| Appender(ctx context.Context) Appender | ||
| } | ||
|
|
||
| type Appender interface { | ||
| Append(entry Entry) error | ||
| Commit() error | ||
| Rollback() error | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's no requirement for us to have Rollback as part of the problem statement. Isn't this complicating things without good reason?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe but I can think of one case where it would be useful to have For a source like Imagine we get a api request. We start a "transaction" by calling Appender with a context. A api request could be aborted in two scenarios, client aborts the request or we are restarting / shutting down. In both these cases we should not commit the batch and return a proper error response. If client aborted due to e.g. timeout they won't care about the response but we would return it either way. The implementation could be context aware (we pass a context when we acquire Appender) but here we have two signal we need to handle and I have not figured out a good solution to that other than having some action that would abort the batch. With channels this is easier because we can just listen for both signals |
||
| } | ||
| ``` | ||
|
|
||
| This approach would, like Proposal 1, solve the issues listed above with a function-based pipeline, but the pipeline would still be iterator-like (one entry at a time). | ||
|
|
||
| ### How it works | ||
| Source components would: | ||
| Obtain an `Appender` that can fan-out to all downstream components, then call `Append` for each entry. | ||
| If every call to `Append` is successful, `Commit` should be called; otherwise `Rollback`. | ||
|
|
||
| Processing components would: | ||
| Implement `Appendable` to return an `Appender` that runs processing for each entry and fan-out to all downstream components. | ||
|
|
||
| Sink components would: | ||
| Implement `Appendable` to return an `Appender` that buffers entries until either `Commit` or `Rollback` is called. | ||
|
|
||
| Pros: | ||
| * Increase throughput of log pipelines. | ||
| * A way to signal back to the source | ||
| * Iterator-like pipeline - one entry at a time | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is this a pro?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Well it should have better performance characteristics and you spotted one of then in this comment #4940 (comment) The other one would be that every component would have to perform a loop on all entries passed individually. In proposal 2 it would always be one loop, starting from the source. |
||
| * Transaction semantics, sources have better control on when a batch should be aborted. | ||
| Cons: | ||
| * We need to rewrite all loki components with this new pattern and make them safe to call in parallel. | ||
| * More complex API | ||
|
|
||
| ## Considerations for implementation | ||
|
|
||
| ### Handling fan-out failures | ||
|
|
||
| Because a pipeline can "fan-out" to multiple paths, it can also partially succeed. We need to determine how to handle this. | ||
|
|
||
| Two options to handle this: | ||
| * Always retry if one or more failed - This could lead to duplicated logs but is easy and safe to implement. This is also how otelcol works. | ||
| * When using `loki.source.api`, we would return a 5xx error so the caller can retry. | ||
| * When using `loki.source.file`, we would retry the same batch again. | ||
| * Configuration option `min_success` - Only retry if we don't succeed on at least the configured number of destinations. | ||
|
|
||
| ### Transition from current pipeline to either Proposal 1 or Proposal 2 | ||
| Changing the way loki pipeline works is a big effort and will affect all loki components. | ||
|
|
||
| We have a couple of options how to do this: | ||
| 1. Build tag | ||
| * We build out the new pipeline under a build tag. This way we could build custom Alloy image using this new pipeline and test it out internally before we commit it to an official release. | ||
| 2. New argument | ||
| * We could add additional argument to components in addition to `forward_to`. This new argument would be using the new pipeline code. This argument would be protected by experimental flag and we would remove it once we are confident in the new code and remove the current pipeline. | ||
| 3. Replace pipeline directly | ||
| * We could replace the pipeline directly without any fallback mechanism. This should be doable over several PRs where we first only replace the communication between components, e.g. in loki.source.file we would still have the [main loop](https://github.com/grafana/alloy/blob/main/internal/component/loki/source/file/file.go#L229-L247) reading from channel and send one entry at a time with this new pipeline between components. Then we could work component by component and remove most of channel usage. | ||
|
|
||
| ### Affected components | ||
|
|
||
| The following components need to be updated with this new interface and we need to make sure they are concurrency safe: | ||
|
|
||
| **Source components** (need to call `Consume()` and handle errors): | ||
| - `loki.source.file` | ||
| - `loki.source.api` | ||
| - `loki.source.kafka` | ||
| - `loki.source.journal` | ||
| - `loki.source.docker` | ||
| - `loki.source.kubernetes` | ||
| - `loki.source.kubernetes_events` | ||
| - `loki.source.podlogs` | ||
| - `loki.source.syslog` | ||
| - `loki.source.gelf` | ||
| - `loki.source.cloudflare` | ||
| - `loki.source.gcplog` | ||
| - `loki.source.heroku` | ||
| - `loki.source.azure_event_hubs` | ||
| - `loki.source.aws_firehose` | ||
| - `loki.source.windowsevent` | ||
| - `database_observability.mysql` | ||
| - `database_observability.postgres` | ||
| - `faro.receiver` | ||
|
|
||
| **Processing components** (need to implement `Consumer` and forward to next): | ||
| - `loki.process` | ||
| - `loki.relabel` | ||
| - `loki.secretfilter` | ||
| - `loki.enrich` | ||
|
|
||
| **Sink components** (need to implement `Consumer`): | ||
| - `loki.write` | ||
| - `loki.echo` | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you outline for both proposal how the transition would look?
I do think Proposal 1 looks more attractive, but I could be convinced to do something in-between Proposal 1 and 2.
What I'm not sure about is whether we flip a switch here or make this a gradual transition. It could affect the behaviour of the pipelines, especially the error propagation. But if it is better in every way, a flip-a-switch approach is viable. If it has some trade-offs we may need to make it an opt-in, gradual transition.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Both proposal 1 and 2 are similar in that we change from channel based pipeline to function based pipelines. So I don't think the transition would be any different between them. I also think that the transition could be a separate discussion, like do we want to be able to still use the current pipeline code for a while.
Would you like me to add a section to discuss the difference between proposal 1 and 2?
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That was my preferred solution first too before I though some more about the actual implementation. In proposal 2 we don't need to handle slices. We don't have to loop in each component individually but it all just becomes one loop. So it's closer to what we have today but we still get the benefits what this proposal aims to solve with the additional benefit that sources now also have control to abort if they need to.