Failure handling: ownership, containment, recovery and evidence¶
Design for issue #77. It turns the failure catalogue from PR #76's reviews into one model, so that later changes are checked against stated rules instead of rediscovered by review.
Status. This is a design document on branch feature/77-failure-model, first written against
feature/control-plane at dfbfdd9 and since merged with 72b6fd4 (Mongoose 1.0.33-rc1 candidate, Fluxtion runtime
1.1.3 / compiler 1.0.80). It is meant for review after PR #76 ships. It does not approve the release and does not widen it.
Every guarantee carries one of three status marks:
| Mark | Meaning |
|---|---|
| [Implemented] | True at the base commit. The cited test exists at the base commit and passes in the full suite (1013 tests, 0 failures, 14 skipped at dfbfdd9). |
| [Implemented: M2] / [Implemented: M3] | True on this branch after that milestone (M2 includes the round-2 review fixes). The cited test was added or changed on this branch and passes in its full suite. |
| [Proposed: Mn] | Agreed direction, implemented on this branch in milestone n (see Milestones). It is not true until that milestone lands. |
| [Unsupported] | A deliberate non-guarantee. Callers must not rely on it. |
Decisions marked working decision, owner to confirm are recommendations from the reviewer response to #77 that the product owner has not formally confirmed. Decisions marked owner confirmed were confirmed after the M2 review.
Related documents: control-plane contracts, capture resource identity, shutdown runbook, and Fluxtion's failed event cycle policy. Overload and back-pressure are out of scope here; they moved to issue #79.
Contents¶
- Principles
- Facts kept apart
- Failure domains
- Failure matrix
- Terminal-state contract
- Failure taxonomy
- Retry classification per effect
- Bounds
- Supervision and callback failure
- Audit failure
- Fault-injection test API
- Fluxtion-level concerns
- Milestones
- Open questions for the owner
Principles¶
These are the seven principles from the #78 catalogue, folded into #77, with the reviewer's amendments.
| # | Principle | Status |
|---|---|---|
| P1 | Every request ends. It ends with exactly one reply, within its deadline, including across death, close and shutdown. Amendment: the reply has an identified author: a rule, the driver's safety net, or the shutdown/death path. | One reply: [Implemented] (ControlTerminalAdmissionTest, ControlScenarioHarnessTest#terminalSweepAnswersEveryIndependentlyRetainedUnpublishedRequest, ControlTerminationAndAdmissionTest#errorHandlerDeathCompletesClaimsWithoutAnotherSenderAndAllowsReentrantCompletion). Author: the author is carried by replyAuthor: DRIVER on driver-authored records [Implemented] (IndependentEvidenceMutationTest#structuredDriverFallbackIsRejectedRegardlessOfReasonWording, FailureKindFlowTest#aRequestNoRuleAnswersIsAnsweredByTheDriverAsFailedDriverFallback). The kind says the cause, not the author: a rule's reply can carry FAILED_DRIVER_FALLBACK after an abandoned effect, and the driver also writes FAILED_DEAD. A rule-authored Reply record carries no author field; an author on every record is [Proposed: M4]. |
| P2 | Ownership is explicit and exact. Every resource, name and service instance has one owner, identified by (group, generation) or by the instance. Ownership passes only on a confirmed release. An unconfirmed release keeps ownership until shutdown, which reports it. | [Implemented] for processors, names, captures and services (rows R7, R8, M1, M4 below). |
| P3 | Physical work outlives logical decisions. A logical timeout or cancellation never releases what a physical operation holds. Bounds are permits held until the physical operation returns. | [Implemented] (row R6). |
| P4 | One deadline per call. It is computed at entry and spent on admission, locks and reply together. No unbounded wait on an agent thread, and no wait under a lock the stop path needs. | [Implemented] (row R2). |
| P5 | Honest outcomes. APPLIED means it happened. Refusal, failure, "not yet" and "cannot verify" are distinct and each carries a reason. Amendment: APPLIED for a stop means the Release effect was observed, not that a rule said so. | APPLIED-stop: [Implemented] by audit invariant I17 and the harness's caller-side ledger (IndependentEvidenceMutationTest#appliedStopWithoutReleaseIsRejected, #stopObligationDoesNotDependOnRuleSpelling, ControlScenarioHarnessTest#nameOnlyStopLedgerRejectsAReplyBeforeReleaseWithoutConsultingAudit). Distinct outcomes: ReplyOutcome has four values [Implemented]; a structured FailureKind beside the reason [Implemented: M2] (FailureKindFlowTest#ruleAuthoredRepliesCarryTheirKindToTheCallerAndTheAudit). |
| P6 | Fail closed in replay and evidence. Anything that cannot be reproduced or verified stops with a reason. Audit gaps are reported, never smoothed over. | Replay: [Implemented] (row R9). Evidence: an incomplete audit makes invariants unverifiable [Implemented] (ControlAuditExportTest#sinkFailureSurvivesExportAndFalseCompleteClaimIsRejected); the runtime reaction to a failed audit sink [Proposed: M3] (Audit failure). |
| P7 | Every failure is evidence. It has a recorded outcome, attributed to its owner, checkable by an invariant with a mutation test. Amendment: absence is not evidence. An oracle reports which invariants were vacuous on an export, that is, had zero records to judge. | Recorded outcome and mutation tests: [Implemented] (ControlInvariantsMutationTest, IndependentEvidenceMutationTest, docs/reports/pr76-third-review-followup-2026-10-25/run-source-mutations.py). Vacuity reporting: [Proposed: M6]. |
Facts kept apart¶
These four facts are related but never interchangeable. A test or an operator reads each from its own source.
| Fact | Values | Where it is read |
|---|---|---|
| Command disposition | refused, accepted/pending, applied, failed, detached | ControlReply.outcome and ControlReply.kind; a null from a timed-out wait means detached |
| Runtime availability | NEW, STARTING, RUNNING, CLOSING, CLOSED, DEAD |
ControlDriver.GroupState, controlGroupStatus(group).state() |
| Physical resource status | acquired, installed, closing, confirmed closed, close failed (unknown) | controlGroupStatus: captureResourcesHeld, captureResourceSlotsInUse, captureCloseFailures, captureCleanupFailures |
| Evidence status | complete, incomplete, unknown | ControlAuditSink.isComplete(), ControlAuditExport.verify(path) |
Complete evidence is necessary for an invariant PASS, not sufficient: it says the record is whole, not that behaviour was correct.
Failure domains¶
| Domain | Owner of decisions | Fails how | Contained to |
|---|---|---|---|
Data processor (handwritten DataFlow) |
its group's agent | handler, lifecycle or service callback throws | the processor: retried by RetryPolicy for dispatch, reported and released for lifecycle |
Data processor (Fluxtion-generated, InternalEventProcessor) |
its group's agent | any generated callback throws | the processor: quarantined, never retried or restarted |
| Processor group | its agent thread and control processor | agent thread ends abnormally; control processor throws or wedges | the group: DEAD, health group:<name>/control DOWN. A rule failure leaves the thread alive, so the group then fences every processor from its inputs and timers (DataPathFenced), as quarantine does for one processor, with no restart; remedy: close the group or stop the server. Other groups keep running [Implemented: M4] (GroupContainmentTest#aDeadControlPlaneIsDownAndFencedWhileOtherGroupsKeepServing) |
| Control processor | the group's driver | a rule throws, effects do not settle | the group: driver answers and marks it dead |
| Service | the server's registry, under serviceLock |
start/stop throws, retirement races re-registration |
the service instance: retained, reported, never stopped twice |
| Feed / sink | the event flow manager | full queue, failing source | the queue binding (drop accounting: see #79) |
| Capture I/O | the group's capture permits | open times out or throws, install/uninstall/close throws | the resource: permit and slot held until confirmed |
| Control audit | the group's ControlAuditSink |
ring overflow, sink conversion failure | the evidence: marked incomplete |
| Replay | the group's GroupReplayer |
input, stream or service identity cannot be matched; recorded failure | one processor's replay: stopped with a reason |
| Server | the MongooseServer lifecycle |
startup step blocks, stop exceeds its deadline | the stop: StopResult incomplete, resumable |
| Process / JVM / host | none | crash, kill, power loss | nothing in memory survives |
Ordinary exceptions are contained to the narrowest domain above. VirtualMachineError is never retried or swallowed:
the agent answers what it claimed, then rethrows, and the group dies
(RetryOnlyTheFailedProcessorTest#aVirtualMachineErrorFromAHandlerIsNeitherRetriedNorSwallowed,
FatalStopReviewTest) [Implemented]. An error thrown by an error-reporting callback ends the agent loop without
onClose; the shared liveness checker finds the dead thread, joins it and answers its claimed requests
(ControlTerminationAndAdmissionTest#errorHandlerDeathCompletesClaimsWithoutAnotherSenderAndAllowsReentrantCompletion)
[Implemented]. Interrupts are not a cancellation signal for control work: an interrupted caller detaches
(AgentHandoffTest#workAlreadyRunning_whenItsCallerIsInterrupted_isWaitedFor) [Implemented].
Failure matrix¶
Rows R1 to R11 are the matrix in the issue. Rows M1 to M8 are the reviewer's missing rows. Rows T1 to T3 come from the third review. "Status" refers to the containment and evidence columns together; where they differ, both are given.
Rows from the issue¶
| # | Trigger | Containment and ownership | Caller and operator evidence | Recovery boundary | Status |
|---|---|---|---|---|---|
| R1 | Admission refused or mailbox full | Nothing applied; the name reserved for it is released (onUnapplied) |
REFUSED, and a Refused fallback audit record |
Submit a new request | [Implemented] AgentControlQueueFullTest, ReviewRefusedStopTest#aStopRefusedForAFullMailboxDoesNotRunItsCompletedCallback, IntegrationReleaseRegressionTest#rejectedEnqueueDoesNotReserveTheProcessorName |
| R2 | Caller deadline expires after admission | The accepted request keeps ownership and may finish | A void call returns (or throws if it was never admitted); it never claims cancellation | Observe the original outcome; do not resubmit blindly | [Implemented] HuntVoidDeadlineTest#voidControlCallsMustSpendOneDeadlineOnAdmissionAndReply, ControlTerminationAndAdmissionTest#admissionTimeIsDeductedFromTheRemainingVoidReplyWait, AuditSinkOnAgentThreadTest#aStartThatTimesOut_changesNothing_evenWhenItsInstallRunsLater |
| R3 | Handler or service callback throws | Handwritten processor: retried, then dropped with a report. Generated processor: quarantined (routes and timers removed, instance and name retained, no retry) | ERROR report, ProcessorQuarantined audit record, controlGroupStatus(..).quarantinedProcessors() |
Operator; no automatic restart (working decision 1) | [Implemented] GeneratedProcessorFailureIsolationTest#generatedAuditThrowIsQuarantinedWithoutRetryAndOtherTargetsContinue, #realAgentKeepsHealthyProcessorsRunningAndReportsQuarantinedOwnership, #generatedServiceFailureQuarantinesTheProcessorBeforeItsNextDataCycle, RetryOnlyTheFailedProcessorTest |
| R4 | Agent terminates, including outside a wrapper | Admission stops (DEAD). Unclaimed requests are swept; claimed ones are answered only by the agent or after its thread is joined. Agent-owned state is reclaimed only after the join |
NOT_RUNNING / FAILED, Dead audit record, CleanupPending / CleanedUp |
Post-join backstop, then shutdown | [Implemented] ControlTerminalAdmissionTest#aGroupWhoseThreadDiesIsCleanedUpAndLaterSendersAreAnswered, #aSenderThatFindsTheAgentThreadDeadRunsTheDeathPath, FollowupConcurrencyReviewTest#deathMustCompleteEarlierClaimedAsyncRequestWithoutAnotherServerCall, HuntGroupFailureModesTest#workHandedToADeadGroupMustBeRefusedNotRunOnTheCaller, EarlyStartDeathReviewTest#failureBeforeThreadCaptureStillAnswersAcceptedRequest |
| R5 | Start, stop or teardown throws or never returns | Attempted steps are tracked; a callback already attempted is never repeated; services a still-running group may use are retained | StopResult names unfinished groups and services; the server stays STOPPING |
Call shutdown() again to resume; no repeated side-effectful callback |
[Implemented] BoundedShutdownTest#aGroupThatCannotCloseInTimeIsReportedServicesKeepRunningAndAResumedStopCompletes, FatalStopReviewTest#fatalTeardownMustContinueCleanupWithoutRepeatingCallbacks, OrderedServerStopTest#aProcessorFailingToStopIsReportedAndTheOthersStillStop, ShutdownAdmissionReviewTest#throwingServiceStopCannotBeReportedAsCompleteOrRetriedBlindly. Java cannot pre-empt user code: a callback that never returns is reported, not stopped [Unsupported] |
| R6 | Capture open times out | The logical request fails; the physical open keeps its permit until it returns; a late resource is closed, never installed | FAILED (open timed out); the pending physical operation stays visible in captureResourceSlotsInUse |
Compensation of the late resource | [Implemented] CaptureAsEffectsTest#anOpenThatTimesOutIsAnsweredFailedAndItsLateResourceClosed, #lateOpenAfterStopAndReplacementIsCompensatedNeverInstalled, CapturePermitRegressionTest#rejectedTimeoutSchedulingDoesNotLeakPhysicalAdmission, IndependentReviewControlProbesTest#anOpenWhoseTimerRefusesMustReturnItsPermit |
| R7 | Physical close throws | Ownership and the resource slot are retained; closure is never inferred | Resource ID, operation, failure, captureCloseFailures(), StopResult incomplete; invariant I4 reports it unverifiable |
Adapter-specific verified recovery; no generic automatic retry | [Implemented] CaptureAsEffectsTest#aCloseThatFailsIsRecordedAndCountedOnce, CapturePhysicalShutdownTest#failedPhysicalCloseKeepsShutdownIncompleteWithoutRepeatingCallback, ChronicleShutdownFailureEvidenceTest#failedChronicleCloseKeepsExactStopResultIncompleteAndProducesOperatorEvidence, ChaosAuditVerdictTest#completeRecordWithFailedPhysicalClosureCannotPassChaos |
| R8 | Service retirement races re-registration | One physical instance is never stopped by an obsolete owner once it is live again; admission and the stop claim are atomic; callbacks run outside publication locks | Retirement decision; failed physical stop reported by shutdown | Re-registration claims the instance | [Implemented] HuntServerFailureModesTest#deferredRetirementMustNotStopAnInstanceRegisteredAgain, RetirementReregistrationRaceTest#reregistrationCannotRacePastAnAlreadyClaimedRetirementStop, ServiceAliasRetirementProbeTest#failedPhysicalStopIsStillReportedByShutdown |
| R9 | Replay diverges or cannot identify an input or service | That processor's replay stops with a reason; no live controller side effect | replayer.stopped(processor) with position and expected/observed identity |
Fix configuration or create a new verified replay context | Stop with reason: [Implemented] ReplayControlAndProvenanceTest#aReplayedControlRequestThatDiffersIsADivergence, ReplayIdentityTest#aLaterIncarnationWithNoRecordedStreamIsStoppedLoudly, IndependentReviewReplayProbesTest#aRecordedWithdrawalOfAServiceTheReplayedProcessorNeverHeldMustBeReportedAsDivergence. Structured stop kind (GroupReplayer.stopKind): [Implemented: M2] ReplayIdentityTest#aLaterIncarnationWithNoRecordedStreamIsStoppedLoudly, ReplayControlAndProvenanceTest#aReplayedControlRequestThatDiffersIsADivergence, ReplayRecordingAcceptanceTest#D4_aFailedDispatchIsMarked_andTheReplayStopsThere |
| R10 | Audit writer fails or evidence is dropped | Runtime policy per working decision 2 | Incomplete status, sinkFailures, manifest complete: false; never an invariant PASS |
See Audit failure | Evidence side: [Implemented] ControlAuditExportTest#sinkFailureSurvivesExportAndFalseCompleteClaimIsRejected, #droppedFallbackWithoutDecisionSequenceGapIsStillIncomplete. Runtime reaction (DEGRADED, evidence = UNVERIFIED, I21): [Implemented: M3] AuditFailurePolicyTest |
| R11 | Process, JVM or host dies | In-memory ownership and futures cannot promise recovery | Only what was durably written: Chronicle capture files, exported audit snapshots, replay stores | Restart from configuration; no implied exactly-once external effects | [Unsupported]: no durable control state, no recovery of in-flight requests |
Rows the reviewer added¶
| # | Trigger | Containment and ownership | Evidence | Status |
|---|---|---|---|---|
| M1 | Service retired under name A while the same instance is re-registered under name B | Supersession is by instance, not by name | Retirement record names the instance and the decision | [Implemented] RetirementOtherNameProbeTest#retirementOfOldNameMustNotStopInstanceLiveUnderAnotherName (extreview) |
| M2 | Processor stopped live while a replay cursor still holds undelivered entries | Detaching a cursor with work left is a stopped outcome with a reason | complete() false or stopped() non-null |
[Implemented] ReplayCursorDetachedMidStreamTest#aLiveStopMidStreamMustNotReadAsACompleteSuccessfulReplay |
| M3 | start() throws for a recorded processor |
The failure is a recorded Failed entry at the ordinal it consumed |
Replay of that stream stops ("the recorded run failed here") | [Implemented] RecordedStartFailureReplaysAsSuccessTest#aRecordedStartFailureMustNotLeaveAStreamThatReplaysAsSuccess, RecordedStartupFailureTest#aFailureAfterRecordingAttachesLeavesADurableMarker |
| M4 | Legacy name-keyed capture stop reaches a scoped sink | Stop by bare name refuses when the only match is a different generation | Refusal names the protected generation | [Implemented] ChronicleCaptureEdgeTest#retiringAnOldGenerationMustNotStopTheNewGenerationsCapture, #aLegacyRouterMustNotSilentlyDowngradeRegistrationScopedStart (direct embedding only; unreachable through MongooseServer) |
| M5 | Physical detach retry fails after the logical release | Handle and slot retained; listed by captureCloseFailures()/captureCleanupFailures(); the control table keeps the row |
CleanupPending.failedPhysicalCloses and failedCaptureDetaches |
[Implemented] CaptureAsEffectsTest#failedOfflineUninstallRetainsOwnershipForGroupCleanup, ChronicleGroupInlineTest#failedDetachRetainsItsResourceAndReportsCleanupFailure |
| M6 | A rule performs effects but never replies | The driver's safety net answers FAILED |
The fallback is classified as a driver reply, distinct from a rule reply | replyAuthor: DRIVER marker: [Implemented] IndependentEvidenceMutationTest#structuredDriverFallbackIsRejectedRegardlessOfReasonWording. Live replies and exports classified by kind, never reason text: [Implemented: M2] ControlScenarioHarnessTest (ledger), FailureKindEvidenceMutationTest#anUnhealthyKindOnAFallbackReplyIsRejectedWhateverItsWording, FailureKindFlowTest#aRequestNoRuleAnswersIsAnsweredByTheDriverAsFailedDriverFallback, #aRuleThatThrowsIsAnsweredByTheDriverAsFailedRule |
| M7 | Status API read off the agent thread | Status views are safe from any thread, or documented agent-only | n/a | [Implemented] ReplayStatusApiConcurrencyTest#completeAndStoppedAreSafeToPollWhileProcessorsAttachAndDetach, ReplayStatusPublicationTest; controlGroupStatus is documented weakly consistent |
| M8 | Group close reclaims captures the control processor left open | Leftover release is a recovery, recorded as such. A handle whose requested uninstall failed is a retry, not a leftover | Offline records marked leftover: true, kind: LEFTOVER_RECOVERED, or retry: true for a retried requested uninstall. The oracle (I19) counts every leftover as a violation; there is no per-scenario exemption |
Reclaim and record: [Implemented] CaptureAsEffectsTest#aDeadControlProcessorsGroupStillClosesItsCaptures. Leftover vs retry marking and I19: [Implemented: M2] same test, Review77ProbesTest#injectedPhysicalDetachFailureRetriedAtCloseIsNotAnEngineLeftover, FailureKindEvidenceMutationTest#aLeftoverRecoveryIsRejected |
Rows from the third review¶
| # | Trigger | Containment | Status |
|---|---|---|---|
| T1 | A generated data processor throws; the queue agent's retry used to hand the event back to a wedged graph and report recovery | Generated processors are quarantined without retry; the retry applies only to handwritten DataFlow. On an opaque reader the thrower and its dispatch position are captured at throw time: only the thrower is quarantined or retried, and no subscriber that already handled the event receives it again (OpaquePeerRedeliveryTest, OpaqueHandwrittenPeerTest, OpaqueDispatchHandwrittenFailureTest) |
[Implemented] GeneratedProcessorFailureIsolationTest#generatedAuditThrowIsQuarantinedWithoutRetryAndOtherTargetsContinue |
| T2 | Standalone failed open deletes a concurrent opener's live recording | A failed build removes only the directory it freshly created | [Implemented] StandaloneFailedOpenDeletesConcurrentRecordingTest#failedOpenRemovesTheDirectoryAConcurrentStartIsRecordingInto, CaptureOpenFailureCleanupTest#failedStandaloneBuildNeverDeletesExistingHistory |
| T3 | Name-only stops were not checked by the caller-side ledger | The harness resolves the target when the reply arrives | [Implemented] ControlScenarioHarnessTest#nameOnlyStopLedgerRejectsAReplyBeforeReleaseWithoutConsultingAudit |
Rows from the admin-web validation (svc-admin-web against rc1)¶
| # | Trigger | Containment and evidence | Status |
|---|---|---|---|
| A1 | A fatal error reaches a group's Agrona error handler | GlobalErrorHandler (used by bootServer) called System.exit(-1): one group's fatal error ended the JVM. Now (owner confirmed: exit is never the default) the default fatalErrorPolicy: CONTAIN logs it and keeps the process up: an Error ends that group's thread, so it is DEAD and its health control check DOWN, while other groups keep serving. fatalErrorPolicy: EXIT_PROCESS restores the exit as an explicit deployment policy. An agent-hosted service group whose thread ends reports agent-group:<name>/agent DOWN and its services' up gauges drop to 0; a repeating non-terminal failure is logged at most once per 10 s with a suppressed count; a failed bootServer stops what it started, then throws (the original failure, or BootFailedException carrying the server and its incomplete StopResult when cleanup cannot finish) (review round 4, external review 3) |
[Implemented: M4] GroupContainmentTest#aFatalGroupErrorKillsThatGroupOnlyAndNeverExitsByDefault, #exitIsAnExplicitOptIn, #aRepeatingContainedFailureIsRateLimited, Review77cProbesTest#serviceAgentErrorUnderContain, #failedBootUnderContainLeavesTheProcessRunningWhenMainReturns (inverted: the child ends) |
| A2 | server.processors.stop with a missing or malformed argument, or an unknown group or name |
Argument validated before any stop; unknown group or name answered REFUSED (REFUSED_STATE) instead of an index error or "pending" |
[Implemented: M2] AdminStopCommandValidationTest#malformedAndUnknownStopsAreRefusedNeverPending |
| A3 | Two wordings for one refusal (mailbox full in replies and docs, queue stayed full / queue is full in exceptions) |
Every refusal and exception says mailbox full |
[Implemented: M2] (wording only) |
| A4 | control.group.status exists only as toString() |
control.group.status <group> --json and ControlGroupStatus.toJson() give every field as a JSON object |
[Implemented: M4] AdminStopCommandValidationTest, FailureKindFlowTest#jsonGroupStatusEscapesEveryString |
Terminal-state contract¶
What each terminal state promises, and who settles it. The settling order is: the agent thread before it exits, then the post-join backstop, then shutdown.
| State | Outstanding requests | Resources | Names and services | Who settles | Status |
|---|---|---|---|---|---|
| Dead (agent thread ended abnormally, or the control processor threw) | Claimed: answered FAILED by the agent before it exits, or by the post-join backstop. Unclaimed: swept NOT_RUNNING. New: refused at admission |
Captures stay owned. releaseLeftoverCaptures runs after the join and records each release in the fallback stream. Failures stay listed |
Processor names stay reserved until death cleanup releases them. Quarantined processors keep theirs until shutdown, which reports them | Agent, then post-join backstop (cleanupAfterDeath), then shutdown |
[Implemented] rows R4, M5, M8. Names still reserved by a dead group or a quarantined processor are listed, typed, in StopResult.heldNames [Implemented: M4] (GeneratedProcessorFailureIsolationTest#realAgentKeepsHealthyProcessorsRunningAndReportsQuarantinedOwnership; the dead-group branch has no dedicated test, because death cleanup normally releases a dead group's names) |
| Closed (orderly) | None outstanding by construction (terminal admission). A request still claimed at close is answered FAILED ("group ended", kind FAILED_SHUTDOWN, or FAILED_DEAD if the group died first); this backstop is not reached by any non-defective path and has no test. A late completion is handed to the orphan handler exactly once |
All closed, or listed as failed (captureCloseFailures) |
Released, except quarantined processors | The agent thread in onClose |
[Implemented] ControlTerminalAdmissionTest#requestsQueuedAtCloseAreAnsweredByTheRulesWithoutStartingAnything, ControlTerminationAndAdmissionTest#completionRacingCloseIsDispatchedOrOrphanedExactlyOnce, CaptureShutdownRetryTest#aClosedGroupRetainsFailedCaptureCleanupAndTheNextStopResumesIt |
| Stopped (one processor) | Replies in flight complete normally | Its captures are uninstalled before release and closed after it, or listed | The name is released on confirmed teardown, never on a refusal or a failed start before its release callback | The agent thread | [Implemented] ProcessorStopReleaseTest, FailedStartNameOwnershipTest#failedStartKeepsItsNameThroughTheReleaseCallback, IntegrationReleaseRegressionTest#aDropCannotFreeANewerReservationUnderTheSameName |
| Quarantined (one generated processor) | Later stops of it are answered FAILED ("processor quarantined") |
Existing capture is detached without calling the graph; new installs refused; generated stop()/tearDown() never invoked |
Instance and name retained until shutdown; shutdown stays incomplete and keeps dependent services | Nobody automatically. The operator, per the shutdown runbook | [Implemented] GeneratedProcessorFailureIsolationTest#generatedStopFailureCannotReenterTearDownOrReleaseItsName, #captureAfterQuarantineRefusesInstallAndDetachesWithoutCallingTheGraph, #groupReplayFailureCannotReenterGeneratedLifecycleOnShutdown |
An operator acknowledgement never manufactures successful cleanup. There is no acknowledgement API in this release [Unsupported].
Failure taxonomy¶
One enum, FailureKind, shared by ControlReply, StopResult, replay stop reasons and control-audit records (field
kind). Free text stays beside it as human detail; programs and oracles classify by kind. [Implemented: M2]
| Kind | Meaning | Typical producers |
|---|---|---|
NONE |
Not a failure: applied, or the effect succeeded | Every APPLIED reply |
REFUSED_ADMISSION |
Not admitted: mailbox full, a sender that cannot wait, a caller that withdrew before the claim | ControlDriver.trySend / send, ControlRequest.cancel |
REFUSED_STATE |
Not applied because of the target's state: no such processor, name in use, group or server closing, add cancelled by an earlier stop | Rules (stopUnknown, addCancelled), terminal admission on a closing or closed group |
REFUSED_DEADLINE |
Not admitted within the caller's own deadline | ControlDriver.send(request, admitBy), capture apply timeout |
FAILED_RULE |
The control processor itself failed (a rule threw, effects did not settle, wedged); the driver answered | ControlDriver safety net. Engine defect |
FAILED_EFFECT |
A requested effect failed: start, release, capture open/install/uninstall, delivery | Rules, from the effect result |
FAILED_DRIVER_FALLBACK |
The rules settled without replying, or an effect was abandoned; the driver authored the outcome | ControlDriver safety net. Engine defect |
FAILED_DEAD |
The group died; accepted work was answered by the death path | markDead, failClaimed, sweeps of a dead group, and the driver (replyAuthor: DRIVER) on a fatal error while performing effects |
FAILED_SHUTDOWN |
The group or server ended in order with this work outstanding, or a stop did not complete | StopResult incomplete (BoundedShutdownTest); a reply only as the close backstop for a request still claimed, which no non-defective path reaches (the rules answer queued work at close), so it has no engine test |
QUARANTINED |
A processor was quarantined, or a request addressed one | Isolation, stopQuarantined |
DETACHED |
The caller stopped waiting; the outcome is not yet known. Not a cancellation | StopResult handed off to a stop thread |
UNVERIFIED |
Cannot be verified from the evidence (incomplete or failed audit). Never a pass | ControlReply.evidence while the control audit is degraded (M3), and the EvidenceDegraded record; also the kind of a ControlReply built with a null outcome |
LEFTOVER_RECOVERED |
A resource the control processor should have released was reclaimed by offline cleanup | releaseLeftoverCaptures |
REPLAY_IDENTITY_MISMATCH |
A replay could not match an input, stream, service version or control request | GroupReplayer |
REPLAY_STOPPED |
Any other replay stop: recorded failure, delivery timeout, clock divergence | GroupReplayer |
Changes from the reviewer's list, with reasons:
APPLIEDbecameNONE.APPLIEDis already aReplyOutcome; repeating it in a failure enum would make the same word mean two things.NONEkeeps the kind non-null on every reply, sokind == NONEiffoutcome == APPLIEDcan be checked (invariant I20).QUARANTINEDadded. PR #76 commitcc9956cimplemented quarantine of failed generated processors. A stop of a quarantined processor is neither an effect failure nor a death; operators must be able to find it by kind.DETACHEDandUNVERIFIEDkept although no reply carries them yet.DETACHEDclassifies a handed-offStopResult.UNVERIFIEDis reserved for the evidence-incomplete marker of M3. Defining them now keeps the enum stable across milestones.- No
NOT_RUNNINGkind.ReplyOutcome.NOT_RUNNINGstays the outcome. Its kind says why:REFUSED_STATEfor an orderly close,FAILED_DEADfor a dead group.
Where the kind flows¶
| Carrier | How | Notes |
|---|---|---|
ControlReply.kind() |
New record component, never null | Authored where the outcome is decided: rules (LifecycleRules, CaptureRules, ServiceRules), the driver (ControlDriver), the server's own refusals (MongooseServer) |
ControlEffect.Reply.kind() |
New record component | A rule's reply effect carries it to the driver |
EffectResult.kind() |
New record component | An effect's failure kind (FAILED_EFFECT, QUARANTINED, FAILED_DRIVER_FALLBACK for abandoned effects); every rule node (LifecycleRules, CaptureRules, ServiceRules) copies it into its reply, so no rule reads reason text. The agent classifies an effect whose failure quarantined its processor, or a delivery to a quarantined one, as QUARANTINED (FailureKindQuarantineTest) |
ControlRequest.cancel(FailureKind, String) |
New overload | cancel(String) stays and means REFUSED_ADMISSION |
ControlDriver.reply(..., FailureKind), failClaimed(FailureKind, String) |
New overloads | The old signatures stay, with defaultFor(outcome) and FAILED_DEAD |
StopResult.failureKind() |
New derived method | NONE, DETACHED (handed off) or FAILED_SHUTDOWN |
GroupReplayer.stopKind(processor) |
New method beside stopped(processor) |
REPLAY_IDENTITY_MISMATCH or REPLAY_STOPPED; null when not stopped |
ReplayRouting.MISMATCH |
New "mismatch:" answer prefix beside "wait:" |
The routing says which service failures are identity failures. This is still a text protocol through a public SPI (as "wait:" already was); a typed answer is [Proposed: M4] |
| Control audit | Field kind on Reply, every failed EffectOutcome (rule and fallback), and the Refused, NotRunning, Failed, driver FAILED, Dead, Anomaly (rule threw), ProcessorQuarantined, CaptureWriteFailed and leftover fallback records |
Driver replies keep replyAuthor: DRIVER; failureKind keeps its 1.0.33-rc1 meaning and is always FAILED_DRIVER_FALLBACK on a driver reply, whatever the new kind says, so an rc1 validator still rejects every driver reply |
The oracle (ControlInvariants) and the scenario harness key on the kind:
- I18 rejects driver-authored records (
replyAuthor: DRIVER) and the kindsFAILED_RULE,FAILED_DRIVER_FALLBACK,FAILED_DEADandQUARANTINED, whatever the reason text. The choice is per export: an export in which any record carries akindis judged by kinds only; reason text is read only in an export with no kinds at all (written before the taxonomy). - I19 (new) rejects a leftover recovery, one violation per reclaimed resource. Retries of a requested uninstall are
marked
retry, not leftover. - I20 (new) requires, in a taxonomy export, every delivered reply, every reply-bearing fallback record and every
failed effect outcome to name a kind; a reply's kind is
NONEexactly whenAPPLIED. - I21 (new, M3) reports every span after an
EvidenceDegradedrecord unverifiable, so it can never PASS. - I1 derives the start of a release from the
Stopinput and requestedReleaseeffects, never from the rule namestopStarted. A name-only stop is resolved to the generation live under that name: the name comes from theAddinput, but "started" and "name released" are read from engine-writtenBuildAndStart/ReleaseNameoutcomes, and theReleaseleg is engine-authored too. I1 is therefore not independent of the engine's outcome records. - The harness classifies replies by
kind: a safety-net reply is a failure; a failed stop is explained only byFAILED_EFFECTand an uninstall failure injected into a capture on that target while the request was outstanding (for a name-only stop, on an incarnation of the name live when it was sent). An injectedstop()failure never excuses a failed stop: the release reports the step and the stop isAPPLIED. A refused stop must beREFUSED_STATE; a failed capture request must beFAILED_EFFECT. The chaos workload excuses a failed stop only for a stopped instance with an injected stop or tearDown failure.run-f3-escape-check.pyreplays the reviewer's spurious-failure mutant over 200 seeds atdfbfdd9and on this branch and fails if any seed caught by the old reason-text rule escapes.
API changes¶
All additions are source compatible except record-shape changes (owner confirmed: shipped in a minor version, documented in the unreleased release notes and the client migration checklist):
- Breaking (record shape):
ControlReplygained trailingkind(FailureKind) andevidence(EvidenceStatus { NOT_DEGRADED, UNVERIFIED }, its own enum since review R9) components;StopResultgainedheldNames(typedHeldName(group, name, generation, reason), M4);EffectResultandControlEffect.Replygainedkind;ControlGroupStatusgainedcontrolAuditDegradedandcaptureWriteFailures. The previous canonical constructors remain as secondary constructors, sonew ControlReply(id, outcome, reason, generation, name)still compiles. Record deconstruction patterns that list every component, andequals, now includekind. Two replies equal before may differ by kind. - Behaviour:
ControlInvariants.isSafetyNetFailure(test code) now takes aControlReplyand reads its kind. - Everything else is additive:
FailureKind, the overloads above,StopResult.failureKind(),GroupReplayer.stopKind,ReplayRouting.WAIT/MISMATCH, and the auditkind/leftoverfields.
Retry classification per effect¶
Nothing with unknown side effects is retried automatically. "Retry-safe" means an operator, or a later milestone's explicit policy, may re-issue the effect; it does not mean the engine re-issues it.
ControlEffect |
Class | Why | Does the engine re-attempt it today? |
|---|---|---|---|
BuildAndStart |
Unknown side effects | Runs the supplier and user start() |
No. A failed start is released and answered FAILED |
Release |
Unknown side effects | Runs user stop()/tearDown() |
No. A step already attempted is never repeated on resume (FatalStopReviewTest) |
ReleaseName |
Retry-safe | Idempotent registry removal | No |
CancelRegistration |
Retry-safe | Sets a flag | No |
DeliverService |
Retry-safe at the host | A delivery already made to a processor is skipped; a callback that threw counts as delivered (ServiceDeliveredOnceTest#aRegisterThatThrowsCountsAsDelivered) |
No |
RunTask |
Unknown side effects | Arbitrary caller code | No |
Reply |
Exactly once | Completed by compare-and-set; a second attempt is refused ("not claimed: answered earlier") | No |
FailBoot, ReportFailure |
Retry-safe | Reporting only; a repeat duplicates a report | No |
OpenCapture |
Unknown side effects | Creates physical files and queues; a re-issue creates a new resource | No |
InstallCapture |
Retry-safe after a verified failure | Swaps a listener on the agent thread | No. A failed install is compensated (resource closed) |
UninstallCapture |
Retry-safe | Restores the configured listener | Yes, not bounded: a failed live uninstall is retried once after the release, offline at group close, and again on every resumed shutdown() (cleanupAfterDeath → releaseLeftoverCaptures) until it succeeds. Each retry is recorded retry: true (CaptureAsEffectsTest#failedUninstallDuringProcessorStopIsRetriedAfterRelease, #failedOfflineUninstallRetainsOwnershipForGroupCleanup, CaptureShutdownRetryTest). A bound is [Proposed: M6] |
CloseCapture |
Unknown side effects | Backend partial effects unknown | No. A failed close is retained and listed |
The classification is written here and in the contracts [Implemented as documentation]. Recording it on each
ControlEffect (for example a retrySafety() method) so an oracle can check that no unknown-side-effect operation
runs twice for one opId is [Proposed: M6].
Separately, the data path's RetryPolicy retries a throwing handwritten DataFlow with backoff on the agent thread.
That is dispatch retry, not control-effect retry, and it is not applied to generated processors (row T1).
Bounds¶
| Bound | Default | At the bound | Surfaces as | Status |
|---|---|---|---|---|
| Group mailbox | configured capacity | Producer backs off up to the full-mailbox timeout (10 s); agent threads and lock holders are refused at once | REFUSED |
[Implemented] AgentControlQueueFullTest |
| Capture opens in flight per group | 8 | Open refused (unresolved capture open limit reached) |
FAILED |
[Implemented] CapturePermitRegressionTest#rejectedTimeoutSchedulingDoesNotLeakPhysicalAdmission, CaptureAdmissionAndOrphanReviewTest |
| Capture resource slots per group | 1024 | Open refused (physical capture resource limit reached) |
FAILED, captureResourceSlotsInUse |
[Implemented] CaptureCloseBacklogProofTest#blockedCloserCannotExceedPhysicalResourceAdmission |
| Control audit ring | 10 000 records (mongoose.control.auditCapacity) |
Oldest dropped | isComplete() false, manifest complete: false |
[Implemented] ControlAuditExportTest |
| Effect rounds per request | 64 | Abandoned with an Anomaly; request answered FAILED |
Audit fallback | [Implemented] (no dedicated test; hardening only, second review) |
| Shutdown | shutdownTimeoutMillis 30 s, drain 2 s, feed phase 5 s |
Stop returns incomplete | StopResult |
[Implemented] BoundedShutdownTest |
| Data-path queues | per queue | Spin ~10 ms, then drop and count | Counters, health, QueueDrop record |
Behaviour [Implemented] (QueueDropObservabilityTest); policy is issue #79 |
Supervision and callback failure¶
Decision 1, owner confirmed: isolate the processor, keep the group, no automatic restart. The same rule applies to the control graph: after a rule failure the group's control plane is DEAD (health DOWN) and its processors are fenced, never restarted.
- A generated processor whose callback throws is quarantined: removed from every input reader, timers cancelled, instance and name retained, health and ERROR raised. The group keeps running the others. [Implemented] (row R3).
- No automatic retry or restart of a failed generated processor. Clearing Fluxtion's processing flag restores an execution boundary; it does not roll back node state or external actions. [Implemented] (no restart path exists).
- Any future opt-in recovery needs an application contract for idempotency, state restoration and reconciliation of external effects. [Unsupported] in this release.
Decision 3, owner confirmed: a dead group is left for the operator in this release.
- No supervisor restarts a dead group. [Implemented] (no restart path exists).
- Names and services it held stay reserved;
StopResultlists the group andcontrolGroupStatusreports its state and cleanup failures. [Implemented] for groups. Held names are typed inStopResult.heldNames(HeldName(group, name, generation, reason)), shipped in the same minor release as theControlReplyshape change so clients migrate once [Implemented: M4]. Services stay inservicesLeftRunning(strings).
Audit failure¶
Decision 2, owner confirmed (with the reviewer's correction): fail closed for the lifecycle control audit, fail open
for Chronicle data capture. A DEGRADED group always accepts stop and close (refusing them would turn an audit
fault into an outage and remove the operator's main remedy; refusal of other requests could become an opt-in policy
later). Its replies are marked UNVERIFIED until the evidence is exported or acknowledged.
| Audit | Policy | Today | Status |
|---|---|---|---|
Control audit (ControlAuditSink) |
Fail closed. A sink write failure marks the group DEGRADED (evidenceDegraded(), controlGroupStatus(..).controlAuditDegraded(), health check group:<name>/control-audit) and writes one EvidenceDegraded fallback record per degraded span. Every later reply carries evidence = UNVERIFIED (EvidenceStatus); every request is still applied, stops and closes included. The marking reaches every reply about the group, including caller cancels and sweeps (a hook the group installs on each request at send) and the server's own refusals about the group (stopProcessor, addProcessor) [Implemented: M4] (Review77bProbesTest#cancelledRequestDuringDegradedSpanIsMarkedUnverified, #serverAuthoredStopRefusalDuringDegradedSpanIsMarkedUnverified). NOT_DEGRADED means only that the audit was not degraded when the reply was made: a record of the request written after the reply can still be lost (the export is then incomplete and never passes, Review77bProbesTest#recordOfTheReplyingRequestLostAfterTheReplyIsDelivered). Exporting (exportControlAudit, admin control.audit.export) acknowledges the failures it exported; acknowledgeControlAuditGap / admin control.audit.acknowledge acknowledges without exporting. Either writes EvidenceAcknowledged; neither makes the evidence whole (isComplete() stays false, I21 still reports the span unverifiable) |
Evidence side [Implemented] (ControlAuditExportTest#sinkFailureSurvivesExportAndFalseCompleteClaimIsRejected). DEGRADED, marker, health, acknowledgement, I21 [Implemented: M3] (AuditFailurePolicyTest#theSinkDegradesOnceAndOnlyExportOrAcknowledgementEndsTheMarking, #aDegradedGroupAcceptsStopAndCloseAndMarksEveryReplyUnverified, #serverHealthAndStatusReportTheDegradedControlAudit, control #aHealthyGroupsTraceStillVerifies). Ring overflow (dropped oldest records) keeps its existing meaning (export incomplete) and does not degrade the group, because the newest records, those of current replies, are kept |
|
| Chronicle data capture | Fail open. A record write failure is counted and logged and never stops the processor or the capture; the group registers its observer (AuditCaptureResources.observeWriteFailures, a new default SPI method) before installing, because the listener swap's own record is the recording's first write; the first failure of each recording is reported once, latched separately from the failure count, and reported on attach if it came first (Review77bProbesTest#firstWriteFailureDuringInstallIsReported, real Chronicle queue; #realClosedChronicleQueueReportsCaptureWriteFailedAndKeepsTheProcessor). If registering throws, the install still proceeds; the capture is recorded CaptureUnobservable and raises audit-capture health (#throwingObserverRegistrationDoesNotFailASuccessfulInstall). observesWriteFailures() (default false, and it stays a default method) tells whether a resource reports at all; one that does not is recorded CaptureUnobservable without raising health. The observer, which writes a CaptureWriteFailed control-audit record (processorStopped: false, kind FAILED_EFFECT) and raises the health check group:<name>/audit-capture (also raised by failed closes and detaches). acknowledgeCaptureFailures / admin control.capture.acknowledge clears that alert until a new failure; the failures stay listed and owned (review R10). It does not degrade the control audit |
A failed capture effect fails the capture request, not the processor [Implemented] (CaptureAsEffectsTest#anOpenThatFailsIsAnsweredFailedAndHoldsNothing, #anInstallThatFailsIsCompensated); quarantine of a generated graph on a routed install/restore failure [Implemented] (GeneratedProcessorFailureIsolationTest#routedChronicleInstallFailureQuarantinesGeneratedGraph). Write-failure record, counter and health [Implemented: M3] (AuditFailurePolicyTest#aCaptureWriteFailureNeverStopsTheProcessorButIsRecordedAndRaisesHealth, through a fake resource). The Chronicle implementation is exercised against real closed queues (Review77bProbesTest#realClosedChronicleQueueReportsCaptureWriteFailedAndKeepsTheProcessor, #firstWriteFailureDuringInstallIsReported, #aWriteFailureBeforeTheObserverAttachesIsReportedOnAttach). Acknowledgement covers failure occurrences, not resources: a later failure of the same resource, in any phase, is a new, unacknowledged occurrence (CaptureAcknowledgementTest) |
Policy execution never depends on the failed sink: fallback records and replies are written by the group, not by the
audit listener [Implemented] (ControlAuditSink fallback stream).
Fault-injection test API¶
The seams exist; they are test-internal today.
| Seam | Where | Status |
|---|---|---|
CaptureIo (open, close, timer) |
public interface in com.telamin.mongoose.control; set per group with setCaptureIo |
Public [Implemented]; supported-API commitment [Proposed: M5] |
AuditCaptureResources fakes (CaptureRig.FakeResources, ManualIo) |
src/test only |
[Proposed: M5], owner confirmed: a separate mongoose-testing artifact (test scope), versioned with the engine; CaptureIo stays in main as an SPI |
ControlDriver.registerUnpublished (sender paused before publishing) |
package-private test seam | Stays internal; not published (owner confirmed) |
| Processor fault doubles (throwing start/stop/teardown/handler) | per test class (LedgerProc, chaos Instance.Kind) |
[Proposed: M5] |
Every matrix row above has at least one test that injects its fault. M5 makes those injection points a supported API so downstream code can run the same scenarios.
Fluxtion-level concerns¶
The three Fluxtion concerns from #77 are resolved by Fluxtion runtime 1.1.3 / compiler 1.0.80, adopted by PR #76
on feature/control-plane (72b6fd4, merged into this branch):
| Concern | Resolution |
|---|---|
Generated processEvent, batchEnd and the service-call bracket had no finally, so a throw wedged the processor |
1.1.3 closes the failed cycle: the in-cycle flag is cleared, queued work is discarded, and auditors get processingComplete. Requires processors regenerated with 1.0.80. Node state is not rolled back |
| Re-entrant queue behaviour on exceptional exit was undefined | 1.1.3 discards re-entrant events, callbacks and work queued during the close. Nothing from the failed cycle runs later |
| One audit record per node per cycle | The failed cycle's record is closed and the next cycle gets its own. The control rules still write one Decision per node per cycle by design |
Mongoose keeps its host-side policy regardless of the runtime fix: quarantine, no retry, because clearing the flag is
not a transaction. The same applies to the control processor: under 1.1.3 a throwing rule no longer wedges it, but its
rules' state is not rolled back, so the driver still stops dispatching to it (controlWedged), marks the group DEAD
and answers FAILED_RULE (FailureKindFlowTest#aRuleThatThrowsIsAnsweredByTheDriverAsFailedRule) [Implemented: M2].
Milestones¶
| Milestone | Scope |
|---|---|
| M1 | This document |
| M2 | FailureKind on replies, effect results, StopResult, replay stops and audit records; oracle and harness keyed on kind; leftover marker |
| M3 | Audit fail-closed: group DEGRADED on control-audit failure, UNVERIFIED marker on later replies, acknowledgement; capture fail-open record and health (done) |
| M4 | Done: StopResult.heldNames (typed held names of dead groups and quarantined processors); JSON group status; fatal errors contained by default (A1); dead control plane fenced and DOWN (R5). Not done: a reply author on every record; a typed replay-routing answer (still the mismatch: / wait: text protocol) |
| M5 | Fault-injection test API in a separate mongoose-testing artifact (not started: needs a multi-module build) |
| M6 | Oracle vacuity reporting (absence is not evidence); retry classification recorded on ControlEffect and checked |
Open questions for the owner¶
Answered after the M2 review (owner confirmed): a DEGRADED group always accepts stop and close; held names go into a
typed StopResult replacement in the same release as the ControlReply break; M5 ships as a separate test-scope
mongoose-testing artifact with CaptureIo kept in main as an SPI; record-shape breaks go in a minor version,
documented.
Confirmed after round 2: isolate the processor, no automatic restart, also for the control graph (health DOWN); refusing non-stop requests while DEGRADED may become a future opt-in, default off, never refusing stop, close or acknowledge; exiting on a fatal error is never the default (A1, implemented).
Still open: the name and scope of the future DEGRADED refusal opt-in.
Combined 1.1.0 failure and overload contract¶
See the 1.1.0 release notes and
client migration checklist for the complete breaking-change and operator
acceptance checklist. PublishRefusedException carries FailureKind and EvidenceStatus; overload transition
records carry OVERLOAD_LOSS (or NONE on recovery/clear) and the evidence status at observation. Group status
JSON includes overload states, including retained REMOVED latches; the group's overload health check reports loss.
A blocked publish retains its intended bindings while waiting. Group death or quarantine cancels that admission
before commit, even if its target queue has since been removed; ordinary unsubscribe re-evaluates remaining targets.
The refusal is all-or-nothing and consumes the pooled reference as documented in the overload guide. Cancellation
is TARGET_UNAVAILABLE / REFUSED_STATE. The six cases in FailureOverloadSeamTest check metadata, group health/JSON,
removed latches, dead control, fatal CONTAIN, and quarantine of a generated processor. Direct calls from external
threads are outside this fence. NOT_DEGRADED never proves an export complete.