fix(engine): complete live in-place processor reconfigure and report its true mode#2619
Conversation
…its true mode Two engine gaps blocked §4 live hot-reload from working end to end, both hidden by mock-based tests. Found by the full-engine integration test in PR2 (#2616), which runs the reconfigure path against the real processor.Service. 1. Second running-guard (the fix). #2617 removed processor.Service.Update's running-guard via UpdateWhileRunning, but MakeRunnableProcessor has its OWN guard: it refuses a running instance (ErrProcessorRunning) and flips i.running. lifecycle.ReconfigureProcessor called it on the still-running instance and got ErrProcessorRunning, so the live swap never happened and ApplyPlanLive silently fell back to a restart every time. New MakeRunnableProcessorForReconfigure builds the runnable WITHOUT the guard and WITHOUT touching i.running (the instance legitimately stays running across an open-before-teardown swap; the caller owns that lifetime). ReconfigureProcessor now uses it. MakeRunnableProcessor is unchanged for the fresh-start path. 2. Honest applied-mode reporting. ApplyPlanLive returned no signal of the path it took, so callers guessed the mode from the pre-apply plan (LiveEligible) — wrong whenever a live-eligible diff falls back to a restart (e.g. a parallel processor that cannot be swapped live). New provisioning.ApplyMode + Diff.AppliedMode carry the ground truth, set at each ApplyPlanLive success return (provisioned / in_place / restart). AppliedMode is a runtime outcome, excluded from JSON/hash/wire (json:"-"); Plan never sets it. The `--dev` watcher (PR2) consumes it for a truthful mode label. Risk tier: 1 (data path — processor lifecycle + provisioning apply path). Failure-mode analysis: - MakeRunnableProcessorForReconfigure is guard-free BY DESIGN. Misuse (calling it for a fresh start) could produce two runnables wrapping one instance. It is called from exactly one site (ReconfigureProcessor), which pairs it with an immediate open-before-teardown node swap; the fresh-start path keeps the guarded MakeRunnableProcessor. Both behaviors are pinned by tests. - Ack/position safety unchanged: the swap happens at a record boundary, new processor opens before the old tears down, and on open failure the old keeps running (invariant 3 — no drop). No serialized format changes. - Diff.AppliedMode is additive and non-behavioral: it changes no apply logic, only reports what happened. Excluded from the hash's hashable view (which lists its own fields) and from the proto, so no wire/stale-plan impact. - Observability: the mislabel would have shown as "mode:in_place" logs with a concurrent "pipeline started" (restart) log — exactly what the integration test now cross-checks. - Rollback: revert this commit; §4 in-place reverts to always-restart (the prior behavior), no data-format migration needed. Regression tests (each verified to fail without the corresponding fix): - TestService_MakeRunnableProcessorForReconfigure_BypassesRunningGuard - TestApplyPlanLive_ProcessorUpdate_NotLiveReconfigurable_FallsBackToRestart now asserts AppliedMode==restart despite a live-eligible plan - AppliedMode assertions on the in_place / restart / provisioned / empty paths Advances ROADMAP §4 (hot-reload / dev). Unblocks PR2 (#2616). Note: pkg/lifecycle has a pre-existing flaky error-injection test (source connector error) unrelated to this change; it passes on re-run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B3cLokwNJYLLBpdyt3cgGr
…re swap
Independent review of this PR caught a Tier-1 state-corruption bug the mock
-based tests were structurally blind to, newly reachable because this branch
makes the live swap actually run for the first time.
RunnableProcessor embeds a single *Instance whose `running` flag is SHARED
across the two runnables of a swap (the old one and the reconfigure one). The
plain Teardown clears that flag. applyPendingSwap tears down the old runnable
on a successful swap (and the failed-new runnable on an open failure) while the
instance stays running via the other runnable — so the plain Teardown left the
still-running instance marked running=false. That silently disarmed the
Update and Delete running-guards (processor.Service) for the rest of the
pipeline's life: an ordinary processor Update would then mutate stored config
out from under the live node, and Delete would tear down a live instance.
Fix: new RunnableProcessor.TeardownForReconfigure tears down the plugin WITHOUT
clearing the shared instance's running flag. applyPendingSwap uses it (via a
type-assert that falls back to Teardown for non-RunnableProcessor
implementations, e.g. mocks) for both swap-teardown sites. running is now
cleared only by a real pipeline stop, which tears down the node's current
processor via the plain Teardown. The instance stays running throughout a
reconfigure regardless of swap success or failure — which is the truth.
Regression tests (both verified to fail without the fix):
- TestReconfigureSwap_KeepsInstanceRunning_GuardsStayArmed (pkg/processor):
real RunnableProcessors sharing an instance; asserts running survives the
reconfigure teardown and Update/Delete stay refused, and that a real stop
(plain Teardown) clears running and re-arms them.
- TestProcessorNode_Reconfigure_{SuccessfulSwap,OpenFailure}_UsesReconfigureTeardown
(pkg/lifecycle/stream): drives the real applyPendingSwap and asserts it uses
TeardownForReconfigure on the swapped-out/failed processor, while stop uses
the plain Teardown.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3cLokwNJYLLBpdyt3cgGr
Independent review cycle (adversarial self-review + fresh-context reviewer)A fresh-context senior review of the first commit ( Finding: Fix (
Re-review of the fix: verified RESOLVED, nothing merge-blocking — reviewer independently confirmed by reversion that each test fails without its fix, that no swap path still clears Pre-existing follow-up (not introduced here, non-blocking)
|
…ss-goroutine race) Follow-up requested at Tier-1 sign-off. Instance.running is read on the API goroutine (Service.Update/Delete guards) and written on the pipeline's Run goroutine (RunnableProcessor.Teardown at stop). It was a plain bool — an unsynchronized cross-goroutine access (a data race the -race detector flags). This predates the live-reconfigure work; addressing it here since the swap path made the running flag load-bearing for the Update/Delete guards. - running is now an atomic.Bool. - MakeRunnableProcessor reserves it with CompareAndSwap(false, true), which also closes a pre-existing test-and-set TOCTOU (two concurrent callers could both pass a plain read guard and both mark the instance running); on a later build failure the reservation is released with Store(false). - Update/Delete guards use Load(); Teardown uses Store(false). - Store.encode now encodes the pointer (enc.Encode(i)) instead of a value copy (enc.Encode(*i)) so the atomic value is never copied (go vet copylocks); JSON output is identical. Regression test: TestService_RunningFlag_ConcurrentUpdateAndTeardown_NoRace runs a concurrent Update (reads running) against Teardown (writes running); verified to report WARNING: DATA RACE under -race with the plain bool and to pass with the atomic. Whole repo `go vet ./...` is copylocks-clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B3cLokwNJYLLBpdyt3cgGr
Follow-up: Instance.running made atomic (commit 0222371)At Tier-1 sign-off the pre-existing
Independent re-review confirmed the race is fully resolved and the change safe. One remaining pre-existing item (non-blocking, unchanged by this PR): the logical check-then-act in |
Adds `conduit run --dev`: a file watcher that hot-reloads pipeline config edits into a running engine. On a watched YAML change it debounces, re-plans, and applies via provisioning.ApplyPlanLive — an in-place processor swap when the diff is live-eligible (no restart), else a graceful drain-and-restart. Reports the engine's true applied mode (in_place/restart/provisioned) per edit, with --dev.json structured events and stable error codes. Completes ROADMAP §4 (hot-reload / dev), building on the engine live-reconfigure work (#2617, #2619). Independent review clean (both prior blockers — in-place non-functional and a shutdown race — verified fixed). Hardening applied from review: apply-goroutine panic containment (a bad edit never crashes the server / live pipelines), always-tear-down debouncers on shutdown, and no fresh apply started mid-teardown. Full-engine integration test asserts real mode:in_place with zero restart. Docs: docs/operations/dev-hot-reload.md runbook + design doc.
Summary
Two engine gaps blocked ROADMAP §4 live hot-reload from working end to end — both hidden by mock-based tests, both surfaced by PR2's full-engine integration test (which runs the reconfigure path against the real
processor.Service).1. Second running-guard (the actual fix).
#2617 removed
processor.Service.Update's running-guard (viaUpdateWhileRunning), butMakeRunnableProcessorhas its own guard: it refuses a running instance (ErrProcessorRunning) and flipsi.running.lifecycle.ReconfigureProcessorcalled it on the still-running instance, gotErrProcessorRunning, so the live swap never happened andApplyPlanLivesilently fell back to a restart every time. NewMakeRunnableProcessorForReconfigurebuilds the runnable without the guard and without touchingi.running(the instance legitimately stays running across an open-before-teardown swap; the caller owns that lifetime).MakeRunnableProcessoris unchanged for the fresh-start path.2. Honest applied-mode reporting.
ApplyPlanLivereturned no signal of the path it took, so callers guessed the mode from the pre-apply plan (LiveEligible) — wrong whenever a live-eligible diff falls back to a restart (e.g. a parallel processor that can't be swapped live). Newprovisioning.ApplyMode+Diff.AppliedModecarry the ground truth, set at eachApplyPlanLivesuccess return.AppliedModeis a runtime outcome, excluded from JSON/hash/wire (json:"-");Plannever sets it.Risk tier: 1 (data path — processor lifecycle + provisioning apply path)
Requires human (DeVaris) sign-off per CONTRIBUTING / CLAUDE.md — this PR does not merge on automated review alone.
Failure-mode analysis
MakeRunnableProcessorForReconfigureis guard-free by design. Misuse (calling it for a fresh start) could produce two runnables wrapping one instance. It has exactly one call site (ReconfigureProcessor), which pairs it with an immediate open-before-teardown node swap; the fresh-start path keeps the guardedMakeRunnableProcessor. Both behaviors are pinned by tests.Diff.AppliedModeis additive and non-behavioral: changes no apply logic, only reports what happened. Excluded from the hash'shashableview (which lists its own fields) and from the proto, so no wire / stale-plan impact.mode:in_placelogs alongside a concurrentpipeline started(restart) log — exactly what PR2's integration test now cross-checks.Tests (each verified to fail without the corresponding fix)
TestService_MakeRunnableProcessorForReconfigure_BypassesRunningGuard— assertsMakeRunnableProcessorstill refuses a running instance while the reconfigure variant builds one and leavesrunninguntouched.TestApplyPlanLive_ProcessorUpdate_NotLiveReconfigurable_FallsBackToRestart— now assertsAppliedMode == restartdespite a live-eligible plan (the exact mislabel bug).AppliedModeassertions on the in_place / restart / provisioned / empty(unknown) paths.Adversarial self-review
Re-read the diff for error paths, concurrency, resource cleanup, and the data-integrity invariants:
runningtwice or leaks a plugin.AppliedModeis set only on success returns; error/stale/refusal returns leave itUnknown, and consumers fall back rather than assert a false mode.hashable{PipelineID, Changes, Desired}, so the new field cannot perturb plan hashes.Verification
go build ./...clean ·golangci-lint0 issues ·pkg/processor+pkg/provisioning+pkg/lifecyclegreen (pkg/lifecycle has a pre-existing flaky error-injection test unrelated to this change; passes on re-run).Advances ROADMAP §4. Unblocks #2616 (PR2).
🤖 Generated with Claude Code
https://claude.ai/code/session_01B3cLokwNJYLLBpdyt3cgGr