-
Notifications
You must be signed in to change notification settings - Fork 2.2k
docs(rfc): separate structural validation from component build #25848
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
Open
pront
wants to merge
12
commits into
master
Choose a base branch
from
pront-rfc-split-transform-lifecycle
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+247
−0
Open
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
7a66785
docs(rfc): split component lifecycle into validate_structure and build
pront 6f45a34
docs(rfc): correct healthcheck pre/post-commit semantics and guard-cl…
pront 79cb007
docs(rfc): park credential-refresh vs healthcheck-timing lifecycle as…
pront 85f6713
docs(rfc): broaden sink spawn audit to all transitive build() spawns
pront 2217071
docs(rfc): drop startup validate_with_context double-compile; add blo…
pront cf4ae39
docs(rfc): retract cannot-diverge claim (stub vs real tables); genera…
pront a08d65b
docs(rfc): resolve worker-ownership via side-effect-free healthcheck …
pront 2380d5d
docs(rfc): generalize healthcheck-conformance wording for existing sinks
pront 7c5f977
docs(rfc): require structured worker ownership, narrow parity scope, …
pront 44eabd0
docs(rfc): simplify RFC — split migration by transforms/sinks, move t…
pront 8944a26
docs(rfc): fix step contradiction and dangling Outstanding Questions …
pront a0225b3
docs(rfc): address PR review threads 2/3/6 — remove behavior-change s…
pront File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,247 @@ | ||
| # RFC - Separate Structural Validation from Component Build | ||
|
|
||
| Component config traits conflate two concerns in a single `build()` method: pure structural | ||
| validation (safe to run any time, including under `vector validate --no-environment`) and | ||
| side-effecting construction (resolving credentials, opening clients, building healthcheck probes). | ||
| This is most acute for `TransformConfig`, but the same shape of problem exists for `SinkConfig`. | ||
| This RFC introduces environment-free validation hooks alongside the existing `build()`: a shared | ||
| `validate_structure()` for context-free checks, plus (for transforms) a `validate_with_context()` | ||
| for checks that compile against the schema/enrichment context. `vector validate` can then check | ||
| configuration without touching the environment, and `build()` has a clear contract: it is the phase | ||
| where side effects are allowed. | ||
|
|
||
| ## Context | ||
|
|
||
| - Immediate motivation: [#25161](https://github.com/vectordotdev/vector/pull/25161) fixed | ||
| `vector validate --no-environment` silently skipping VRL/condition errors by adding `validate_env()` | ||
| and stub-enrichment plumbing. The resulting split between `validate()` (context-free, ignores its | ||
| `&TransformContext` parameter) and `validate_env()` (VRL/condition compilation) is what this RFC | ||
| cleans up. | ||
| - `TransformConfig` already has both hooks; this RFC renames them and formalizes the split by | ||
| whether a check needs the context. `SinkConfig` has neither: it exposes only `build()`, which | ||
| returns `(VectorSink, Healthcheck)`. What sinks lack is a structural phase that validates | ||
| config-level construction without reaching the real endpoint. | ||
|
|
||
| ## Scope | ||
|
|
||
| ### In scope | ||
|
|
||
| - For `TransformConfig`: rename the two existing hooks and split them by whether they need the | ||
| context. `validate()` becomes `validate_structure(&self)` for context-free checks; it drops the | ||
| `&TransformContext` parameter it currently ignores. `validate_env()` becomes | ||
| `validate_with_context(&self, context)` for checks that compile against the assembled | ||
| `TransformContext` (VRL programs, conditions, enrichment references), run in `vector validate` | ||
| (both modes). At startup `build()` performs the equivalent compilation, so | ||
| `validate_with_context` is not run there (avoiding a double compile; see the call-site note). This | ||
| is a rename and clarifying split, not new surface; the signatures now enforce which checks may | ||
| touch the context. | ||
| - For `SinkConfig`: add `validate_structure()` only (new; defaults to a no-op). Sinks have no | ||
| no VRL/condition compilation checks, so they do not need `validate_with_context`. Move the lexical | ||
| checks currently trapped in `build()` (e.g. the routing-field confinement check) into | ||
| `validate_structure()` so `--no-environment` catches them. | ||
| - Establishing the contract that `build()` may have side effects but must not spawn background | ||
| tasks; anything that spawns moves to startup (`run()` for sinks) so a rolled-back reload leaves | ||
| nothing running. This contract is enforced for sinks and for migrated function/sync transforms. | ||
| Task transforms are the known exception: `aws_ec2_metadata` spawns from `TransformConfig::build()` | ||
| and others spawn from `TaskTransform::transform()`, both invoked pre-commit by | ||
| `TopologyPiecesBuilder::build_task_transform()`. Enforcing the contract there requires a | ||
| topology-builder change and is out of scope (see Motivation and Future Improvements). The contract | ||
| is therefore stated as "must not spawn" with task transforms explicitly carved out until that | ||
| change lands, rather than claimed as universally true on introduction. | ||
| - Healthchecks are side-effect-free probes, like Kubernetes readiness/liveness probes. A component's | ||
| healthcheck must not share live background workers or mutable runtime state with the component; it | ||
| may acquire its own short-lived resources (a token, a probe connection) and must release them | ||
| promptly when it completes. Background workers are therefore owned by the sink inside its own | ||
| `run()` future and terminate when that future is dropped, so there is no topology-owned lifecycle | ||
| to manage even for a detached `require_healthy=false` probe. Existing sinks that violate this | ||
| principle (by sharing a live worker or connection between their healthcheck and the sink) should be | ||
| refactored to conform, altering their healthcheck behavior if necessary so it acquires and releases | ||
| its own short-lived resources. | ||
| - Update `TopologyPiecesBuilder` and `vector validate` to call `validate_structure()` 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. Adding `validate_structure()` to sources is doable but a larger effort than this | ||
| RFC's scope; see Future Improvements. | ||
| - Changes to user-visible configuration format. | ||
|
|
||
| ## Motivation | ||
|
|
||
| - `vector validate` has no clean way to "check VRL without starting threads." The current workaround | ||
| (stub enrichment tables plus a separate `validate_env()` hook) 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. This RFC establishes that such spawns belong in startup, not | ||
| `build()`, owned by the component future so they terminate when the component is removed. Sinks | ||
| are clean here: their `build() -> run()` boundary is genuinely post-commit, so relocating a spawn | ||
| such as `gcp_pubsub`'s token-regeneration task fixes the leak. Task transforms are not: their | ||
| spawns (whether in `build()` like `aws_ec2_metadata`'s metadata-refresh loop, or in `transform()` | ||
| like `throttle`'s rate-limiter flush) run pre-commit because `TopologyPiecesBuilder::build_task_transform()` | ||
| invokes `transform()` before commit. Fixing task-transform rollback needs a topology-builder | ||
| change (deferring `transform()` to post-commit) and is out of scope here. | ||
| - Testing transform logic requires spinning up background machinery because construction and startup | ||
| are inseparable. | ||
| - 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 resolves real credentials and constructs a live `Healthcheck` future | ||
| (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, lexical checks) without also being | ||
| able to reach the real endpoint. | ||
| - [#25840](https://github.com/vectordotdev/vector/issues/25840) is a concrete, currently-open | ||
| instance of this gap. 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 change to configuration format. The one observable difference is stricter | ||
| validation: `vector validate --no-environment` now runs `validate_structure` for sinks, so configs | ||
| with structural errors (e.g. the routing-field confinement check) that previously passed | ||
| `--no-environment` and only failed at boot will now be rejected earlier. This is a correctness | ||
| improvement, but it means a config that formerly passed `--no-environment` may now fail it. | ||
|
|
||
| ### Implementation | ||
|
|
||
| The config-time phases are validation (context-free plus, for transforms, context-dependent checks) | ||
| and build, with a strict naming rule: a `validate_` method does no external I/O and spawns no tasks | ||
| (it may build derived in-memory state, e.g. enrichment indexes via `TableRegistry::add_index`), and | ||
| any method with external side effects is named for the construction it performs. Startup | ||
| (post-commit) is unchanged. | ||
|
|
||
| ```text | ||
| // Phase 1: validation. No external I/O, no network, no spawning. | ||
| // (May build derived in-memory state such as enrichment indexes.) | ||
| // Split by whether a check needs the schema/enrichment context: | ||
|
|
||
| // 1a. Context-free checks: malformed URIs, out-of-range values, duplicate keys, | ||
| // reserved output names. Runs everywhere, including generic config compilation. | ||
| // Transforms + Sinks: validate_structure(&self) -> Result<(), Vec<String>> | ||
| // (transforms: renamed from validate(); sinks: new, today sinks have no hook) | ||
|
|
||
| // 1b. Context-dependent checks: VRL/condition compilation against the assembled | ||
| // context, whose enrichment tables may be stubs. Runs in `vector validate` | ||
| // (both modes); at startup build() does the equivalent, so this is not | ||
| // re-run there. Transforms only. | ||
| // Transforms: validate_with_context(&self, context) -> Result<(), Vec<String>> | ||
| // (renamed from validate_env()) | ||
|
|
||
| // Phase 2: construct the component. Side effects are allowed (resolve credentials, open | ||
| // clients). Must NOT spawn background tasks (task transforms excepted, see Scope): | ||
| // spawns move to startup so a rolled-back reload leaves nothing running. | ||
| // Sinks: build(context) -> (VectorSink, Healthcheck) // as today | ||
| // Transforms: build(context) -> Transform // as today | ||
|
|
||
| // Startup, unchanged. | ||
| // Pre-commit: topology builder runs Healthcheck (awaited if require_healthy; detached | ||
| // otherwise). TopologyPiecesBuilder::build_transform() wires channels. | ||
| // Post-commit: VectorSink::run() / spawn_diff() starts the Task. | ||
| ``` | ||
|
|
||
| `build()` keeps its current signature for both traits, so no return-type reshaping is required. For | ||
| transforms this renames `validate()` to `validate_structure()` (dropping the `&TransformContext` | ||
| parameter it currently ignores) and `validate_env()` to `validate_with_context()`. For sinks it is | ||
| additive: a new `validate_structure()` in front of `build()`. In both cases `build()` gains the | ||
| discipline that it does not spawn, with task transforms carved out until the topology-builder change | ||
| lands (see Scope); this RFC does not deliver task-transform rollback safety. | ||
|
|
||
| Existing component wiring and serialization registration are unaffected. | ||
|
|
||
| **Call sites:** | ||
|
|
||
| | Call site | Transforms | Sinks | | ||
| | --- | --- | --- | | ||
| | `vector validate --no-environment` | `validate_structure` + `validate_with_context` | `validate_structure` | | ||
| | `vector validate` (full) | `validate_structure` + `validate_with_context` + `build` | `validate_structure` + `build`, then run the returned `Healthcheck` | | ||
| | Startup / reload (pre-commit) | `validate_structure` + `build` + `build_transform` (channel wiring) | `validate_structure` + `build`, then run or spawn the `Healthcheck` per `require_healthy` | | ||
| | Startup / reload (post-commit) | `spawn_diff` starts the Task | `VectorSink::run` | | ||
|
|
||
| The table shows which validation *applies* on each path, not a fresh invocation per row. | ||
| `validate_structure` is owned by generic `ConfigBuilder` compilation; all paths inherit its result | ||
| transitively. `validate_with_context` runs in `vector validate` only: `build()` already compiles | ||
| the same VRL/conditions at startup, so running it again there would double-compile and could repeat | ||
| enrichment-index registration. Note: `validate_with_context` compiles against stub enrichment tables | ||
| (whose `add_index()` always succeeds) while `build()` uses the real ones (which can reject an | ||
| index), so they can disagree on enrichment-dependent errors; both must share the same compilation | ||
| helper. | ||
|
|
||
| `--skip-healthchecks` and the per-sink and global `healthcheck.enabled` gates and the configured | ||
| timeout remain the caller's responsibility (`TopologyPiecesBuilder`), exactly as today. `build()` | ||
| returns the raw probe future unchanged; nothing about healthcheck gating moves onto the component. | ||
|
|
||
| ## 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. | ||
| - **Add a separate `validate_environment` phase between `validate_structure` and `build`.** An | ||
| earlier draft of this RFC did exactly that. It was rejected for two reasons. First, naming: a | ||
| `validate_` method that resolves credentials, opens connections, and spawns a token-refresh task | ||
| is not validating, it is constructing; the name would lie about its side effects. Second, a | ||
| separate phase adds machinery for no gain: `build()` already returns a self-contained `Healthcheck` | ||
| future that the topology builder runs, and under the side-effect-free healthcheck contract that | ||
| future owns its own short-lived probe resources anyway. Keeping healthcheck construction inside | ||
| `build()`, exactly as the `(VectorSink, Healthcheck)` shape does today, needs no state-transfer | ||
| channel and no extra phase. The genuine environment check, the healthcheck probe, stays a deferred | ||
| future the topology builder runs; running it is the validation. | ||
| - **A single `validate_structure(context)` for transforms instead of the two-hook split.** This | ||
| would require the assembled `TransformContext` (stub enrichment plus merged schema) at every call | ||
| site, including generic `ConfigBuilder` compilation, which today only has | ||
| `TransformContext::default()`. Building the merged schema there needs the input graph and is a | ||
| substantial change. The rejected fallback, passing a partial/default context and guarding on | ||
| `context.key.is_some()`, is the guard pattern the current `validate()`/`validate_env()` split was | ||
| designed to avoid; folding both into one context-carrying method would reintroduce it. The chosen | ||
| split keeps context-free checks context-free (no assembly, no guards) and confines the context | ||
| requirement to `validate_with_context`, which only runs where the context already exists. | ||
|
|
||
|
|
||
| ## Plan Of Attack | ||
|
|
||
| **Migration for transforms:** | ||
|
|
||
| 1. Add `validate_structure(&self)` to `TransformConfig` with default `Ok(())`. Wire `validation.rs` | ||
| to call it alongside `validate()` during the migration window. `validate_structure` is owned by | ||
| `ConfigBuilder` compilation. | ||
| 2. Rename `validate_env()` to `validate_with_context()` in a single PR: update the trait default, | ||
| the one call site in `validate.rs`, and all implementors. Signatures are identical so no | ||
| migration window is needed. `validate_with_context` is owned by `vector validate`, matching | ||
| where `validate_env()` runs today. | ||
| 3. Migrate `validate()` to `validate_structure()` one transform at a time. The signature drops | ||
| `&TransformContext`, so this cannot be a straight rename. Transforms with real `validate()` logic | ||
| need the full move-and-override treatment; all others only need `validate_structure()` added if | ||
| they have structural checks hidden elsewhere (e.g. buried in `build()`). Open a tracking issue | ||
| covering all affected transforms -- including those with no `validate()` but with pure checks in | ||
| `build()` -- and check them off incrementally. Once all are done, remove `validate()` from the | ||
| call sites and then from the trait. | ||
|
|
||
| **Migration for sinks:** | ||
|
|
||
| 1. Add `validate_structure(&self)` to `SinkConfig` with default `Ok(())`. Wire `validation.rs` to | ||
| call it. Migrate lexical checks out of `build()` one sink at a time (e.g. the | ||
| [#25840](https://github.com/vectordotdev/vector/issues/25840) routing-field confinement check in | ||
| `aws_s3`). Where a check is not a pure predicate (e.g. `Template::confine()` consumes its input | ||
| and returns a protected copy), `validate_structure()` runs the check on a clone; `build()` still | ||
| attaches the real runtime guard. | ||
| 2. Enforce the no-spawn contract on sink `build()` per component. The audit must cover every task | ||
| spawned directly or transitively from `SinkConfig::build()`. Examples: the GCP sinks | ||
| (via `spawn_regenerate_token()`), `datadog_traces` (APM stats flush thread), and `redis` | ||
| (connection repair task via `build_connection()`). Track the full list in a dedicated tracking | ||
| issue. For each spawn: (a) bring the worker under the sink's `run()` future using structured | ||
| concurrency (poll it in a `select!` or hold an abort-on-drop handle) so it stops when that | ||
| future ends; and (b) decouple the healthcheck so the probe acquires and releases its own | ||
| short-lived resources. Add regression tests for rollback and removal. The no-spawn contract may | ||
| only be claimed trait-wide once every sink in the tracking issue has been migrated. | ||
|
|
||
| ## Future Improvements | ||
|
|
||
| - Add `validate_structure()` to `SourceConfig`. Sources have no `build()`-returns-inert-handle shape | ||
| today (`Source` is `BoxFuture<'static, Result<(), ()>>`, the run loop itself), so the pure-check | ||
| phase is the natural first step there too: some sources (e.g. `socket`) already defer all work into | ||
| the returned future; others (e.g. `file`) perform environment-dependent work eagerly and would | ||
| need that separated from their structural checks. | ||
| - Rollback safety for task transforms: `TopologyPiecesBuilder::build_task_transform()` calls | ||
| `t.transform(...)` pre-commit, and some task transforms spawn there (e.g. `throttle`). Deferring | ||
| that invocation to post-commit is a separate change to the topology builder. | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.