synchronizers: a transient start failure is FAILED and retried, never FATAL (#7248) - #7309
Merged
Merged
Conversation
… FATAL (#7248) A .listener whose subscription the embedded broker refused while it was still taking its store lease was retried once in the same pass, promoted to FATAL on the next pass and stripped from synchronization for good, so every message on that topic was silently lost until the file's bytes changed. The same FAILED-then-FATAL promotion sat in JobSynchronizer, CamelSynchronizer (within the very first pass, via the in-pass retry) and SchemasSynchronizer (an unresolved data source). All four now record a failed start as FAILED with its cause, return false so the in-pass retry has it, and retry it on later passes, healing to CREATED on success; the gate includes FAILED because the multitenant wrapper resets only the lifecycle between tenants. ListenersManager throws for a missing handler instead of returning normally as CREATED and running. The processor ran its phases only on a pass carrying a NEW or MODIFIED artefact, so an idle instance never retried anything; it now gives every FAILED artefact one START attempt on such a pass and, every DIRIGIBLE_SYNCHRONIZER_FAILED_RETRY_INTERVAL_SECONDS (30), on an idle instance, without touching isSynchronizationNeeded, which the test framework waits on. A repeat of the same error logs at DEBUG. Verified: ListenerArtefactSubscriptionRetryIT (new, fails without the processor change), the new *SynchronizerRetryTest unit tests, the unit suites of the six touched modules, formatter:validate with the cache wiped, and the release javadoc build on commons-config and core-initializers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
delchev
force-pushed
the
issue-7248-listener-transient-fatal
branch
from
September 10, 2026 13:55
edd482a to
2adacda
Compare
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Fixes #7248.
The defect
A
.listener/listener.tsartefact whose subscription fails while the embedded broker is still starting is retried exactly once, in the same synchronization pass. On the next pass theSTARTphase finds itFAILEDand registersFATAL; from the pass after that,SynchronizationProcessor.parseDefinitionsstrips everyFATALartefact from the set before any phase runs. Nothing brings the listener back until the file's bytes change, and since a topic discards what it delivers to nobody, every message published to that destination afterwards is silently lost.FATAL- "this artefact is broken, stop trying" - was being reached by a transient failure of a collaborator that is expected to be briefly unavailable at exactly that moment (with a shared message store the broker may not take its lease for far longer than the first second of boot, andEmbeddedBrokerMessagingConfigalready promises that a listener started before mastery "fails and is retried"). Same root cause as #7217 (the client-Java path, fixed in #7220), different module, different mechanism for giving up.The same pattern elsewhere
Asked to check the other artefacts, I grepped every
registerState(..., FATAL, ...)in the synchronizers. Four sites, all the same shape - a second failure of a collaborator promoted toFATAL:FATALListenerSynchronizerSTARTon aFAILEDlistener (pass 2)JobSynchronizerSTARTon aFAILEDjob (pass 2)CamelSynchronizerSTARTon aFAILEDroute - within the very first pass: the outer catch records a refused createFAILEDand returnsfalse, the in-pass cross-retry reachesSTART, and the guard firesSchemasSynchronizer.datasourcea schema names, possibly in a project published on a later passEvery other synchronizer with a
STARTphase already recordsFAILEDand keeps retrying;registerFatalsis defined and called by nothing.The fix
The issue's first, most surgical option, applied to all four: a failure of a collaborator is
FAILED, retried every pass, and neverFATAL- the same philosophycase BROKEN:inparseDefinitionsalready applies one level down (a definition that failed to parse is re-parsed every pass because "the original failure may have been transient"), and the shapeViewsSynchronizeradopted in #6942.CREATEbranch records a start that threw asFAILEDwith its cause and returnsfalse(it used to registerCREATEDwithrunning = false- the issue's first "smaller thing" - and the job branch registeredCREATEDwithout even the cause). TheSTARTphase drops theFAILED -> FATALguard, retries the start while the artefact isFAILEDor not running, heals it toCREATEDon success (so the Registry and Problems views stop showing a stale error) and staysFAILED+return falsewhile refused.STARTguard is dropped; the existingaddToProcessor+CREATEDthen is the retry.FATALpromotion block is dropped; the existingFAILED+return falseis the retry.ListenersManager.startListenerthrows for a missing handler instead of logging one ERROR and returning normally - the issue's second "smaller thing": the normal return had the synchronizer record the artefactCREATEDand running with nothing subscribed. A handler is also something a later pass may publish, soFAILEDis the right reading for it too.The second layer the IT found: an idle instance never ran a retry at all.
processSynchronizersruns its phases only when the pass carries aNEWorMODIFIEDartefact (or is the boot pass), andisSynchronizationNeededadmits a pass only on a registry change - so with theFATALguard gone, a FAILED listener was still retried only when someone published something else. The processor now gives every FAILED artefact one START attempt per pass: on any pass that runs without new or modified artefacts (retryFailed), and on an idle instance everyDIRIGIBLE_SYNCHRONIZER_FAILED_RETRY_INTERVAL_SECONDS(default 30) through a private gate (isFailedRetryDue). The publicisSynchronizationNeededkeeps meaning "the registry changed" - the test framework'swaitForStableSynchronizationpolls it for a ten-second quiet window, which is also why the retry cadence is its own key and not the ten-second job tick. No cross-retry loop on that path: a permanently failing artefact costs one refused call per interval, andregisterStatelogs a repeat of the same error at DEBUG instead of stack-tracing at ERROR each time (the per-pass "Error occured during synchronization" summary line remains - one line per interval per broken artefact, the operator signal the single boot-time ERROR never was).Per-tenant correctness needed one more line.
MultitenantBaseSynchronizercompletes one shared artefact once per tenant and resets only the lifecycle between tenants, notrunning- so aSTARTgate onrunningalone lets the first tenant to subscribe flip the flag and skip every tenant after it (a pre-existing weakness of theSTARTphase this retry would have inherited). The gate is thereforeFAILED || !running; both managers are idempotent per tenant (LISTENERS.containsKey,scheduleJob'scheckExists), which is what makes the repeated attempt safe.Cost, stated: a permanently failing artefact now stays
FAILEDinstead of being stripped, so it occupies the in-pass cross-retry loop (DIRIGIBLE_SYNCHRONIZER_CROSS_RETRY_COUNTx_INTERVAL_MILLIS, 10 x 10 s by default) on every pass - exactly the cost aFAILEDview already carries since #6942, and the cost theUPDATEphase'sif (FAILED) return falsewas always paying between the first and the second pass. Artefacts already persisted asFATALby an earlier version stay stripped until republished (the file's bytes must change once).The
.claude/docs/synchronizer-model.mdguide gains a paragraph on this.Verification
ListenerArtefactSubscriptionRetryIT(new, HTTP-level, in the smoke set) - the end-to-end guard, modelled onJavaListenerSubscriptionRetryIT. A@MockitoSpyBean ActiveMQConnectionArtifactsFactoryrefuses the connection whose exception handler carries the probe's handler path (so the platform's own messaging is untouched), a.listeneron a topic is deployed, and the artefact row is asserted across passes:FAILEDand not running after a forced pass 1 (the refusal actually happened), stillFAILEDafter a forced pass 2 (the pass that used to registerFATAL), then - with the refusal lifted and nothing forced or published from there on -CREATEDand running within the idle instance's own retry (the passes the artefact used to be stripped from, and which used to run no phase at all). Then a real round trip through the JS handler. Cross-retry count/interval and the FAILED retry interval are shortened for the class and restored. Verified to fail without the processor change (the artefact stayedFAILEDwith no start attempted on the later passes).ListenerSynchronizerRetryTest(4),JobSynchronizerRetryTest(3),CamelSynchronizerRetryTest(2),SchemasSynchronizerRetryTest(1) - new unit tests in the style ofViewsSynchronizerRetryTest, each asserting the refused start readsFAILEDnotCREATED, the second refusal staysFAILEDnotFATAL, and the healed artefact readsCREATEDwith no stale error.ListenersManagerTest.testStartListenerOnMissingListenernow asserts the throw.engine-listeners,engine-jobs,engine-camel,data-structures,core-initializers,commons-config: green.mvn formatter:formaton the changed modules, formatter cache wiped,mvn -T 1C formatter:validate:BUILD SUCCESS.Not verified: a real multi-tenant instance with a second provisioned tenant (the per-tenant gate is reasoned from
MultitenantBaseSynchronizer.completeInternaland covered by unit tests only), and the Job / Camel / Schema retries end to end (unit-tested at the synchronizer boundary).🤖 Generated with Claude Code