Control-plane contracts¶
What this page covers. This page lists every public contract of the Mongoose control plane: adding and stopping processors, registering and removing services, shutting the server down, audit capture, record and replay, and the control audit. For each contract it gives the exact API, what is guaranteed, what is not guaranteed, how it fails, and an example.
Status: unreleased. These contracts arrive with PR #76 (branch feature/control-plane). They are not in Mongoose
1.0.33 or earlier. See the unreleased release notes for what changed for
callers.
How to read it. Each section stands alone. Terms such as group, agent thread, control processor, generation and incarnation have one definition each, in the glossary. When this page and the code disagree, the code is right. Please report the difference.
The design behind these contracts is docs/design/control-plane-as-events.md on the branch
design/control-plane-as-events
(PR #75). Its "Implementation notes" section lists where the build differs from the design. This page describes the
build.
Contents¶
| Contract | Section |
|---|---|
| The one reply every control request gets | Replies and outcomes |
| Which threads may wait, and where replies complete | Threading rules |
addEventProcessor, addProcessor, duplicate names, addFailurePolicy |
Adding a processor |
ProcessorHandle, stopProcessor (three forms), the admin stop command |
Stopping a processor |
registerService, removeService, versions, the service lock |
Services |
shutdown(), StopResult, resuming, awaitStopped |
Shutdown |
MongooseAuditCaptureService, open timeout and limit, AuditCaptureResources |
Audit capture |
ReplayConfig, stream keys, what is recorded, when a replay stops |
Record and replay |
| What the control audit records, exporting and reading it | Control audit |
controlWaitMillis, shutdownTimeoutMillis, addFailurePolicy, capture settings |
Configuration keys |
| What none of the above promises | Not guaranteed |
Replies and outcomes¶
Every lifecycle change to a processor group is a control request. The group's
control processor decides each request in the order it was accepted, and answers it
with one ControlReply.
package com.telamin.mongoose.control;
public record ControlReply(long requestId, ReplyOutcome outcome, String reason, long generation, String name,
FailureKind kind, EvidenceStatus evidence) {
public boolean applied(); // outcome == APPLIED
}
package com.telamin.mongoose.control.event;
public enum ReplyOutcome { APPLIED, REFUSED, FAILED, NOT_RUNNING }
public enum FailureKind { NONE, REFUSED_ADMISSION, REFUSED_STATE, REFUSED_DEADLINE, REFUSED_INVALID, FAILED_RULE, FAILED_EFFECT,
FAILED_DRIVER_FALLBACK, FAILED_DEAD, FAILED_SHUTDOWN, QUARANTINED, DETACHED, UNVERIFIED, LEFTOVER_RECOVERED,
REPLAY_IDENTITY_MISMATCH, REPLAY_STOPPED }
public enum EvidenceStatus { NOT_DEGRADED, UNVERIFIED }
kind is never null: NONE exactly when the outcome is APPLIED. Classify a reply by outcome and kind; the
reason text is human detail and may change. evidence is UNVERIFIED when the reply was made while the group's
control audit had an unacknowledged write failure (the group is then DEGRADED but still applies every request,
stops and closes included), otherwise NOT_DEGRADED, which does not prove that every record of the request was
written. A full mailbox is always refused with reason mailbox full; exceptions
thrown for it say request refused: mailbox full (capacity N). The meaning of each kind is in the
failure-handling design. (Issue #77, after 1.0.33-rc1.)
| Outcome | Meaning | Typical reason values (from the code) |
|---|---|---|
APPLIED |
The requested transition has finished, not just been accepted. For a stop, release was attempted and the name freed; reported lifecycle callback failures do not imply callback success. A processor already gone is also APPLIED. |
started, stopped, alreadyStopped, releasing, cancelled (a stop overtook its add), recording, not recording, delivered, ignored: stale version N |
REFUSED |
The requested transition was not applied. Admission refusals run no effects; a cancelled capture start may already have an open in flight and still require cleanup. | name in use, cancelled (the add was cancelled by an earlier stop), no such processor, not started yet, mailbox full, not admitted within its deadline: mailbox full, handle belongs to another server, no such group |
FAILED |
The request was accepted and acted on, but its effect did not complete. | the start failure's text; open timed out; open failed: ...; delivered with failures: ...; group died: ...; control processor threw: ... |
NOT_RUNNING |
The group, or the server, is closing, closed or dead. The request was not applied. | group closed, server is stopping |
requestId is -1 for a request refused before it reached a group. generation is -1 when the reply is not about
one registration. name is the processor name, or null.
Guaranteed
- Every request that reaches a group gets exactly one final reply. This includes a group that closes or dies while
the request is queued: the request is answered
NOT_RUNNINGorFAILED, never left pending. - Mailbox requests to one group are decided in queue publication order. Concurrent callers have no ordering before publication; asynchronous effect completions use a separate queue. A later request can finish before an earlier request waiting for I/O.
- For an add,
APPLIEDmeans the processor started. For a stop it means its release attempt finished and its name is free. The ordinary stop/teardown failure policy reports callback failures and continues release; this reply does not prove every callback succeeded. Physical capture closure can still be pending.
A shared daemon checker detects a captured agent thread that terminated without closing, even when no later sender or shutdown call arrives. It joins the thread before completing claimed requests; unstarted groups are not judged dead. Legacy low-level reply callbacks must not block.
Not guaranteed
- A reply says what happened to this request. It does not say what other requests did meanwhile.
- A void method that times out does not cancel its request (see timeouts detach). The reply still arrives, but nobody is waiting for it.
Threading rules¶
Agent threads never wait¶
An agent thread is a thread that runs one of the server's processor groups or service agent groups. Mongoose marks it when the group starts and clears the mark after the group closes.
Guaranteed
- Processor mailbox admission and reply waiting never sleep or back off on an agent thread. A method that would await a reply queues its request and returns instead. This is not a promise that arbitrary service preparation or lifecycle callbacks cannot block: those may run synchronously, and registry publication still takes a short lock.
- A full mailbox on an agent thread is refused at once. Stage APIs return
REFUSED; void add follows its failure policy. Lower-level send methods can throwIllegalStateException. There is no capacity backoff. ControlReplies.awaitthrowsIllegalStateExceptionif called on an agent thread.
Waiting is also skipped, for the same reason, on the thread running MongooseServer.start(), on the server's stop
thread and its helpers, and when the target group has not started (it cannot answer yet).
Timeouts detach¶
A void processor control call shares one controlWaitMillis deadline (default 10 000 ms)
between admission and waiting for its reply. Service-change reply waits use the same configuration setting. If an
admitted request has no reply in time, the caller logs a warning and returns. Timing out after admission does not
cancel the request. A full-mailbox admission that expires is instead REFUSED and will never apply. Outcomes are
recorded in the control audit.
Where reply stages complete¶
addProcessor(...).reply() and stopProcessor(handle) return a CompletionStage<ControlReply>.
Guaranteed
- A stage for a request that reached a group is completed on the server's reply executor: an elastic pool of daemon
threads named
mongoose-control-replies-N. It is never completed on an agent thread. So a dependent attached before completion never runs on, blocks or re-enters an agent thread. - After the server has stopped, its reply executor is shut down. A completion that arrives later runs on a fresh
daemon thread named
mongoose-control-replies-late.
Not guaranteed
- A synchronous dependent attached after completion runs on the attaching thread, as
CompletionStagespecifies. A request refused before it reached a group (for exampleaddProcessorwith a duplicate name, or a handle from another server) returns a stage that is already complete, sothenAccept(...)runs at once, on the caller's thread. If that thread is an agent thread, so is the dependent. - Use the
...Asyncforms (thenAcceptAsync,whenCompleteAsync, and so on) for blocking work, or for any dependent that must not run on the calling thread.
What is safe to call, and from where¶
| Called from | Void methods (addEventProcessor, stopProcessor(group, name), registerService, removeService) |
Stage methods (addProcessor, stopProcessor(handle)) |
shutdown() |
|---|---|---|---|
| An ordinary thread | Admit, then wait up to controlWaitMillis for the reply |
Return at once with a stage | Runs the stop and returns its StopResult |
A processor's event handler, start() or stop() (agent thread) |
Admit and queue; return without waiting. A request to the processor's own group is applied at the group's next drain | Return at once | Hands the stop to a mongoose-server-stop thread and returns handedOff = true |
A @ServiceRegistered / @ServiceDeregistered callback in a processor (agent thread) |
As the row above | As the row above | As the row above |
A service agent's doWork() (agent thread) |
As the row above | As the row above | As the row above |
A service's start() during MongooseServer.start() |
Admit and queue; return without waiting | Return at once | Hands off, as on an agent thread |
Two rules follow:
- Never block an agent thread on a reply. Do not call
join(),get()orControlReplies.awaiton a stage from a handler,start(),stop()or a service callback. The group that would answer may be the one you are blocking. - A processor may stop itself.
stopProcessor(group, ownName)from its own handler is queued and applied at the next drain. Called from its ownstop(), it joins the release already in progress (APPLIED, reasonreleasingoralreadyStopped). It never reaches a later processor with the same name, because the stop is bound to the registration at call time.
Adding a processor¶
addEventProcessor (void)¶
public void addEventProcessor(String processorName, String groupName, IdleStrategy idleStrategy,
Supplier<DataFlow> feedConsumer) throws IllegalArgumentException
Adds a processor to a group, creating the group (and starting its thread, if the server has started) on demand.
idleStrategy may be null; the group's configured or default strategy is used.
Guaranteed
- An admitted add reserves the name before the call returns. A second add of the same name in the same group
throws
IllegalArgumentException, whether or not the group has started. Refusal releases any temporary reservation. - Off an agent thread, on a running group, the call waits up to
controlWaitMillisfor the add's reply. - With the default
addFailurePolicy: REPORT, an add that is refused or whosestart()fails is logged and reported, and the call returns normally. WithTHROW, see addFailurePolicy. - The supplier runs on the group's agent thread, never on the caller's thread.
Failure modes
| Situation | Result |
|---|---|
| Name already admitted in the group | IllegalArgumentException, at once |
| Server is stopping or stopped | IllegalStateException ("server is stopping") |
| Mailbox full, on an agent thread | IllegalStateException, at once; the name is released |
| Mailbox full, off an agent thread | Backs off until controlWaitMillis from the call (or the group's 10 s full-mailbox timeout, if sooner), then IllegalStateException; the name is released. One deadline covers admission and the reply |
| Mailbox full when a server stop begins | A sender still backing off leaves at once: the request is answered NOT_RUNNING ("server is stopping") and its name released, so it never holds up the stop |
start() throws, REPORT |
Reported through ErrorReporting; processor dropped; call returns |
start() throws, THROW |
ProcessorStartException |
No reply within controlWaitMillis |
Warning logged; call returns; the add still applies |
| On an agent thread, or the group not started | Call returns once queued; the outcome is only logged |
addProcessor¶
public AddedProcessor addProcessor(String processorName, String groupName, IdleStrategy idleStrategy,
Supplier<DataFlow> feedConsumer)
public record AddedProcessor(ProcessorHandle handle, CompletionStage<ControlReply> reply) { }
Adds a processor and returns its outcome as a stage, without waiting for it.
Guaranteed
- Never throws for a duplicate name or a stopping server. Those come back as a completed
reply:REFUSED(name in use) orNOT_RUNNING(server is stopping). handlenames exactly the registration this add admitted, so a stop through it can never reach a later processor with the same name.handleisnullwhen the add was refused before admission.replycompletesAPPLIED(started) once the processor has started, orFAILEDonce a failed processor has been released and its name freed.
Not guaranteed
- Admission does not back off: a full mailbox is refused immediately (
REFUSED, null handle). A server admission lock unavailable at the attempt is answeredNOT_RUNNING. Creating a new group still performs synchronous setup; this is not a hard execution-time guarantee.
Example
AddedProcessor added = server.addProcessor("pricer", "pricing", null, PricerProcessor::new);
added.reply().thenAcceptAsync(reply -> {
if (!reply.applied()) {
log.warning("pricer not added: " + reply.outcome() + " " + reply.reason());
}
});
ProcessorHandle pricer = added.handle(); // null if the add was refused before admission
Duplicate names¶
A processor name is unique within its group, from admission until the stop that frees it has been applied.
- The name is reserved when an add is admitted, not when the processor starts.
- It is freed only by the control processor, once a stop, a cancelled add or a failed start has been fully applied. A stop that was refused (for example for a full mailbox) never frees the name.
- So an immediate re-add after a stop that has not yet been applied is refused (
IllegalArgumentExceptionorREFUSED: name in use). Wait for the stop's reply first. - A configuration that lists the same name twice in one group fails boot.
addFailurePolicy¶
MongooseServerConfig.addFailurePolicy controls only the void addEventProcessor, and only when it waited for
a reply that was not APPLIED:
| Policy | FAILED reply (start threw) |
REFUSED or NOT_RUNNING reply |
|---|---|---|
REPORT (default) |
Logged and reported; returns | Logged; returns |
THROW |
ProcessorStartException(group, name, cause) |
IllegalStateException naming the outcome and reason |
addProcessor's reply carries the same outcome under either policy. A timeout returns normally under either policy.
Stopping a processor¶
ProcessorHandle and stopProcessor(handle)¶
public final class ProcessorHandle {
public String group();
public String name();
public long generation();
}
public CompletionStage<ControlReply> stopProcessor(ProcessorHandle handle)
public ProcessorHandle processorHandle(String groupName, String processorName) // what is admitted now, or null
Guaranteed
- A stop through a handle stops exactly that registration (generation). It never stops a replacement added later under the same name.
- Handles are server-scoped. A handle from another
MongooseServeris refused (REFUSED,handle belongs to another server) and stops nothing. - A handle whose processor has already gone is answered
APPLIED(alreadyStopped). - A handle naming a group that does not exist is answered
REFUSED(no such group). - The returned stage does not wait for the outcome. It completes as described in where reply stages complete.
Failure modes
- The handle's group mailbox is full: admission is refused immediately with a
REFUSEDreply. The processor keeps running and its name stays reserved.
processorHandle(group, name) returns a handle for whatever is admitted under the name at the moment you ask. Prefer
the handle from addProcessor, which cannot name a replacement.
Example
server.stopProcessor(pricer).thenAcceptAsync(reply ->
log.info("pricer gen " + pricer.generation() + ": " + reply.outcome() + " (" + reply.reason() + ")"));
stopProcessor(group, name)¶
public void stopProcessor(String groupName, String processorName)
Stops the processor admitted under the name at call time. The stop is bound to that registration then, so if the processor is replaced before the stop is applied, the replacement keeps running.
Guaranteed
- No group, or nothing admitted under the name: returns at once and does nothing.
- Off an agent thread, on a running group: waits up to
controlWaitMillisfor the reply; a timeout logs and returns, and the stop still applies. - On an agent thread, or before the group has started: queues the stop and returns.
Failure modes
- Mailbox full:
IllegalStateException, at once on an agent thread, or oncecontrolWaitMillisfrom the call (or the 10 s full-mailbox timeout, if sooner) has passed elsewhere. Nothing is stopped and the name stays reserved. - The method does not report the outcome. Use the
waitMillisform, or a handle, if you need it.
stopProcessor(group, name, waitMillis)¶
public ControlReply stopProcessor(String groupName, String processorName, long waitMillis)
As stopProcessor(group, name), but returns the reply. One deadline, waitMillis from the call, covers both
admission into the mailbox and waiting for the reply.
| Result | When |
|---|---|
A ControlReply |
The stop was answered in time, or refused at admission |
REFUSED, reason not admitted within its deadline: mailbox full |
The mailbox stayed full past the deadline |
REFUSED, kind REFUSED_STATE, reason no such group / no such processor |
Nothing was admitted under that group or name (issue #77; it returned null in 1.0.33-rc1) |
null |
The caller may not wait (agent thread, group not started), or no reply by the deadline. The stop still applies |
On an agent thread with a full mailbox this reply-returning form returns REFUSED; the void form throws.
The admin stop command¶
server.processors.stop <group>/<name> calls stopProcessor(group, name, 1000). It prints stop <OUTCOME> (<KIND>):
<reason>, or stop pending (still applies): <group>/<name> when no reply came within one second. An unknown group or
name prints stop REFUSED (REFUSED_STATE): no such ..., never pending. A missing or malformed argument (not exactly
one <group>/<name> with both parts non-blank) is refused on the error stream with
stop REFUSED (REFUSED_INVALID): usage: ... and stops nothing. On a MongooseServerController that is not a
MongooseServer, it calls the void form.
Services¶
registerService¶
public void registerService(Service<?> service)
public void registerService(Service<?>... services)
Ordering, per service:
- Reserve the name, under the service lock. A name in use throws
ServiceRegistrationException. - Inject: other services into the new one, and the new one into existing services. This runs outside the lock.
- Publish a versioned registration broadcast to every processor group, under the lock. Publication never waits: a
group whose mailbox is full misses the broadcast, and the miss is reported once through
ErrorReporting. - Off an agent thread, wait up to
controlWaitMillisfor every group's reply, under one deadline, outside every lock.
Guaranteed
- Processors already running receive
@ServiceRegisteredon their own agent thread. Each processor receives each service once. - A processor added later is given the registry's services when it is built.
- If the service is removed between steps 1 and 3, its registration is not published, so a removed service is never resurrected.
Failure modes
- Server stopping:
IllegalStateException; nothing is registered. - The same physical instance is currently being stopped by an earlier removal:
ServiceRegistrationException. Registration and claiming that stop are serialized; the stop callback itself runs outside the publication lock. - A
@ServiceRegisteredhandler that throws is reported; the processor keeps running; the other processors still receive the service.
removeService¶
public void removeService(String serviceName)
Ordering:
- Under the service lock: remove the service from the registry and publish a versioned deregistration broadcast to every group.
- Outside the lock, off an agent thread: wait up to
controlWaitMillisfor every group's reply. - Unbind before stop. Only when every group has confirmed the withdrawal (
APPLIED) isservice.stop()called. If the replies arrive after the wait, the stop runs then, on a reply-executor thread.
Retained ownership. If any group did not confirm the withdrawal (it missed the broadcast, a
@ServiceDeregistered handler threw, or a group failed), the service is not stopped. The server keeps owning it,
and shutdown() stops it after every processor group has closed. A warning says
service '<name>' remains owned until shutdown.
Guaranteed
- A service is never stopped while a processor that was given it may still hold it.
- A later service registered under the same name is unaffected by the removed one's deferred stop: it is not marked down, and it never receives the old one's stop.
- Unknown name: no-op.
Failure modes
- Server stopping: the call is ignored (logged at
INFO), not refused with an exception. The shutdown stops the service once, itself.
Versions and the service lock¶
- Every registration or removal of a service name gets the next version for that name.
- A group applies a broadcast only if its version is newer than the last it applied for that service. An older one is
answered
APPLIEDwith reasonignored: stale version Nand changes nothing. So concurrent register and remove of one service cannot make an older delivered broadcast overwrite a newer delivered one. A group that missed a broadcast because its mailbox was full is reported; version checks alone do not repair that missed delivery. - The service lock covers only the name reservation, the registry change and the non-blocking broadcast
publication. Injection,
start(),stop(), waiting, and reporting toErrorReportingall run outside it. A slow service callback therefore never stalls other service changes.
Refusal while stopping¶
Once shutdown() has begun:
| Call | Result |
|---|---|
registerService, registerWorkerService, registerAgentService |
IllegalStateException |
startService, stopService |
IllegalStateException |
removeService |
Ignored (logged); the stop stops the service |
addEventProcessor |
IllegalStateException |
addProcessor |
NOT_RUNNING reply |
Shutdown¶
shutdown() and StopResult¶
public StopResult shutdown()
public void stop() // shutdown(), result discarded
public StopResult lastStopResult() // the last attempt's result, or null
public boolean awaitStopped(long timeout, TimeUnit unit) throws InterruptedException
public record StopResult(boolean complete, boolean handedOff, String blockedStartup,
List<String> groupsRunning, List<String> servicesLeftRunning) { }
| Field | Meaning |
|---|---|
complete |
Everything stopped. The server is STOPPED. |
handedOff |
Called on one of the server's own threads. The stop runs on a mongoose-server-stop thread and its outcome is not known yet. Use awaitStopped from another thread. |
blockedStartup |
The startup, admission or coordination step that blocked this attempt, or null. |
groupsRunning |
Groups or cleanup tasks not confirmed finished. Current group entries are raw group names; the internal helper keys use processor: / service:. Bracketed notes include <event sources stopping> and <audit capture stopping>. Do not parse raw names as a stable typed protocol. |
servicesLeftRunning |
Services not confirmed stopped: a group may still use them, their stop is still running, or their stop failed. |
Phases and the deadline¶
One deadline, shutdownTimeoutMillis (default 30 000 ms), covers the whole stop. Each phase gets what is left:
- Fence startup: ask an in-progress startup step to return, and wait for it.
- Stop feeds: close agent groups that host only event sources; take event sources off shared groups; stop every event source. This phase is also capped at 5 s.
- Drain events already queued to processors, at most
shutdownDrainTimeoutMillis(default 2000 ms). On expiry, log the pending count and proceed to release the queues; remaining entries may be dropped, not delivered. - Close every processor group, each from its own tracked helper thread.
- Only if every processor group closed: close the service agent groups, then stop the registered services (including removed services the server still owns), each on a helper thread.
- Run the capture service's final
stopAll()cleanup. A group with unresolved physical capture resource permits remains incomplete before this phase; a failed physical close is not silently counted as closed.
Guaranteed
shutdown()returns by the deadline (plus scheduling slack) with a truthfulStopResult. It never reportscompletewhile tracked lifecycle/capture work remains unconfirmed. Independent diagnostic export workers are not awaited; finish exports before terminating the process.- If any processor group did not close, remaining service dependencies are retained. Event sources were already stopped in the feed phase; this guarantee does not keep their inbound streams available.
- Groups closing do not run queued adds: their suppliers never run and they are answered
NOT_RUNNING.
Resuming an incomplete stop¶
When a stop runs out of time, the server stays STOPPING and complete is false. Call shutdown() again to
resume. A resumed stop:
- starts from the first unfinished phase;
- re-joins helpers still running; retries unfinished group cleanup after a failed helper exits without repeating processor lifecycle callbacks already attempted;
- does not call a service
stop()that already failed again. Its side effects are unknown, so it stays inservicesLeftRunning.
Concurrent callers on ordinary threads are serialised: a second caller waits for the attempt in progress (within its own deadline), then resumes it if it ran out.
Capture uninstall restores the configured listener before processor stop() and tearDown(). Their audit records
therefore go to that restored listener, not the ended capture; physical closure follows on a helper. If uninstall
fails, that restoration is not established and the unresolved resource remains diagnostic evidence.
Failed physical closes are not retried: the backend's partial side effects are unknown. An unresolved close keeps the stop incomplete. Follow the shutdown runbook to preserve the capture, export control evidence, distinguish failed detach from failed close, and escalate process termination.
awaitStopped¶
- Returns
trueonly once a stop has completed. An incomplete stop leaves itfalseuntil a later attempt completes. - Use it after a stop that was handed off (called on an agent thread). Do not call it on an agent thread.
Stopping a server that never started¶
shutdown() on a server that was never started closes every group that exists. Their queued adds are answered
NOT_RUNNING, and their suppliers never run. Earlier releases returned at once in this state.
Example
StopResult result = server.shutdown();
if (result.handedOff()) {
server.awaitStopped(60, TimeUnit.SECONDS); // from a thread that is not an agent thread
} else if (!result.complete()) {
log.warning("stop incomplete: " + result);
result = server.shutdown(); // resumes where the first attempt stopped
}
Audit capture¶
Audit capture records a processor's Fluxtion audit log to a Chronicle queue. It is installed when
performanceMonitoring.auditCapture.enabled: true. Otherwise the server uses a no-op service. Both are registered
under MongooseAuditCaptureService.SERVICE_NAME.
start and stop¶
void start(String processorName);
void stop(String processorName);
boolean isRecording(String processorName);
With capture enabled, start and stop are requests to the processor's group. The group decides and acts on its own
agent thread.
start, guaranteed
- The Chronicle queue is opened on an I/O helper thread, never on the processor's thread. The capture is installed on the processor's thread, between event cycles.
- Off an agent thread, on a running group,
startreturns once the capture is recording.isRecording(name)is thentrue. - Calling
starton a processor that is already recording is a no-op (APPLIED,already recording). Calling it while an open is in progress joins that open and is answered with it. A start while an earlier session is closing can open a new session; the earlier resource keeps its independent cleanup ownership and capacity slot. - The request is bound to the processor admitted under the name when it is sent. A late open never installs on a replacement processor. It is closed instead.
stop, guaranteed
- On a thread allowed to wait, returns once the capture is uninstalled from the processor. The queue is handed to the closer afterwards for one close attempt; failures are recorded as unresolved resources.
- Stopping a processor that is not recording is a no-op.
- Stopping or removing the processor stops its capture as part of the release.
Waits and timeouts
| Step | Bound | On expiry |
|---|---|---|
| Group begins applying the request | 5 s | Request cancelled; IllegalStateException ("cancelled, and nothing changed") |
| The open, once begun | group open timeout, 30 s by default | Reply FAILED: open timed out; IllegalStateException. A later open completion is closed, never installed |
| Caller's wait for an open in progress | 35 s | IllegalStateException ("still being applied"); the request may still complete |
These are successive reply waits, not one end-to-end deadline. Before they start, mailbox admission may back off for its full-mailbox timeout (10 s by default) on a waiting caller. The 5 s claim wait can be followed by the 35 s open-progress wait if the group already claimed the request. Scheduling and filesystem delays also apply; do not interpret the table as a single 5 s or 35 s method-call bound.
Failure modes
| Situation | Result |
|---|---|
| No processor admitted under the name | IllegalArgumentException |
| Processor has left its group | IllegalArgumentException |
Group closed (NOT_RUNNING) |
start: IllegalStateException; stop: no-op |
| Processor not started yet, open limit reached, open failed or timed out | IllegalStateException with the reason |
| Called on an agent thread, or the group has not started | Returns at once without waiting. The request applies later, and only a failure is logged |
| Interrupted while waiting | IllegalStateException; the request still applies |
The admin commands audit.start <name> and audit.stop <name> call these methods and print the exception message
on failure. audit.start prints audit: RECORDING <name> once start returns. If the admin command runs on an agent
thread, start returns before the capture is installed, so that line means "requested", not "recording". Check with
audit.status.
Naming the processor to capture¶
The following selection rules apply to the built-in Chronicle capture service. start(name) and stop(name)
(and the admin audit.start / audit.stop) take a processor name. A name admitted in
one group addresses that processor. A name admitted in several groups is ambiguous: the call throws
IllegalArgumentException naming the groups, and you address one as group/name. A processor never inherits a capture
recording another processor of the same name in a different group.
Encode each component with AuditCaptureIdentity.selector(group, name) when it contains /, % or other special
characters. The same selection rules apply to isRecording and currentSink; currentSinks uses qualified keys.
The built-in Chronicle service pins each resource to its group, name and registration generation. Each recording
session has a fresh capture-* directory and capture id, with a .mongoose-capture.properties identity sidecar;
the display selector is not a filesystem path. Replacement generations and late closes cannot share a queue.
Historical discovery still reads the old standalone layout. See capture resource identity.
MongooseAuditIntrospectionService.capturesFor(group, name) returns immutable current/historical session handles;
latestCapture(group, name) returns the preferred latest handle or null. Selector overloads are available.
Use the returned id and path for importers; never construct a path from group/name. Old unqualified directories
are discoverable but are not silently assigned to a group. audit.list / audit.status print id, selector and path.
YAML auto-start submits capture work after processor admission. start() / bootServer() returning does not
promise asynchronous capture I/O has completed. Poll status with a deployment-specific deadline before relying
on recording. A failed fail-fast processor startup does not start its queued auto-capture.
Sessions accumulate until external retention tooling removes them. Core Mongoose does not enforce retainHours.
See the migration guidance.
Custom capture plugins must implement scoped attachment, lookup and retirement to provide these guarantees.
The interface's compatibility defaults forward attachScoped, startScoped and group-scoped lifecycle callbacks
to the legacy bare-name methods. They do not add group or generation isolation to a name-keyed plugin. Such a
plugin must reject duplicate names across groups or implement identity-aware storage before it can safely support
them. A custom CaptureRouter must also implement scoped dispatch for scoped auto-start requests.
Open and resource limits¶
Each group allows at most 8 physical opens in flight by default. An open that timed out still holds its permit
until its helper returns. While the limit is held, further audit starts fail (FAILED, reason
unresolved capture open limit reached). Every resource that a late helper returns is scheduled for one close attempt. A failed close is reported and
retained as unresolved ownership, not claimed to be closed.
Separately, each group reserves at most 1024 physical capture resource slots by default, including opens,
installed captures, queued closes and failed closes. A slot is returned after confirmed closure or an open that
produced no resource. Failed closes retain their slots and are not blindly retried. This bounds resource retention
when the closer stalls; it is not just an open-concurrency limit. captureResourceSlotsInUse() and
captureCleanupFailures() expose unresolved physical ownership and combined detach/close diagnostics.
captureCloseFailures() reports physical close failures only. Shutdown remains incomplete while ownership exists.
Set the limits with ComposingEventProcessorAgent.setCaptureOpenLimit(int) and setCaptureResourceLimit(int),
and the open timeout with setCaptureOpenTimeout(Duration). MongooseServer does not expose its groups, and neither setting is a server
configuration key. See configuration keys.
Deprecated: attach(..., Executor) and stopAll()¶
attach(DataFlow, String, LogRecordListener, Executor)is deprecated. The server now callsattachScoped, whose compatibility default delegates to the old method. With routing in place the group installs and uninstalls captures itself. The executor is used only by a capture service that no server routes.stopAll()is deprecated. Each group closes its captures as it closes. The server callsstopAll()only after group capture operations have completed successfully, for any residual service-owned resources. It does not race a blocked group closer or treat a failed physical close as complete.
AuditCaptureResources, for implementers¶
public interface AuditCaptureResources {
Object openCapture(String processorName) throws Exception; // on an I/O helper
default Object openCaptureScoped(AuditCaptureIdentity identity) throws Exception {
return openCapture(identity.name());
}
void installCapture(Object resource, String processorName, DataFlow flow); // on the group's agent thread
void uninstallCapture(Object resource, String processorName, DataFlow flow); // on the group's agent thread
void closeCapture(Object resource) throws Exception; // on the closer, once per resource
}
The group decides and an AuditCaptureResources only acts. The contract for an implementation:
Install/uninstall normally run on the owning agent thread. Exclusive cleanup after that agent has terminated and been joined may run on a shutdown helper; implementations must permit that ownership handoff. They must not require dispatch to a thread that can no longer execute work.
openCapture/openCaptureScopedmust close anything created before throwing. The group calls the scoped form; implementations that inherit the compatibility default still receive bare names. A custom backend must implement scoped ownership before claiming isolation between same-named processors in different groups.installCaptureputs the capture in front of the listener the server configured on the processor.uninstallCapturerestores that listener.flowisnullonce the processor has gone. The resource must stop receiving records before this returns, because the group may close it straight after.closeCaptureflushes and closes. The group calls it exactly once per resource. A failure is recorded and counted, and never blocks a processor's release.- A resource is opaque to the group. It is held by id and handed back.
The additive processorReleased(identity, flow) hook lets direct embeddings retire the exact scoped binding
after lifecycle release. It does not transfer physical-close ownership away from the group.
The server wires a group to a capture service only when the installed MongooseAuditCaptureService also implements
AuditCaptureResources. In this build that is the built-in Chronicle service. A group used directly can be given one
with ComposingEventProcessorAgent.setAuditCaptureResources.
Record and replay¶
Record and replay is @Experimental. The how-to Record and replay a processor
shows it end to end. This section states its contracts.
ReplayConfig¶
ReplayConfig.record(Set<String> processors, Map<String, EventCodec> journalledFeeds, EventJournal journal, ReplayStore store)
ReplayConfig.replay(Set<String> processors, Map<String, EventCodec> journalledFeeds, EventJournal journal, ReplayStore store)
.withClock(LongSupplier liveClock) // RECORD: the live clock recorded processors read
.withDeliveryTimeout(Duration timeout) // REPLAY: default 5 s
.withControlInterception(boolean on) // default true
.withProvenance(boolean on) // default true
processorsempty means every processor.withControlInterception(true): control requests a covered processor makes throughMongooseServerControllerare recorded with their outcome (RECORD), and intercepted (REPLAY). See what a replay does.withProvenance(true): each stream's provenance is recorded when it begins (RECORD) and checked (REPLAY).RECORDrefuses a journal or store that already holds a recording (IllegalStateExceptionat boot).
Stream keys¶
Each processor's entries form one stream, keyed by name and per-name build ordinal:
- the first processor of a name built in the group records under the bare name, for example
pricer; - later ones record under
name#2,name#3, and so on; - a
#in a processor name is doubled first, so the first processor namedp#2records underp##2, and never aliasesp's second build (p#2).
ReplayStreams.key(name, ordinal) computes a key. An add that was refused, cancelled or never built uses no number,
so keys do not depend on reproducing refusals. They are also independent of adds of other names.
Replay failures from keys: a later stream (#2 and up) that was never recorded stops that processor's replay
(no stream was recorded for ...); it is not replayed as empty. A legacy key that is ambiguous because of an
unescaped # stops it too (ambiguous legacy stream key ...).
What is recorded¶
| Entry | Recorded when |
|---|---|
Indexed |
An input from a journalled feed, received as the journal holds it: the feed, sequence number and route |
Inline |
Any other input, or a typed service call: a copy taken just before the processor handled it |
TimerFired |
A timer the processor scheduled fired |
AdminInvoked |
A processor-owned admin command ran: its name and arguments |
Failed |
A dispatch threw; an input could not be copied; or a control request was made off the processor's thread |
Stopped |
The processor was stopped by a request (not by its group closing), with the clock readings its stop() took |
ServiceChanged |
A service was delivered to or withdrawn from the running processor after the server started, with its registry version and clock readings |
ControlCall |
The processor made a control request through MongooseServerController (stopService, startService, stopProcessor, registerService, registerEventSource, removeService, addEventProcessor), with its arguments as text and its outcome (ok or failed: <exception text>) |
| Provenance | Beside the stream, not as an entry: the processor class, the handler class it wraps (empty for a generated processor) and its build ordinal |
Every timed entry carries every clock reading the processor took while handling it. Services registered while the server boots are configuration: they are not recorded.
What a replay does with each entry¶
| Entry | In a replay |
|---|---|
Indexed, Inline |
Delivered to the processor alone, through the route it was recorded on, with its clock pinned to the recorded readings |
TimerFired |
Fires the processor's timer with that schedule number |
AdminInvoked |
Rebuilt from the command the replayed processor registered; replies are collected in adminReplies() |
Failed |
Stops the processor's replay |
Stopped |
Stops the processor through its group's control processor, at this point, with the recorded clock readings |
ServiceChanged |
Delivers or withdraws the service at this point. Live deliveries of services the stream records are suppressed for this processor. A service the stream registers later is not given at build |
ControlCall |
The processor must make the same supported request in position; its recorded outcome is returned without executing the live control action. Registry mutations (registerService, registerEventSource, removeService) and supplier-based adds stop replay instead, even if matched. Calls made in start() are expected before the first input |
A replayed processor's live inputs are muted, and what it sends to MessageSink services is captured
(outputs(processor)), not delivered. Other services it was given are live.
When a replay stops¶
A replay stops one processor's replay, never the group or the server, and records why.
replayers().get(group).stopped(processor) returns the reason, or null. A stopped replay counts as done for
complete().
| Situation | Reason starts with | Why it stops |
|---|---|---|
| The replayed processor's class or handler class differs from the recorded provenance | the replayed processor is not the recorded one |
Replaying a stream into another program would be meaningless |
| The processor refused the replay clock | the replay clock could not be installed |
Its time could not be reproduced |
| The store or the provenance could not be read | the replay store could not be read / the stream's provenance could not be read |
No trustworthy stream |
| A later stream was never recorded; an ambiguous legacy key | no stream was recorded for / ambiguous legacy stream key |
See stream keys |
| The recorded run failed here | the recorded run failed here |
Determinism is not claimed after a failure |
An entry cannot be delivered within deliveryTimeout |
entry N could not be delivered within |
A route or admin command the replayed configuration never makes would otherwise stall the replay silently |
| An entry throws while being replayed; the journal lacks an item | entry N could not be replayed / the journal holds no |
The input cannot be reproduced |
A cycle read the clock a different number of times from the recorded one, including in a replayed stop() |
clock divergence |
The processor took a different path |
| A control request the recorded run did not make here, or made with different arguments | control request ... was not made by the recorded run here / control request differs |
Divergence |
| The recorded run made a control request the replay did not | the recorded run made control request |
Divergence |
The replayed processor calls addEventProcessor |
addEventProcessor supplier cannot be verified from the recording |
The supplier is executable code that the recording cannot identify. It is never run against the live server. The call also throws UnsupportedOperationException into the processor |
The replayed processor calls registerService, registerEventSource or removeService |
registry mutation |
Stops and throws UnsupportedOperationException; a separate replay registry is required to avoid changing resources shared with unreplayed processors |
| The recorded control call failed | recorded control exception cannot be reconstructed |
Only the exception's text was recorded; its type and payload cannot be rebuilt, and a processor may branch on them. The call also throws IllegalStateException into the processor |
| A control request made off the processor's agent thread | control request ... made off the processor's thread |
Outside the input order, so not reproducible |
| The recorded stop could not be replayed | the recorded stop could not be replayed |
The processor is no longer in the group |
Service changes require the recorded positive version to match the currently published live binding. A retained historical object may already have been stopped and cannot be resurrected from a cache:
| Situation | Reason starts with | What happens |
|---|---|---|
| The recorded service version is not registered in this run yet | (waits) | The entry waits for the broadcast of that version, then stops after deliveryTimeout with entry N could not be delivered within |
| This run's registry has moved past the recorded version or the binding is no longer live | recorded service S at version V is unavailable |
Stops. Neither the latest instance nor a historical stopped object is substituted |
| A visible object has no published identity for a positive recorded version | no registry binding identifies recorded service (waiting reason) |
Waits up to deliveryTimeout for version publication, then stops. Registry visibility alone never permits delivery |
A replayed @ServiceRegistered or @ServiceDeregistered callback throws |
recorded service callback for S was not delivered |
Stops. A failed callback is never counted as delivered |
| The processor already held the service when the entry is replayed | recorded service callback for S was not delivered |
Stops: it reached the processor before its recorded position |
| The recorded run withdrew a service the replayed processor never held | recorded withdrawal of service S has no delivered incarnation |
Stops: the withdrawal cannot be reproduced |
Only deliveries that reached the processor are recorded. A broadcast it already held (skipped live) records nothing, and
a delivery whose callback failed records a Failed entry, past which the stream is not reproducible.
Limitations: unversioned legacy entries still use configuration-based lookup; they cannot prove incarnation
equivalence. Positive recorded versions fail closed if publication identity is still missing after deliveryTimeout;
a registration may be visible briefly before its versioned broadcast reaches the group. Version numbers identify
bindings within the configured run, not a build, service state or cross-machine identity proof.
In a replay, a live withdrawal suppressed because the stream owns its delivery returns a failed acknowledgement.
That acknowledgement is not later upgraded when the stream withdraws it: removeService retains the instance
until shutdown has stopped the groups. Repeated churn can therefore retain services for the server's lifetime.
A replayed processor's
registry requests (registerService, registerEventSource, removeService) are never executed against the live
server: the replay stops, because they need an isolated replay registry.
complete() with a null stopped(...) means every entry was delivered. It does not prove the run was reproduced.
Compare outputs(processor) or the audit logs.
Control audit¶
What it records¶
Each group's control processor writes an analyser-readable record of every decision, through Fluxtion's audit log.
Records are kept in memory, per group, in a bounded ring (ControlAuditSink, 10 000 records by default).
Set the JVM property -Dmongoose.control.auditCapacity=400000 before groups are constructed to size each ring
for a longer investigation. Missing, malformed or nonpositive values use the default. Larger rings retain more
heap; overflow still marks the evidence incomplete and cannot authorize an invariant PASS. This property changes
control-history retention, not data-processor audit capture or the event queue capacity.
| Record | Written | Key fields |
|---|---|---|
Input |
For each request or completion the control processor receives | fact (Add, Stop, Register, Deregister, AuditStart, AuditStop, Close, Run, or a completion), generation, name; opId and completionId for completions |
Decision |
For each rule that fires, before any effect it requests is performed | rule (for example stopStarted, captureOpenedStale), from and to states, effects (each as opId Effect(generation)), notes such as pendingCleanup, stale, duplicate |
EffectOutcome |
For each effect's result, pending or final | opId (requestId.n), effect, outcome (pending, succeeded, failed), reason, cleanup when it arrives after the request was answered |
Reply |
For each final reply | opId, outcome, generation, name |
Fallback records (controlFallback:) |
Written by the group itself, when the control processor cannot write: Anomaly, Dead, NotRunning, Refused, Failed, EffectRequested, EffectOutcome, CaptureOrphan, CaptureCompensated, CleanupPending, CleanedUp |
coarser, not framed by Fluxtion |
Every control-processor record carries record, seq (group-local, gap-free while the sink works), requestId and
rootId. Records caused by another record also carry causeSeq; records caused by an effect carry causeOp.
Control-processor records for one request share its rootId: input, decisions, effects, pending and final
outcomes, compensation, late completions and the reply. Fallback cleanup uses its available resource and operation
identities; see reading it.
Data processors keep their own Fluxtion audit. The control audit does not record what happens inside them.
Getting the records¶
ControlAuditSink audit = group.control().audit(); // group: a ComposingEventProcessorAgent
List<String> records = audit.records();
boolean complete = audit.isComplete(); // false once anything was dropped or failed to write
long total = audit.total(), dropped = audit.dropped(), failures = audit.sinkFailures();
For a server-owned group, use server.exportControlAudit(groupName, newDirectory) from an ordinary background
thread, or admin control.audit.export <group> <new-directory> and control.audit.export.status <job-id>.
control.group.status <group> / server.controlGroupStatus(groupName) expose non-blocking cleanup diagnostics.
See export and investigation for permissions, file
verification and the deployed graph. The direct group access above is also available to embedded callers.
Exporting it, with a completeness manifest¶
Path yaml = audit.export(Path.of("target/control-audit/pricing.yaml"));
ControlAuditExport.Verification v = ControlAuditExport.verify(yaml); // reads only the two files
if (v.permitsInvariantPass()) { /* COMPLETE */ }
export(path)writes the YAML topath, and a sidecar topath.manifest.yaml, from one locked snapshot.- The sidecar holds
format: mongoose-control-audit,version: 1, thesha256of the exact exported bytes,total,retained,dropped,sinkFailuresandcomplete. verify(path)returnsCOMPLETE,INCOMPLETEorUNKNOWN. A missing, malformed, inconsistent or mismatched sidecar isUNKNOWN.COMPLETEis necessary, not sufficient, for an invariant pass: it establishes export completeness, not correct behaviour. The test validator requiresControlInvariants.Result.verified(), which rejects both violations and unverifiable evidence;ok()alone is insufficient. Failed physical closes remain unverifiable even in a complete export and must agree with the resource ledger.exportText()returns the raw YAML with no manifest.- Copy both files together. The hash detects accidental change or a mismatched pair. It is not a signature and does not authenticate a pair rewritten on purpose.
- An export started again on the same path deletes the old sidecar first, so an interrupted export never claims to be complete.
Reading it¶
The export is a sequence of YAML documents, each preceded by ---. A Fluxtion event-cycle document can contain
several structured control records; fallback records occupy their own documents. To follow one request, select the
records with its rootId. Control records have seq, fallback records have a separate fallbackSeq; preserve
export order when comparing them. Fallback cleanup can instead link by opId, resourceId and generation, and may
not carry the original request's rootId. Include these resource-linked records when checking physical cleanup.
To investigate in the Fluxtion audit-log analyser, the design ("Investigating through the analyser") pairs three artefacts:
- the exported YAML, opened as the audit log;
- the control processor's graph,
src/main/resources/com/telamin/mongoose/control/generated/GroupControlProcessor.graphml; - its authored source,
src/main/fluxtion/control/control-processor.xml, and the generatedGroupControlProcessor.java.
With those loaded, read selects one rootId or generation, aggregate counts effects requested against final
outcomes per opId, and topology and source name the rule node that decided.
Limits¶
- The ring is bounded. When it drops records,
isComplete()isfalseand the manifest says so. This build writes no separateGaprecord. - The audit covers the control processor's inputs, decisions, effects and replies. It does not cover the inside of data processors, or what a sender did before its request reached the mailbox.
- A node that writes no record is not proof that it did not run.
Configuration keys¶
| Key | Default | Applies to | How to set |
|---|---|---|---|
controlWaitMillis |
10000 |
How long void addEventProcessor, stopProcessor(group, name), registerService and removeService wait, off agent threads |
YAML top-level key, or MongooseServerConfig.setControlWaitMillis(long) |
shutdownTimeoutMillis |
30000 |
The end-to-end deadline of shutdown() / stop() |
YAML, setShutdownTimeoutMillis(long), or MongooseServerConfig.builder().shutdownTimeoutMillis(long) |
addFailurePolicy |
REPORT |
What void addEventProcessor does with a reply that is not APPLIED |
YAML (REPORT or THROW), or setAddFailurePolicy(AddFailurePolicy) |
shutdownDrainTimeoutMillis |
2000 |
The drain phase of shutdown(), within shutdownTimeoutMillis |
YAML, setter or builder (unchanged) |
| Capture open timeout | 30 s | How long one audit-capture open may take | Per group only: ComposingEventProcessorAgent.setCaptureOpenTimeout(Duration). Not a server key |
| Capture open limit | 8 | Physical capture opens in flight per group | Per group only: ComposingEventProcessorAgent.setCaptureOpenLimit(int). Not a server key |
| Capture resource limit | 1024 | Open, installed, closing or failed-close resource slots per group | Per group only: ComposingEventProcessorAgent.setCaptureResourceLimit(int). Not a server key |
| Full-mailbox timeout | 10 s | How long a producer backs off on a full group mailbox, off agent threads | Per group only: ComposingEventProcessorAgent.setControlQueueFullTimeout(Duration) |
mongoose.control.auditCapacity |
10000 records |
Retained control audit records per group, read at construction | JVM system property (-D), not a YAML key |
controlWaitMillis and addFailurePolicy have no builder method in this build. Set them on the config object or in
YAML.
controlWaitMillis: 5000 # void control methods wait at most 5 s
shutdownTimeoutMillis: 20000 # stop() returns, complete or not, within about 20 s
addFailurePolicy: THROW # void addEventProcessor throws when the add does not apply
Overload and loss¶
Since telaminai/mongoose#79, each feed has an explicit overload policy. The default is described first, then the policies a feed can configure. See Configuring overload and loss and the design.
Default (DROP_AFTER_SPIN, and any feed without an overload block). Ordinary publication retries a full target queue by spinning for approximately 10 ms, then drops that item's
copy for that queue. Other target queues are still attempted. The dropped queue's pooled reference is released;
this does not release another queue's reference. The publisher's aggregate droppedCount() increases.
The publisher holds its existing serialization lock across dispatch. This bounds the retry loop, not the whole
call: lock contention, several full queues, journal I/O, first-drop diagnostics and logging add time. The source
can be delayed, but receives no lossless credit/backpressure protocol or failed-publication result; the void call
returns normally after a drop. publishReplay has a separate immediate-offer path: a failed offer is dropped
without this retry, counted with sequence -1 and reason replay offer full; no retry.
When warning logging is enabled, an ordinary full-queue drop logs:
dropping publish to slow/contended queue: <queue> after ~<elapsed>ms seq:<sequence> queueSize:<size>
Per-queue monitoring uses queue.<queuePath>.dropped in MongooseCountersService, using the same queue path as
the depth gauge. The feed's queue-drops:<queuePath> health check becomes DEGRADED after loss and is removed
on unsubscribe. With counters disabled it reports UNKNOWN; publisher droppedCount(queueName) and first-drop
evidence still work. The first drop of each queue binding emits QueueDrop in the group's control fallback audit
and the publisher's bounded drop audit, with feed, queue, queue path, group, sequence, count and wall-clock time.
This is first-ever evidence for the lifetime of the queue binding, not a fresh record for every overload incident.
These records consume the same bounded group control-audit capacity; repeated subscription churn can exhaust
that capacity and make the export incomplete. It is a correlation point, not one audit record per lost item
or a shared business-event ordering guarantee.
On a feed without an overload block this health behaviour is that of PR #76: DEGRADED after loss, from the path's
cumulative drop counter, with no recovery and no overload.clear.
Configured policies (#79).
overload.policy: DROP_AFTER_SPINwith aspinBoundMillis(default 10) behaves as the default, with two additions. A queue's health returns toUPafterquietPeriodMillis(default 60 s, minimum 1 s) without further loss. Every state change writes oneOverloadStaterecord to the publisher's state audit.overload.policy: BLOCK_WITH_DEADLINEwithblockDeadlineMillismakes a publish all-or-nothing:- A publish from a non-agent thread waits, without holding the publish lock, until every blocking queue has room. It is then delivered to all of them. The deadline bounds only that wait for queue capacity, not the data mapper, the journal codec, or acquiring the publish lock. A publish into the same feed from code run during the commit is deferred until the commit completes.
- If there is still no room at the deadline, or when the server begins to stop, the publish throws
PublishRefusedException. No queue holds the item and no sequence number is used. - A refused pooled item is back in its pool, already reset: copy the payload before publishing if you may retry.
- On an agent thread, or nested in a dispatch, the publish falls back to drop-after-spin, counted in
downgradedCount(). - Any loss on a blocking queue latches it.
- The latch outlasts an unsubscribe:
LATCHED→REMOVED, thenREMOVED→LATCHEDif the queue is re-added. - The queue's health check reports the latch until the admin
overload.clear <feed>clears it. - Each change also writes an
OverloadStaterecord to the group control audit. - With
killSwitch: true, the hook set byMongooseServer.setOverloadKillSwitchruns once per latch, on the feed's own daemon thread. Once the server begins to stop, no hook is started, including one already queued. A hook that is already running is not interrupted. - Overrides per subscriber group:
overload.subscribers.<group>. - Counters:
queue.<path>.refused,.blocked,.blockedNanos,.blockedMaxNanos, andfeed.<name>.downgraded.
EventSource.SlowConsumerStrategy is deprecated. In feed and pipe configuration without an overload block,
BACKOFF means DROP_AFTER_SPIN (warned), and DISCONNECT and EXIT_PROCESS fail boot, naming the feed. Set
on a source directly, the strategy is still ignored.
In RECORD mode, a dropped item keeps its journal entry and sequence number. The processor's recording gets a
Dropped entry where the items were lost. A replay carries on and reports the loss through
GroupReplayer.losses() and lossless(), which completeness checks must read beside complete().
Unbounded blocking, conflation, priority lanes and spill to disk are not implemented. Blocking propagates latency to the publisher, which is why the deadline is bounded and agent threads never wait.
Decide per feed whether loss is acceptable. Replaceable market-data snapshots may permit loss with explicit
freshness/recovery logic; incremental updates can also require gap detection and replay. Orders, fills, credit
changes and hedge instructions need BLOCK_WITH_DEADLINE, published from non-agent threads whose callers handle a
refusal. The policy ends at the feed's publish call: it does not provide end-to-end delivery from the venue, and
the client must still reconcile with the venue after a latch.
No setting in the current enum changes that fact.
Shutdown drains queued events for shutdownDrainTimeoutMillis, capped by the remaining overall deadline. On
expiry it logs shutdown drain timed out after <bound>ms, dropping <pending> queued events and proceeds with queue
release. Pending items can be discarded; the logged count is a snapshot, not an exact committed loss ledger.
Size the drain from backlog and conservative processing throughput, and test stalled consumers. See the
shutdown runbook.
Not guaranteed¶
These are deliberate non-guarantees in this build:
- A void control method that times out has not failed. Its request still applies.
- A stage dependent attached after completion runs on the attaching thread. That thread can be an agent thread.
addProcessorandstopProcessor(handle)never back off for mailbox capacity, but synchronous setup and JVM scheduling do not have a hard real-time bound.stopProcessor(group, name)stops what was admitted under the name at call time, not at the time it is applied.- A removed service whose withdrawal no group confirmed is stopped only by
shutdown(). removeServiceduring shutdown is silently ignored. It does not throw.- A
StopResultthat is not complete leaves the serverSTOPPING. Nothing resumes it unlessshutdown()is called again. - Replay does not detect arbitrary hidden inputs or prove service state/build equivalence. It refuses unavailable positive service versions and failed callbacks; new recordings omit skipped duplicate deliveries. See when a replay stops.
- Provenance compares class names. It does not identify a build, a configuration or a generated-graph fingerprint.
- The control-audit manifest detects accidental change. It is not a signature.
Generated processor failure containment¶
A group whose control plane dies while its thread lives (a rule threw) fences every processor the same way: inputs
and timers stop, instances and names stay owned, nothing restarts, and group:<name>/control reports DOWN (issue
77). The fence covers what the group delivers. It cannot stop other threads calling a processor's exported services¶
directly, and fenced readers keep their queued backlog until the group closes.
A generated or interpreted Fluxtion processor implementing InternalEventProcessor can retain partial application
state and external effects after a handler throws. Runtime 1.1.3 and newly generated processors clear the processing
flag and discard failed-cycle work, but old generated code can still wedge until regenerated. A normal return from
a subsequent call is not proof that application state is safe. Mongoose therefore does not retry that graph after a non-VM callback failure on its hosted
dispatch, replay, service, timer, capture-listener or lifecycle paths.
The group removes that processor from every input reader, cancels its timers, emits an ERROR and a
ProcessorQuarantined control fallback record, and keeps healthy processors running. It retains ownership of the
failed instance and its name. New capture installation is refused; existing capture is detached without calling
the failed graph. A plugin unable to detach offline must report failure and retain ownership. Generated stop()
and tearDown() are not invoked after quarantine. A generated stop that itself fails must not be followed by
teardown. Explicit stop replies are FAILED, and shutdown remains incomplete with dependent services retained.
controlGroupStatus(group).quarantinedProcessors() names the affected processors and reasons. Running-processor
queries exclude them while the name reservation remains held. Failed replay cursors settle with their reason
preserved. Admin commands addressed to a removed route are refused and queued commands abandoned by reader
close receive a refusal instead of waiting for a processor that can no longer dispatch them.
There is no automatic restart, flag reset, callback replay, or operator acknowledgement that converts unknown cleanup into success. Preserve the audit and application state and use the deployment's incident procedure. The failed input may already have produced partial external effects. Healthy subscribers receive that input at most once: a subscriber that already handled it is never given it again. This is not transactional rollback.
A custom dispatcher that reports no failed position (an opaque strategy) is resolved through the processor and dispatch position the base dispatch loop captures at throw time (issue #77, rc1 review N1 and external review 1):
- A handwritten thrower is retried alone under the retry policy, resumed at its position. It never quarantines a generated peer.
- A generated thrower is quarantined alone, and dispatch resumes with the subscribers after it.
- Subscribers before the thrower, which handled the input, never receive it again.
- A recorded position is used only when the strategy implements the relevant
processEventFromorprocessEventRecordingFromcapability; otherwise it follows identity-only handling. - During single-target retry the host retains the selected identity even if the custom strategy does not record it again.
- If a strategy records the thrower but not its position, only the thrower is handled and the input is skipped for the remaining subscribers, so no peer is given it twice:
- a generated thrower is quarantined;
- a handwritten thrower is retried alone under the retry policy if the strategy implements the optional
single-processor delivery
EventToInvokeStrategy.processEventFor; - without that method, the handwritten thrower is not retried either. The input is skipped and the skip is reported at ERROR. The group is never ended for this recoverable failure, and the whole fan-out is never redispatched.
- A strategy that records nothing (it bypasses the base dispatch loop) leaves the target unidentifiable. With a
generated subscriber on the reader, the group ends with a
ProcessorDispatchExceptionsaying so, and no peer is quarantined. With handwritten subscribers only, the input is skipped with an ERROR and the reader stays alive. There is no safe retry target, so the whole fan-out is never repeated.
An admin cycle refused before its command action remains a command refusal. The runtime's runInEventCycle
already clears its processing flag in finally; a custom override that executes other graph calls before entering
that bracket must define its own failure contract. A command that ran and then failed still propagates its dispatch
failure to the host.
Handwritten DataFlow retries require an identified target and a supported single-target or resume capability.
Uninstrumented fan-out no longer uses legacy whole-dispatch retry. The containment marker is the runtime
interface, not reflection or a guessed flag name; handwritten wrappers hiding a generated graph need their own
failure contract. A standalone scheduler without a group handler cancels its failed owner's remaining timers and
reports ERROR, preserving its reporting-only API; it cannot isolate routes owned by another container.
This host policy does not repair the compiler's processing flag or make arbitrary direct graph calls retry-safe.
Driver-authored FAILED replies carry replyAuthor: DRIVER (the author) and a kind (the cause): FAILED_RULE (the
control processor threw or did not settle), FAILED_DRIVER_FALLBACK (it settled without replying) or FAILED_DEAD
(a fatal error while performing effects). failureKind keeps its 1.0.33-rc1 meaning, "a driver-authored reply", and
is always FAILED_DRIVER_FALLBACK. The kind alone does not identify the author: a rule's reply can carry
FAILED_DRIVER_FALLBACK after the driver abandoned one of its effects. The validator rejects driver-authored records,
the kinds FAILED_RULE, FAILED_DRIVER_FALLBACK, FAILED_DEAD and QUARANTINED, and ProcessorQuarantined
records, regardless of reason wording. An export in which any record carries a kind is judged by kinds only, and a
reply or failed outcome missing its kind is a violation; only exports with no kinds at all (written before issue #77)
use reason-text classification.
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.