Skip to content
Open
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
175 changes: 175 additions & 0 deletions rfcs/2026-07-15-split-component-build-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
# RFC - Split Component Lifecycle into Four Distinct Phases

Component config traits currently conflate structural validation, environment validation, pure
construction, and task spawning into a single `build()` method. This is most acute for
`TransformConfig`, but the same shape of problem exists for `SinkConfig`. This RFC proposes a
shared `ComponentConfig` trait with three explicit config-time phases (`validate_structure`,
`validate_environment`, and `build`), implemented by `TransformConfig` and `SinkConfig`,
to make `vector validate` reliable, prevent resource leaks on topology reload rollback, and
simplify unit testing.

## Context

- Immediate motivation: [#25161](https://github.com/vectordotdev/vector/pull/25161) fixed
`vector validate --no-environment` silently skipping VRL/condition errors, but needed ~540 lines
(`validate_env()` plus `TransformContext::key` guard clauses) to work around the lack of a clean
trait contract.
- Investigating the fix surfaced that `SinkConfig` has the same entanglement in a milder form.
`build()` already returns `(VectorSink, Healthcheck)`, an unstarted sink plus a deferred
environment check, and the topology builder already treats `Healthcheck` as a distinct phase
(`run_healthchecks` gates topology commit in `src/topology/running.rs`, before `spawn_diff`
actually starts driving events). Formalizing this as a named phase on a shared trait, rather than
an implicit convention, closes the same `vector validate` gap for sinks that this RFC closes for
transforms.

## Scope

### In scope

- A new `ComponentConfig` trait with associated types `Context` and `Built`, defining
`validate_structure`, `validate_environment`, and `build` as the three config-time phases.
Comment on lines +29 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Add an environment-output associated type

The shared trait is specified with only Context and Built, but the same validate_environment method is later expected to return nothing for transforms and a Healthcheck future for sinks. Without another associated return type (or separate per-kind traits), this ComponentConfig contract cannot represent the sink healthcheck output and the implementation has to either ignore sink healthchecks or abandon the shared trait shape described here.

Useful? React with 👍 / 👎.

- `TransformConfig: ComponentConfig<Context = TransformContext, Built = Transform>`: introduce
`validate_structure`, `validate_environment`, and `build` as construction only, no task spawning.
Phase 4 (task construction and channel wiring) stays in `TopologyPiecesBuilder::build_transform`
unchanged.
- `SinkConfig: ComponentConfig<Context = SinkContext, Built = VectorSink>`: hoist `Healthcheck`
construction out of `build()` and into `validate_environment`; redefine `build` as construction
of an unstarted `VectorSink`, no task spawning.
- Update `TopologyPiecesBuilder` and `vector validate` to call each phase at the right point for
both transforms and sinks.
- Migrate all existing transforms and sinks.

### Out of scope

- `SourceConfig` is deferred. `Source` is defined as `BoxFuture<'static, Result<(), ()>>`
(`lib/vector-core/src/source.rs`), so `build()`'s return value *is* the run loop rather than an
inert handle. Applying `ComponentConfig` to sources is doable but a larger effort than this RFC's
scope; see Future Improvements.
- Changes to user-visible configuration format or component behavior.

## Motivation

- `vector validate` has no clean way to "check VRL without starting threads." The current workaround
(stub enrichment tables, `validate_env()`, `context.key` guards) must be replicated per-transform.
- `build()` spawns background tokio tasks before a topology reload is committed. If the reload is
rolled back, those tasks leak.
- Testing transform logic requires spinning up background machinery because construction and startup
are inseparable.
- The `build()` signature gives no signal about whether an implementation is safe to call
speculatively (during validation) or whether it has observable side effects.
- For sinks, `--no-environment` is all-or-nothing: `vector validate` either skips `build()` entirely
for every sink (no config validation beyond deserialization) or calls the real `build()`, which
today also constructs a live `Healthcheck` future tied to real credentials/endpoints
(e.g. `src/sinks/http/config.rs`, `src/sinks/kafka/config.rs`). There is no way to validate a
sink's config-level construction (auth parsing, encoder setup) without also being able to reach the
real endpoint.
- [#25840](https://github.com/vectordotdev/vector/issues/25840) is a concrete, currently-open
instance of the same gap on the `validate_structure` side. The routing-field template
confinement check is purely lexical (no I/O), but it lives in `SinkConfig::build()`
(e.g. `src/sinks/aws_s3/config.rs`), so `--no-environment` skips it and a confinement-violating
config is only caught at real boot.

## Proposal

### User Experience

No user-visible change. `vector validate` and `vector validate --no-environment` behave the same
externally; the difference is that `validate` now exercises the same VRL compilation and sink
construction paths as normal startup, rather than separate, potentially divergent ones.
Comment thread
pront marked this conversation as resolved.

### Implementation

A shared `ComponentConfig` trait defines the three config-time phases. Phase 4 (startup) is not
part of the config trait: for sinks it is the existing `VectorSink::run()`; for transforms it stays
in `TopologyPiecesBuilder::build_transform()`, which owns the topology channels that cannot be
plumbed through a config-trait method.

```
ComponentConfig:
// Phase 1: pure structural checks (malformed URIs, duplicate keys, out-of-range values).
// No context, no I/O. Default: always ok.
validate_structure()

// Phase 2: environment checks.
// Transforms: compile VRL/conditions against stub (validate) or real (startup) enrichment tables.
// Sinks: produce a deferred Healthcheck future — caller awaits or spawns per require_healthy.
validate_environment(context)

// Phase 3: construct the component. No task spawning. Safe to discard on rollback.
build(context)

// TransformConfig: context = TransformContext, validate_environment returns nothing
// SinkConfig: context = SinkContext, validate_environment returns Healthcheck future

// Phase 4 is unchanged for both:
// Sinks: VectorSink::run() — existing, no change.
// Transforms: TopologyPiecesBuilder::build_transform() — existing, no change.
```

Existing component wiring and serialization registration are unaffected.

**Call sites:**

| Call site | Phases invoked (transforms) | Phases invoked (sinks) |
| --- | --- | --- |
| `vector validate --no-environment` | `validate_structure` + `validate_environment` (stub context) | `validate_structure` + `build` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep sink build out of no-environment validation

For vector validate --no-environment, this still calls the normal sink build phase, but several sinks need environment-backed setup just to construct the sink. Checked S3: current build() awaits create_service(), and create_service goes through AWS region/credential providers that may read profile/env state or IMDS; HTTP AWS auth similarly awaits credentials_provider(...). Following this row would make --no-environment touch or fail on the external environment again unless there is a separate no-environment construction-validation path or stubbed build context.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

vector validate --no-environment calling validate_environment with a stubbed context raises a bit of a red flag.

It might be too cumbersome but maybe we should split validate_environment into validate_internal_environment and validate_external_environment so that validate_internal_environment validates things like VRL transforms, enrichment tables, and other components we control and validate_external_environment validates connections, healthchecks (maybe), and other things that are external to the config

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good point, let's discuss offline and come back to this later.

| `vector validate` | `validate_structure` + `validate_environment` + `build` | `validate_structure` + `build` + `validate_environment` → await returned `Healthcheck` directly |
| Normal startup / reload (pre-commit) | `validate_structure` + `validate_environment` (real resources) + `build` | `validate_structure` + `build` + `validate_environment` → await or spawn returned `Healthcheck` per `require_healthy` |
Comment on lines +116 to +117

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve healthcheck enabled and timeout handling

Awaiting or spawning the raw returned Healthcheck loses the wrapper semantics that currently gate probes on both global and per-sink healthcheck.enabled and apply the configured timeout (src/topology/builder.rs:670-683, src/topology/builder.rs:781-810). For example, the HTTP sink currently returns a real probe whenever a healthcheck URI exists, regardless of whether that sink disabled healthchecks, because the builder wrapper is what turns it into a no-op; following this matrix would probe disabled healthchecks and ignore custom timeouts unless EnvironmentOutput carries those options or remains wrapped as a Task.

Useful? React with 👍 / 👎.

| Normal startup / reload (post-commit) | `TopologyPiecesBuilder::build_transform` (unchanged) | `run` (existing `VectorSink::run`) |

`--skip-healthchecks` short-circuits only the probe execution, not `build()`. The sink `build` phase
runs regardless; only the `validate_environment` healthcheck probe is skipped, matching current
behaviour (`src/validate.rs:315`).

**Migration:**

1. Add `ComponentConfig` with `validate_structure`, `validate_environment`, and `build` as
required methods. Provide a blanket adapter for un-migrated components where `validate_structure`
is always a no-op (never calls legacy `build()`), `validate_environment` calls legacy `build()`
for sinks (to extract the `Healthcheck`) and is a no-op for transforms, and `build()` calls

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve un-migrated transform environment checks

During the migration window, this adapter makes validate_environment a no-op for transforms, but several existing transforms currently use validate_env() for no-environment checks that are not just remap/filter/route—for example reduce, window, delay, sample, and throttle all validate configured conditions there. If vector validate --no-environment is switched to this trait before every such transform is migrated in the same change, those configs will silently stop catching condition compilation errors until startup.

Useful? React with 👍 / 👎.

legacy `build()` and returns the component. Un-migrated sinks therefore call legacy `build()`

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reuse legacy sink builds during migration

For any sink left on the adapter during normal startup, this runs legacy build() once from validate_environment and again from build(), but that can change observable behavior rather than just add temporary overhead. For example, RedisSinkConfig::build opens a Redis connection and shares that same connection with the healthcheck and sink today (src/sinks/redis/config.rs:209-211, :247-250); with a constrained Redis deployment such as maxclients=1, the adapter would consume or fail on a second connection even though current startup succeeds. Reuse the first legacy build result or avoid enabling the adapter for sinks that perform environment-backed setup.

Useful? React with 👍 / 👎.

twice during full startup; this is acceptable during the migration window.
2. Migrate transforms one at a time, starting with `remap` (VRL) and `filter` / `route` (conditions).
Prerequisite for `remap`: move VRL file reading (`file:`/`files:` options) to config load time so
`compile_vrl_program` never does file I/O — by the time any lifecycle phase runs, the source is
already a `String` in memory. This keeps `validate_structure` free of I/O and `validate_environment`
free of file-path concerns.
Comment on lines +133 to +136

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve watched VRL file paths

When a remap transform uses file or files and config watching is enabled, current reload detection depends on RemapConfig::files_to_watch() returning those paths (src/transforms/remap.rs:405-409), which are added to the watcher alongside the main config paths (src/config/watcher.rs:78-85). If moving VRL file reading to config load time leaves lifecycle phases with only an in-memory String and no file-path concerns, edits to the .vrl files can stop triggering ReloadComponents; preserve the original paths somewhere the watcher still consumes, or call out the watcher update as part of this step.

Useful? React with 👍 / 👎.

3. Migrate sinks one at a time: hoist `Healthcheck` construction out of `build()` into
`validate_environment`, starting with `http` and `kafka` as representative cases, since their
`build()` impls already construct `Healthcheck` as a clearly separable step
(`src/sinks/http/config.rs`, `src/sinks/kafka/config.rs`).
4. Update `TopologyPiecesBuilder` to invoke phases at the appropriate points for both transforms and
sinks. For sinks this mostly formalizes the existing `build`, `run_healthchecks`, `spawn_diff`
ordering in `src/topology/running.rs` rather than restructuring it.
5. Update `vector validate` to call transform `validate_environment` with stub enrichment tables
under `--no-environment` and real tables otherwise. VRL/condition compilation stays in
`validate_environment` (it needs `TransformContext` for enrichment tables and merged schema).
For sinks, `--no-environment` skips `validate_environment` entirely. Remove the
`validate_env()` workaround method.
6. Remove the blanket adapter once all transforms and sinks are migrated.

## Alternatives

- **Keep the current approach and add more per-transform workarounds.** Already proven insufficient:
the fix PR added hundreds of lines of guard logic with no improvement to the trait contract.
- **Return a compiled artifact from `validate_environment` so `build()` doesn't recompile.**
`build()` runs once per topology build or reload, not per event, so the duplicate VRL/condition
compilation is a one-off, compile-time cost of a few milliseconds at most. Not worth the added
type-system complexity (GAT vs. `Box<dyn Any>`) this would require.

## Plan Of Attack

1. Spike `ComponentConfig` behind a blanket adapter for one transform and one sink to prove the
pattern compiles and holds under `TopologyPiecesBuilder` and `vector validate`.
2. Open a tracking issue listing every transform and sink still on the blanket adapter; migrate them
incrementally, checking each off as it moves over.
3. Remove the blanket adapter and the `validate_env()` workaround once the tracking issue is clear.

## Future Improvements

- Apply the same `ComponentConfig` contract to `SourceConfig`. This requires introducing a new
unstarted-source type to serve as `ComponentConfig::Built` (today `Source` is
`BoxFuture<'static, Result<(), ()>>`, the run loop itself, not an inert handle), and auditing each
source implementation individually: some (e.g. `socket`) already defer all work into the returned
future and would migrate cheaply; others (e.g. `file`) perform environment-dependent work eagerly
inside `build()` today and would need that work relocated to `validate_environment`.
10 changes: 5 additions & 5 deletions rfcs/_YYYY-MM-DD-issue#-title.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# RFC <issue#> - <YYYY-MM-DD> - <title>
# RFC [<issue#>] - <YYYY-MM-DD> - <title>

One paragraph description of the change.

Expand Down Expand Up @@ -54,10 +54,10 @@ One paragraph description of the change.

Incremental steps to execute this change. These will be converted to issues after the RFC is approved:

- [ ] Submit a PR with spike-level code _roughly_ demonstrating the change.
- [ ] Incremental change #1
- [ ] Incremental change #2
- [ ] ...
1. Submit a PR with spike-level code _roughly_ demonstrating the change.
2. Incremental change #1
3. Incremental change #2
4. ...

Note: This can be filled out during the review process.

Expand Down
Loading