Messaging
|
This page documents the Hazelcast wire contract fixed in All four inbound queues are consumed as of M2. |
Hazelcast Queues
Cluster: a dedicated container hazelcast:ft (hazelcast/hazelcast:5.5.0-slim); both the ACS and ft-fcc-service are Hazelcast client version 5.5.0 (pinned in pom.xml). Queues are obtained with hazelcastInstance.getQueue(name) directly and are used with no name prefix — the ACS’s HazelcastCacheFactory only prefixes maps and sets (see FccQueueNames).
| Queue | Direction | Element type | max-size | Batching |
|---|---|---|---|---|
|
|
|
1000 |
None |
|
ACS → |
|
1000 |
None |
|
ACS → |
|
10000 |
100 items or 5s, whichever first |
|
ACS → |
|
10000 |
100 items or 5s, whichever first |
|
ACS → |
|
1000 |
None |
max-size is configured in hazelcast.xml in the ft-products-compose repository, not in this service.
Producer and consumer rules that apply on both sides of every queue:
-
Producer:
queue.offer(item, 1, SECONDS)(FccOutboxPublisher.OFFER_TIMEOUT_SECONDS). Neveradd()— anadd()that silently drops on a full queue is a known anti-pattern in an older FT service and is not to be repeated here. Afalseresult never drops the message: the outbox row simply staysPENDING— see The Transactional Outbox. -
Consumer: one dedicated, non-daemon
take()-loop thread per queue (FccQueueListenerRunner, one thread per registeredFccQueueConsumerbean; since M2 that is all four:FccAckListener,FccResultListener,FccDeviceStateListener,FccActivationEventListener). An interrupt ends the loop (the shutdown signal). AHazelcastSerializationExceptionorClassCastExceptionon one message is logged, counted (fcc.queue.message.unreadable) and skipped — that message only, the thread keeps taking. Any otherThrowableis logged (throttled the same way as The Transactional Outbox's dispatcher, via the sharedFccFailureThrottle), counted (fcc.queue.consumer.error), the queue handle is dropped so it is re-acquired from the Hazelcast instance on the next iteration (a reconnected client hands out a new proxy), and the loop backs off 5 seconds and retries the message it is still holding, up to five attempts before giving it up (fcc.queue.message.lost).take()removes the element, so without that the firstftfccblip during a consume would destroy a whole batch of up to a hundred measurements, and unlike the ack queue there is no republish path that could recover it. The thread itself is never restarted, it survives the failure. The sharedFTQueueConsumerhelper fromftdm-coreis explicitly not used here: it sleeps 2 seconds on every iteration and only catchesInterruptedException. -
Ordering:
commandSeqis monotonic pertestId(stored infcc_test.command_seq, incremented in the same transaction as the outbox row — see fcc_test). The ACS discards a command whosecommandSeqis not strictly greater than the last one it applied for that test, answeringREJECTED(STALE_SEQ). -
A rejected command consumes its
commandSeq. The seq is incremented for every new command this service publishes, whatever the ACS then answers — aREJECTEDdoes not hand the number back. An ack-timeout republish is not a new command: it goes out with the sameidempotencyKeyand the samecommandSeq(see Ack Timeout and the Republication Window). A corrected command — an operator fixed the config and activated again — is a new command and gets a new key and a new seq. Stated explicitly in contract 1.3 because the alternative reading (reuse the seq of a rejected command) would makeSTALE_SEQfire on the corrected command that was supposed to fix the problem. -
Idempotency:
idempotencyKeyis the activation’s UUID. A repeat of a key the ACS has already processed getsDUPLICATE, not a second execution — and, since contract 1.3, thereasonof thatDUPLICATEcarries the outcome of the original processing, which this service unwraps and applies; see DUPLICATE Carries the Original Outcome. -
Wire identifier types:
activationIdandtestIdareLongon every DTO that carries them (contract 1.3; they wereIntegerup to 1.2, changed before the first deploy). This matches theBIGINTprimary keys on both sides, so nothing on the queue/command path narrows an id — there is nointconversion and therefore no bounds check to fail, either when a command is built or when an ack, a result or a device state is read back. The one bounds-checked narrowing the codebase had lived inFccCommandMapper, and 1.3 correctly deleted it, because the wire no longer narrows.That claim is scoped to the queue contract on purpose. The REST surface still exposes ids as Integerand still narrows theLongprimary keys down to it, unguarded:FccTestServicenarrows the test id intoFccIdResponse, andFccItemRequest/FccItemsRequesttake campaign ids asIntegeron the way in. Both narrowings pre-date contract 1.3 and 1.3 did not touch them — widening the REST DTOs is an open follow-up, not something the contract change settled. No installation is anywhere nearInteger.MAX_VALUErows infcc_testorfcc_activation, so this is a latent gap rather than a live defect; it is written down here so that "ids are not narrowed" is not read as true of the whole service.
The Closed REJECTED Reason Vocabulary
Both sides agree on this exact, closed list — no other string is ever placed in FccCommandAckDto.reason for a REJECTED result:
| Reason | Meaning |
|---|---|
|
|
|
Command type is not supported by this ACS version ( |
|
|
|
The |
|
The ACS consumer flag ( |
|
Anything else on the ACS side. |
For an FccActivationEventDto of type ACTIVATION_FAILED, the equivalent closed vocabulary for reason is ACS_RESTART, ACS_UNAVAILABLE, INTERNAL.
On this service’s side the same vocabulary is FccAckReasonEnum, plus one constant the ACS side does not need: UNKNOWN. FccAckReasonEnum.fromWire normalizes the wire string and falls back to UNKNOWN for anything it does not recognize — a new reason added to a future ACS release is recorded and logged, never thrown on. FccAckService.reject stores the unwrapped reason (truncated to 255 chars) in fcc_activation.failure_reason, not the enum name, so the exact ACS text survives for a report; the enum is used for the closed-vocabulary check and the structured log line only. For a plain REJECTED the unwrapped reason is the raw wire string, so nothing about that case changed; for a DUPLICATE it is what the wire wrapper carried, so a reason of REJECTED:INVALID_CONFIG leaves INVALID_CONFIG in the column and the REJECTED: wrapper itself is never stored — see DUPLICATE Carries the Original Outcome. A null reason on the wire is stored as the literal UNKNOWN, and so is a DUPLICATE reading a bare REJECTED with no suffix. reject is guarded by the same source states as accept (PLANNED, DISPATCHING): a REJECTED for a run that has already started or already been closed is logged at INFO with the activation id, its state and the reason, and changes nothing. The concrete case is a lost ack — the ACS started the ACTIVATE, the row stayed SENT, the timeout poller republished the identical payload, and an ACS whose idempotency memory has expired answers REJECTED(STALE_SEQ) for a run that is live and streaming results.
Matching an Ack: idempotencyKey and commandSeq
Contract 1.3 added FccCommandAckDto.commandSeq — the seq of the command the ack is answering. An ack is therefore correlated against the fcc_acs_outbox row on both fields: the idempotencyKey finds the row, and the commandSeq says which send of that key this ack belongs to. The key alone cannot do that: it names the activation, not a particular send, so with the key alone every ack that arrives looks like the answer to the send currently outstanding — including one that has been sitting in the queue since a command this service has long since moved past.
Ack commandSeq vs. the outbox row |
What this service does |
|---|---|
Equal |
The normal case. The ack answers the send currently recorded on the row and is applied — |
Lower |
A stale ack answering an earlier send of the same key. Logged at |
Higher |
Should not happen — the ACS answered a send this service has no record of. Logged at |
|
A pre-1.3 ACS that does not populate the field. Applied exactly as before 1.3, on the key alone. The field is optional on the wire precisely so that an ACS and a service can be deployed in either order. |
DUPLICATE Carries the Original Outcome
DUPLICATE means "I have already processed this idempotencyKey`" — it says nothing, by itself, about whether that original processing succeeded. Since contract 1.3 the `reason field carries the original outcome and this service unwraps it and applies that outcome, instead of treating every DUPLICATE as an acceptance:
reason on a DUPLICATE |
Applied as |
|---|---|
|
Acceptance. The activation moves to |
|
The rejection it names, with |
A bare |
Still a rejection — the ACS did say it refused the original — but with no reason to name, so the activation fails with |
|
Still the rejection it names. The unknown string is stored verbatim in |
Anything else — a reason that names neither outcome |
Logged at |
Matching is case-insensitive and both the whole reason and the part after the colon are trimmed, so the unwrap does not turn on the ACS’s spacing or casing.
|
The two fallbacks lean in opposite directions on purpose. A duplicate reason that names neither outcome is treated as acceptance because the realistic cause is a newer ACS reporting its original processing in a shape this service has not been taught yet, for a campaign that is in fact running; failing the activation on an unrecognised string would kill a live run over a vocabulary mismatch, and the run’s real state arrives anyway through results and activation events. Note how narrow this fallback is: it is the envelope that has to be unrecognised. A A |
Command and Event Semantics
ACTIVATE
The ACS saves fcc_sa_activation (+ devices), schedules FccSaActivationJob, and acks ACCEPTED immediately after saving and scheduling — not after any device has actually started. ACTIVATION_STARTED is emitted at the job’s first tick; ACTIVATION_FINISHED when every iteration has run and no device remains SENT; ACTIVATION_FAILED(reason) on an ACS restart with no surviving job (ACS_RESTART) or an unrecoverable error.
Window: the ACS computes N = totalDurationH * 60 / singleDurationMin iterations and startAt = max(now, windowStartAt). window_end_at on the ACS side is informational only; completion is decided by iterations_done == iterations_total and no device in flight. On this service’s side, FccAckService.accept recalculates windowEndAt from the actual start the moment an ACCEPTED ack — or a DUPLICATE that unwraps to an acceptance, DUPLICATE Carries the Original Outcome — lands (FccActivationMapper.windowEndAt(startedAt, totalDurationH)) — this part is implemented and code-read. The two records therefore diverge by the dispatch latency, and that is the contract, not a bug: the command already on the ACS still carries the original window, the ACS does not decide completion from it, and this service’s watchdog and reports use the re-anchored value so that a command which waited in the outbox still gets its full measurement window.
Since M2 the same re-anchoring also happens on ACTIVATION_STARTED, and it is applied once: the ack and the event travel on different queues, either may arrive first, and letting the second one move started_at would shift the window the watchdog and every report read (FccActivationTransitionService, SET_STARTED_AT only when the column is still null).
A watchdog that actively closes a run whose window has passed is implemented as of M2: FccActivationWatchdog / FccActivationClosingService claims RUNNING rows past window_end_at + ft.fcc.watchdog.finish-grace and closes them as FINISHED(WATCHDOG) — not as failures, because the window really did run to its end, nobody reported a fault, and the measurements taken inside it are valid; only the stop reason records that the closing decision was local. The campaign then gets the same ACTIVATION_FINISHED a reported completion would have produced, which is what plans the next daily reactivation — without it a single lost control message would end the campaign. That close is logged at WARN and not INFO, because it means a control event was lost between the ACS and here. See What the Watchdog Closes, and With Which Grace and The Five Pollers.
STOP
The ACS removes FccSaActivationJob, sets next_action = NONE on every device of the activation, cancels its own cpe_pending_task rows by transaction_id, and acks ACCEPTED immediately; it then emits ACTIVATION_STOPPED{inFlightCount} where inFlightCount is the number of devices still SENT at that moment. Results for those in-flight devices still arrive normally, each carrying afterStop = true. On this service’s side, the activation stays STOPPING until inFlightCount reaches 0 or ft.fcc.stop-grace (default 15 minutes) elapses, whichever comes first. Since contract 1.5 that same window also travels to the ACS: every ACTIVATE carries it as config.retry.stopDrainGraceSec, so the ACS’s own decision about how long to keep holding in-flight results after a STOP agrees with the wait this service applies locally — by contract, not by coincidence — see What Contract 1.5 Changed.
A STOP is always a new command — fresh idempotencyKey, command_seq = fcc_test.command_seq + 1 — written by the single FccStopCommandPublisher on all three paths that need one: the operator stopping or pausing a campaign, the operator deleting one whose run is still live, and FccStalledActivationService giving up on a run the ACS never acknowledged.
The stop reason does not travel on the wire: the contract’s STOP carries no reason field, so USER_STOP / USER_PAUSE / WATCHDOG / ACS_UNAVAILABLE exist only in fcc_activation.stop_reason and on the log line.
A run still PLANNED is the one case where no STOP is sent at all — see the PLANNED divergence and commandPublished: false Is Not a Failure.
The STOPPING Drain: Recounted, Never Decremented
in_flight_count is written from the stop ack (ACTIVATION_STOPPED{inFlightCount}), only when the ACS actually reported one.
A null count is left alone: writing 0 for it would make the drain check close the run immediately and discard every measurement the devices still in flight are producing.
The state machine makes the same choice, treating an absent count exactly like a positive one — waiting out the stop grace costs a delay, closing early costs measurements.
From then on the figure is recomputed from fcc_device_state, never decremented, which is why FccActivationDao.decrementInFlight no longer exists:
stillSent = SELECT COUNT(*) FROM fcc_device_state
WHERE activation_id = ? AND state = 'SENT'
in_flight_count = MIN(stored_in_flight_count, stillSent)
A per-message decrement cannot be made idempotent on an at-least-once queue.
The same batch redelivered after a failed consume, or two service instances handed overlapping batches, would each subtract again and drive the column to a number of devices that were never in flight.
Counting the rows that are still SENT is exact by construction — it reads the state the upsert has just written, on the same connection and in the same transaction — and it costs one indexed count per stopping run rather than one update per device.
The MIN clamp is load-bearing: the column may only ever fall.
Hazelcast does not order messages, so a redelivered or reordered batch can re-assert SENT for a device that has since finished; taking that recount at face value would resurrect devices the run has already drained and keep it open until the stop grace killed it.
The close is additionally guarded by stored_in_flight_count > 0, which is not the same condition as recount == 0.
A run reaches STOPPING with the column still at its initial zero whenever the ACS did not report a count, and reading that zero as "drained" on the first device-state batch to arrive would undo exactly the decision described above.
When the recount does reach zero, IN_FLIGHT_DRAINED closes the run as STOPPED.
When it does not, ft.fcc.stop-grace does — see What the Watchdog Closes, and With Which Grace.
|
Contract 1.5’s device-state |
PAUSE / Resume
PAUSE, as exposed by this service’s REST surface, is implemented as a STOP of the current activation with stop_reason = USER_PAUSE, plus the campaign moving to PAUSED instead of the NOT_ACTIVE a stop lands in.
There is no PAUSE on the wire: what reaches the ACS is an ordinary STOP, so the running measurements end rather than being suspended.
Resume is a plain POST /FCC/activate, which creates a new activation with a new idempotencyKey, a new seq and a fresh settings snapshot — it is not a resend of the paused one.
See POST /FCC/pause.
The PAUSE and UPDATE_CONFIG command types exist in FccCommandTypeEnum for future use but the ACS answers REJECTED(UNSUPPORTED) to both in v1, and this service’s outbox never writes either type — see fcc_acs_outbox.
Ack Timeout and the Republication Window
If no ack arrives within ft.fcc.command-ack-timeout (default 60s), FccAckTimeoutPoller (ticking every ft.fcc.outbox.ack-timeout-interval, default 30s) republishes the outbox row with the same idempotencyKey and the same commandSeq via FccOutboxPublisher.republishTimedOut, backing off along the same ladder as a fresh publish — see The Transactional Outbox. A republish is a resend of one command, not a new one, which is why neither identifier moves; the ACS recognises it by the key and answers DUPLICATE if it already has it. If an ACCEPTED (or a DUPLICATE whose reason unwraps to an acceptance, DUPLICATE Carries the Original Outcome) does arrive, but only after some delay, and that delay is still within ft.fcc.activation-max-delay (default 30 minutes), the run starts with a shifted window: FccAckService.accept moves the activation to RUNNING and recalculates windowEndAt from the actual start regardless of how long the ack took.
The commandSeq check of Matching an Ack: idempotencyKey and commandSeq is the second half of this: an ack whose seq is lower than the one recorded on the outbox row answers a send this service has already moved past, so a late ACCEPTED cannot restart a run that the checks below have already written off.
Independently of the outbox row’s own retry state, the same poller tick also runs FccStalledActivationService.failStalled, which claims every activation still DISPATCHING whose window_start_at is older than now - activation-max-delay — this check is driven by the activation’s age, not by the outbox row’s attempt count. Past max-delay with still no ack, the activation goes FAILED(ACS_UNAVAILABLE), the owning fcc_test moves to ERROR, and a brand-new STOP command (fresh idempotencyKey, command_seq = fcc_test.command_seq + 1) is written to the outbox for the same activationId — in case the ACS did in fact apply the original command after all, despite the lost ack.
|
Nothing was ever sent for a The case the wider predicate would otherwise cover — a run whose |
Activation Event Semantics
fccActivationEventQueue is the ACS’s control channel about a run it is executing, separate from the ack channel and from the result stream, and it is the only one that can report a run finishing.
Everything else this service knows about the end of a run is a local guess: the watchdog closing an expired window is the fallback for exactly this message being lost.
FccActivationEventListener → FccActivationEventService.apply translates the wire vocabulary into the two state machines' events and applies both, in one transaction per message:
FccActivationEventTypeEnum |
Run event | Campaign event | Effect |
|---|---|---|---|
|
|
|
Run |
|
|
|
Run → |
|
|
|
Run → |
|
|
|
Run → |
An event naming an activation this service does not know is dropped, not an error (fcc.activation.event.dropped): an event can outlive the run it describes — a purge, a restored backup, an ACS pointed at a different database — and there is nothing to apply it to.
An event with no type or no activationId is dropped the same way, at WARN.
An ACS that omits the instant falls back to Instant.now(), because a null started_at would make the run invisible to the window watchdog forever.
The Reported Columns Are Written AFTER the Transition Is Accepted
failure_reason and in_flight_count are the two columns an event carries that are not part of any transition, and they are written only once the run transition is known to be allowed — never before.
The activation is a managed entity inside the transaction, so a field set on it is flushed at commit whether or not anything calls save.
A late ACTIVATION_FAILED for a run that is already FINISHED is denied by the state machine, but writing the reason first would still have left its failure_reason on that finished run, next to a contradictory stop_reason.
The fix is to write nothing until the transition is allowed.
Symmetrically, when the run transition is denied, the campaign is not touched either: applying only the campaign half of an event the run refused is how a stopped campaign gets resurrected by a late report.
ACTIVATION_STOPPED Does Not Always Reach the Campaign
Two cases where FccActivationEventService deliberately stops short of applyTest:
-
The run is still
STOPPING. It has devices in flight. The campaign is reported as stopped once, when the run actually closes — either by the drain recount or by the stop grace. -
The run’s
stop_reasonisUSER_STOPorUSER_PAUSE. The operator stopped it and the REST endpoint has already moved the campaign toNOT_ACTIVEorPAUSED. A campaign the operator paused and then stopped keepsUSER_PAUSEon its draining run, and is covered by the same clause — see A Paused Campaign Can Be Stopped.
|
The second case is the one that matters.
Feeding That rule has exactly one implementation, |
Reactivation: Daily, and Missed Slots Are Skipped
The PLAN_REACTIVATION effect brings FccReactivationService.planNext into play, which decides when the next daily run opens and creates it as a PLANNED fcc_activation with no command attached.
The cadence is the product rule: fcc_test.reactivation_days is a count of daily runs, so run n+1 opens twenty-four hours after run n opened — not twenty-four hours after it was reported finished, which would let the campaign drift by the length of every run.
The anchor falls back from started_at to window_start_at to now: a run closed by the watchdog before the ACS ever reported a start has no started_at, and the cadence still has to be anchored on something the operator would recognise.
|
Missed slots after an outage are skipped, not fired.
|
planNext re-checks finished.seq < test.reactivationDays — the same predicate FccTestTransitionContext.hasMoreReactivations() evaluated when it emitted the effect.
The two disagreeing means the campaign was edited in between, and planning a run the campaign does not owe would make it measure longer than the operator configured, so it logs a WARN and plans nothing.
The planned row’s window is then claimed by the PLANNED poller when it opens, and FccActivationHandler.dispatch re-takes the settings snapshot on the way out — see A Planned Reactivation Re-Takes Its Snapshot at Dispatch.
A Skip Is Not a "Try Again Next Tick"
When dispatch answers false, the poller does not leave the row alone — it closes it, as STOPPED(WATCHDOG), inside the same transaction that holds the claim.
dispatch answering false means the campaign does not expect this run any more: it was stopped, paused or re-activated while the row waited for its window, or its settings rows are gone.
None of those can change back.
A PLANNED row whose window has opened is claimed by every single tick from now on, and every later PLAN_REACTIVATION adds another one to claim — so leaving it behind is an unbounded, permanent leak of poller work.
WATCHDOG and not ERROR is the truthful reason, and the state machine already uses the constant in exactly this sense: RUNNING + WINDOW_EXPIRED → FINISHED(WATCHDOG) records that the closing decision was local, not that anything failed.
Nothing did fail here either — the ACS was never involved, nobody rejected anything, and a local poller is closing a run its campaign has moved on from.
ERROR would put a fault into the run history of a campaign that is running perfectly well on a newer run.
Only a row that is still PLANNED is closed (FccStopService.closePlannedRun re-checks the state rather than assuming it), because dispatch also answers false for a row somebody else has already moved to DISPATCHING or RUNNING, which is not the poller’s to close.
What the Watchdog Closes, and With Which Grace
Every remote report may simply never arrive — the ACS restarted, the queue was drained by another cluster, the message was unreadable — and a run nobody closes stays RUNNING or STOPPING forever: its campaign never reactivates, never completes, and its window sits open in every report.
| Pass | Claim | Outcome |
|---|---|---|
Window expiry |
|
Run → |
Stop grace |
|
Run → |
|
Tying them together would mean that making stops more patient also delays every watchdog close.
Both default to Since contract 1.5, |
Both bounds are computed in Java (now.minus(grace)) and passed in as parameters, so no statement depends on vendor date arithmetic or on the database clock agreeing with this service’s — and the grace stays a @ConfigurationProperties value rather than something baked into SQL.
Device State Ingest
fccDeviceStateQueue carries FccDeviceStateBatchDto, and FccDeviceStateService.apply turns one message into rows of fcc_device_state — the current position of each CPE of a run, which is what POST /FCC/devices/status reads.
One statement per message, not per device.
A batch can carry the campaign’s whole fleet — up to twenty thousand devices — so anything per row is twenty thousand round trips.
The activations the batch refers to are resolved in one query (findAllById), and the states go down as a single JDBC batch through FccDeviceStateDao.
A row is written only if it can be keyed and attached, and the two rejections are counted apart on purpose:
| Rejection | Counter | What it means |
|---|---|---|
Missing |
|
A malformed message, logged at |
|
|
Not malformed at all, logged at |
The upsert itself is documented column by column in fcc_device_state: GREATEST for the four monotonic counters, newest-wins on the observed instant for serial / state / state_reason, and one vendor-native statement (ON DUPLICATE KEY UPDATE on MySQL, MERGE on Oracle) because a read-modify-write would lose a concurrent update from a second service instance consuming the same queue.
An absent observation instant falls back to the batch’s sentAt and finally to now: a row with an invented timestamp merges by arrival order, which is wrong but recoverable, while a row refused for a missing timestamp is a device that stays INITIAL on the status screen forever.
After the upsert, every run the batch touched that is currently STOPPING gets its drain recount — see The STOPPING Drain: Recounted, Never Decremented.
Device State Reasons
FccDeviceStateDto.stateReason is an open string on the wire, qualifying FccDeviceStateEnum where the state alone does not say why.
On this side it is the closed enum FccDeviceStateReasonEnum, stored in fcc_device_state.state_reason.
It is not on any response since POST /FCC/devices/status was reduced to DEV-1381 #9’s six counters (POST /FCC/devices/status); it is kept because it is what the state column means and what the state-queue log lines carry, and it is what a per-device grid would render if one is added back.
| Reason | Usual state | Meaning |
|---|---|---|
|
|
The CPE does not implement the diagnostic the run asked for. |
|
|
The CPE is busy with another diagnostic and could not accept this one. |
|
|
The CPE did not answer within the configured diagnostic timeout ( |
|
|
No WAN interface could be resolved to measure on. |
|
|
The CPE reported a fault of its own. |
|
|
Added in contract 1.3. The ACS no longer holds the activation this device belonged to, so the device could not be run. Explicitly not a device fault, and must not be reported as one when per-device status reaches the UI. |
|
|
Added in contract 1.5, alongside the |
|
any |
Anything this version does not recognise. Local only — the ACS never sends it. |
This is a closed vocabulary on this side, but it is not the same closed vocabulary the ACS sends: the eight named reasons above are exactly FccDeviceStateReasonEnum’s constants, and an ACS `stateReason of USER_STOP maps to the enum constant of the same name rather than falling back to UNKNOWN — a reader who stops at ACTIVATION_GONE and assumes anything else lands in UNKNOWN would misread every STOPPED device the drain reports.
FccDeviceStateReasonEnum.fromWire trims and upper-cases the wire string and falls back to UNKNOWN for anything it does not know.
This is the same split as FccAckReasonEnum, and for the same reason: the DTO package is the frozen contract shared byte-for-byte with the ACS, so a new constant added on one side must not break deserialization on the other.
A device-state screen showing UNKNOWN for a reason this version has not learned yet is strictly better than an ACS upgrade that makes the whole state queue unreadable — and fcc_device_state.state_reason is an enum column here, so an unmapped value would otherwise poison it.
state itself is the opposite case: it is stored as the wire enum com.friendly.fcc.dto.FccDeviceStateEnum (INITIAL, OFFLINE, SKIPPED, WAN_CHECK, CROSS_TALK, SENT, FINISHED, ERROR, STOPPED) with no local copy at all, because the ACS owns that vocabulary end to end and a second dictionary would only add a mapping that can drift.
Contract 1.5 added STOPPED as the ninth constant: the device was halted by a STOP between steps — its WAN sample or its diagnostic was never sent. It is not a failure and not an error.
It is its own state, exactly as CROSS_TALK is, and neither is ever counted as a failure: the drain recount ignores both (The STOPPING Drain: Recounted, Never Decremented), and the six counters of POST /FCC/devices/status fold STOPPED into notSent and CROSS_TALK into skipped, never into failed. The nine states themselves are what fcc_device_state holds; the six are only the shape of that one answer. See What Contract 1.5 Changed.
The Transactional Outbox
A Hazelcast offer() cannot participate in a database transaction, so the command DTO is written to fcc_acs_outbox in the same transaction that creates fcc_activation and increments fcc_test.command_seq (FccActivationHandler.activate) — see fcc_acs_outbox for the full column reference and the state-to-state mapping.
FccOutboxDispatcher ticks every ft.fcc.outbox.dispatch-interval (default 5s) and delegates to FccOutboxPublisher.publishPending, which claims up to ft.fcc.outbox.batch-size (default 100, same value as the older flat ft.fcc.outbox-batch-size property — see Configuration Reference) rows with FccOutboxDao.claimPending (the two-step FOR UPDATE SKIP LOCKED claim, Vendor-Specific Claim SQL), offers each payload to fccCommandQueue with queue.offer(command, 1, SECONDS), and moves the row to SENT only after a successful offer(). A false (timed-out) offer() never drops the message — the row is left PENDING with a later next_attempt_at; this is the entire reason the outbox table exists rather than a direct offer() call from the REST handler. It does, however, end the tick: a full queue means the consumer is behind, so the rows still claimed are left PENDING for the next tick instead of holding the write transaction, its row locks and a pooled connection for one second per remaining row. The claim, the offer() and the state update run inside one @Transactional method, so the row lock is held for the whole batch — a second service instance dispatching concurrently simply skips the locked rows instead of double-sending.
Both a fresh publish and an ack-timeout republish share the same failure path (FccOutboxPublisher.publishOne): each failed attempt increments attempts and computes the next retry with FccOutboxBackoff.nextAttemptAt — the ladder is 5s, 15s, 45s, 2m, 5m, 15m, then 30m flat from the seventh attempt on. Once attempts >= ft.fcc.outbox-max-attempts (default 10), FccOutboxDao.markAbandoned sets the row ABANDONED and nothing automatically retries it.
|
The independent, age-based check described in Ack Timeout and the Republication Window still exists and, with the shipped defaults, normally fires first — the waits after attempts 1-6 (5s, 15s, 45s, 2m, 5m, 15m) total roughly 23 minutes, and |
Vendor-Specific Claim SQL
FccOutboxDao and FccActivationDao claim in two statements, not one, and only the first of the two is vendor-specific (selected by FccSqlDialect):
-
an ordered, capped, read-only select of candidate ids;
-
SELECT id FROM <table> WHERE <the same predicate> AND id IN (…) FOR UPDATE SKIP LOCKED, in the same transaction, which actually claims them.
| Vendor | Row cap of step one |
|---|---|
MySQL |
|
Oracle |
|
Why two statements: a capped view combined with FOR UPDATE is exactly what raises ORA-02014 ("cannot select FOR UPDATE from view with DISTINCT, GROUP BY, etc."), so an ordered Oracle claim and a locking Oracle claim cannot be the same statement. Splitting them sidesteps it. The predicate is repeated in step two because another instance can claim, process and commit a candidate between the two statements; a locking read sees the latest committed row, so the re-check is what keeps the two-step claim as safe as the single statement it replaces. A row another instance is holding is simply skipped by SKIP LOCKED, so the worst case is claiming fewer rows this tick.
FccSqlDialect.current() resolves the vendor from DatabaseMetaData.getDatabaseProductName() exactly once and caches it in a volatile field — but the detection itself is lazy, on first use, not eager at application startup: the constructor deliberately opens no connection, because the service is required to start even against an unreachable ftfcc database (it degrades and retries rather than failing the Spring context), and a constructor-time probe would break exactly that. In practice the first outbox dispatch tick (five seconds after startup by default) triggers the detection, so the effective difference from "at startup" is small, but the two are not the same guarantee.
Result Ingest
FccResultListener takes each FccResultBatchDto off fccResultQueue and hands it to FccResultIngestService.accept, which per measurement counts and logs a result that names no activation (fcc.result.unroutable, an ACS bug rather than a device fault), de-duplicates the rest across the whole cluster and buffers them. The activation is resolved (FccActivationContextResolver, an ftfcc read) and the verdict computed (FccSpeedStatusCalculator or FccLatencyStatusCalculator) at flush time, not on the way in: take() is destructive, so anything between taking a message and buffering it is a path where a failure costs the whole batch, while the flush is retried.
Routing by Direction Happens at Flush Time
A PING is buffered exactly like a DOWNLOAD — both are measurements of the same run and share one dedup key space and one buffer — and the batch is split only once its activations have been resolved:
FccResultDto.direction |
Destination |
|---|---|
|
|
|
|
|
|
Since M4 the two INSERT`s sit in two separate `try`s, each with its own half of the retryable rows — not the single `try M1-M3 used. They write different tables, from disjoint sets of measurements, under different natural keys: no row of one is the counterpart of a row of the other, so "half the flush is written" is not a corrupt state, it is the speed measurements stored and the ping measurements still owed, and the next tick owes exactly those.
|
Putting the written half back with the failed one would insert it a SECOND time, and that is the defect the split fixes. A duplicate in a The first failure is the one reported in the batch’s |
In M1 a PING result was counted (fcc.result.ping.ignored) and dropped, because fcc_result_latency did not exist yet. That counter is gone — every direction is now stored.
|
De-Duplication Is One Atomic putIfAbsent
The natural key is (activationId, serial, direction, iteration), joined into one string (FccResultIngestService.dedupKey) and written to the fccResultDedup Hazelcast IMap (FccMapNames.FCC_RESULT_DEDUP) with:
dedup.putIfAbsent(key, Boolean.TRUE, dedupTtlSeconds, TimeUnit.SECONDS) != null
This has to be one atomic call, never a get followed by a conditional put. With more than one service instance consuming the same batch (or the ACS re-delivering it), two instances doing get first would both see "absent" in the window between the two calls and both would insert the row — the exact duplicate the mechanism exists to prevent. putIfAbsent is atomic on the member that owns the key’s partition, so only one of the two racing calls can return null. The TTL is ft.fcc.dedup.ttl (default 1h) — long enough to cover the ACS’s own retry window, short enough that the map does not grow without bound; see Configuration Reference for the operational note about also bounding it with a max-size in hazelcast.xml.
If the Hazelcast call itself fails (cluster unreachable, serialization error), isDuplicate answers "not a duplicate" and the row is stored anyway: a row a reader can still collapse on the natural key is recoverable, a silently dropped measurement is not.
The marker is written before the ClickHouse write, so every path that then drops the row releases it again (dedup.remove): the buffer overflow in buffer(), the exhausted re-offer in requeue(), and the @PreDestroy shutdown flush, which calls forgetDedup for every row it could not write before parking it (FccResultIngestService.flushOnShutdown). The invariant is a key may only outlive a row that was actually written — otherwise a recoverable drop would become a permanent loss, because the key would keep the slot for the full TTL and an ACS retransmission inside that window would be skipped as a duplicate; the dedup IMap lives on the Hazelcast cluster with that TTL, so it outlives this JVM and the invariant has to hold across a restart too. Each buffered row therefore carries its own dedup key.
Batching and the ClickHouse Retry Path
Rows that pass de-duplication are pushed onto an in-memory LinkedBlockingQueue bounded by ft.fcc.result.queue-capacity (default 10000), still in wire form. The buffer is flushed — resolve the activation context, judge the rows (The Pass/Fail Formula — the Single Shared Definition), then FccResultSpeedDao.insert, one batched JDBC batchUpdate — as soon as it holds ft.fcc.result.batch-size (default 100) rows, and independently every ft.fcc.result.flush-interval (default 5s) via FccResultIngestService.flushTick, whichever happens first. A @PreDestroy flush drains it once more at shutdown: it runs after FccQueueListenerRunner has interrupted and joined the consumer threads, so nothing is still arriving, and spring.lifecycle.timeout-per-shutdown-phase (20s) leaves room for it.
The same buffer doubles as the retry queue: a failed flush — the ftfcc resolve or either ClickHouse batchUpdate — re-offers every retryable row of the failed batch back onto the queue (requeue) and computes the next retry time with the same FccOutboxBackoff ladder the outbox dispatcher uses, keyed on consecutive ClickHouse failures rather than per-row attempts.
"Retryable" is the whole batch minus the late arrivals, which have already left this service for the dead letter and must not be parked a second time by the next attempt.
Only when the buffer itself is full does a row leave the buffer — logged as an ERROR with the row count, counted (fcc.result.parked), and setting FccDegradedState.setClickhouseStatus(UNAVAILABLE) — not SCHEMA_BEHIND, since a full buffer or a failed insert says nothing about why, and ClickHouseHealthIndicator refines the status to SCHEMA_BEHIND on its next probe if that turns out to be the cause (Health). Either status closes FccResultStorageGate and makes every gated endpoint start refusing new work until a batch is written successfully again: POST /FCC/activate; since M4, all four /FCC/reports/ endpoints; POST /FCC/admin/result-dead-letter/resend (FccAdminController), because a resend feeds the very ingest path a result store outage already broke; and the legacy-import start (FccLegacyImportService), because it would read millions of rows only to fail every insert against a store that cannot take them (POST /FCC/activate, Endpoints Implemented in M4).
Since M2 such a row is not dropped: it is parked in fcc_result_dead_letter** — see The Dead Letter. The ERROR level is kept deliberately even though nothing is lost any more, because reaching the dead letter at all means the buffer has been full long enough to overflow, which needs a human whatever happens to the row afterwards.
The @PreDestroy shutdown flush parks whatever it could not write, for the same reason: a rolling deploy during a ClickHouse outage is the most likely way to meet that branch, and it is the one moment where there is no next tick to retry on. The ClickHouse backoff is reset before that final flush, since there is no next tick to wait for either.
|
|
Per-batch context (the activation’s fcc_test_id, the cross-talk thresholds and the speedSuccessPercent / latencySuccessMs a result is judged against, plus domain_id and hubb_id as a fallback, plus stoppedAt) is resolved once for the whole flush batch, not once per row: FccActivationContextResolver.resolve runs two queries (fcc_activation by id, then fcc_test by the resulting test ids) and returns an activation-id-keyed map. It is deliberately never cached across batches — a stale threshold would silently change a verdict, which a compliance report cannot tolerate — so every batch re-reads the activation snapshot it needs, which is exactly what makes an edited-while-running test still measured against the settings that were frozen at activation time (The fcc_activation Settings Snapshot).
The After-Stop Window
Contract Q26. fcc_activation.stopped_at defines two boundaries, not one, and they mean different things:
| Boundary | Column / table | What it does |
|---|---|---|
|
|
Marks the measurement as taken after the run was told to stop. It is still stored normally, in whichever result table its direction calls for. |
|
|
Excludes the measurement from the run. It is not written to ClickHouse at all. |
For after_stop the wire flag wins when it is set — the ACS knows when it issued the diagnostic — and the local comparison is the backstop for an ACS that does not set it.
Without that backstop the flag would be the only source, and an older ACS would have every one of its after-stop results silently indistinguishable from the rest.
|
A That is the regulatory rule and not a performance decision.
A compliance export must be able to exclude what was measured after the campaign was stopped — which means keeping the measurement somewhere it cannot be counted by accident, not throwing it away, which would also destroy the evidence that it happened.
Resending it later would put it straight back into the export the rule exists to keep it out of, which is why a |
Two details of the comparison matter:
-
It uses the arrival instant the row has carried since
accept, neverInstant.now()at flush time. After a long ClickHouse outage every buffered row would suddenly look late, and a whole run’s results would be parked instead of stored. -
A row whose activation cannot be resolved is not late. "Unknown run" and "after the run ended" are different statements, and treating them alike would park rows that merely lost their context, along with the fallbacks the mapping already applies to them.
The Dead Letter
fcc_result_dead_letter replaces both M1 TODO(M2) drop paths and the shutdown drop.
Four paths write to it, and they carry one of the two reasons of the reason dictionary:
| Path | Reason | Counter |
|---|---|---|
The in-memory buffer refused a row ( |
|
|
A failed flush could not put every row back |
|
|
The |
|
|
The measurement arrived past its run’s stop grace |
|
|
|
There is no |
FccResultDeadLetterService.store runs in its own transaction (REQUIRES_NEW) and never throws: the ingest path that calls it is already failing and must not be able to roll back the record of that failure, nor be handed a second exception in the middle of a flush.
If even that insert fails, the row is genuinely gone — and only then is fcc.result.lost incremented, separately from fcc.result.parked, because the two mean completely different things to an operator.
Rows are parked grouped by their arrival instant, because receivedAt belongs to the row and not to the call: a retry batch mixes measurements from several accept invocations, and parking them all under one instant would rewrite when they arrived.
The dedup marker (De-Duplication Is One Atomic putIfAbsent) is released on every path that parks a CLICKHOUSE_UNAVAILABLE row, for two reasons stacked on each other: the general invariant that a key may only outlive a row that was actually written, and the fact that a parked row is replayed through accept on resend and therefore has to be able to pass the dedup check again.
A key left behind would make the resend a no-op and turn a recoverable outage into silent data loss.
A LATE row is the one exception and deliberately does not release its key: it is durably recorded, it is never resent, and a retransmission of the same natural key would be judged late all over again.
Where domain_id and hubb_id Come From
Contract 1.3 added domainId and hubbId to FccResultDto itself. The ingest path therefore takes both from the result, and falls back to the activation snapshot only when the wire value is absent — a pre-1.3 ACS (no domainId/hubbId field at all), a pre-1.4 ACS (the field exists but the ACS had no domain to put in it, see [contract-1-4-domain-answered]), or an activation whose config carried no hubbId.
"Absent" is not the same test for the two fields, and the asymmetry is deliberate:
| Field | Which wire values count as absent |
|---|---|
|
|
|
|
The snapshot resolve stays in place regardless. It is not made redundant by the two new fields: it is what supplies fcc_test_id, the cross-talk thresholds and speedSuccessPercent, and a batch cannot be judged at all without it (The Pass/Fail Formula — the Single Shared Definition). What the new fields remove is only the dependency of those two label columns on the snapshot: when the ACS names the domain and the hubb on the result, they are taken as sent and the snapshot value is not consulted. See fcc_result_speed for the columns themselves.
|
Whose domain is Contract §2 annotated the field as coming "from the activation config" from 1.3 onward, but through 1.3 no outbound command actually carried a domain: Contract 1.4 closes it: The caveat that made this an open question is still a live caveat for historical data: a row written by a pre-1.4 ACS may carry a different domain than a row written by a 1.4 ACS for the same kind of campaign, because the pre-1.4 value was plausibly the device’s domain rather than the test’s. Anyone building a report that spans the 1.3/1.4 boundary should not assume |
DTO Contract Rules
The com.friendly.fcc.dto package is copied byte-for-byte into both ft-fcc-service and the ACS’s fcc-core module. There is deliberately no shared library for it (a shared JAR would recouple two services that are supposed to deploy independently).
| Rule | Why |
|---|---|
|
The wire format is Java serialization on the Hazelcast queue; a |
Explicit |
Without it, the JVM-computed default UID changes with any structural change and silently breaks deserialization of anything already sitting in a queue or spool at deploy time. |
Fields may only ever be added, never renamed, retyped, or removed. Enum values may only be appended. |
This is what lets an old consumer read a new producer’s message and vice versa without a coordinated deploy — see The Governing Decision. |
No Jackson annotations, no Spring dependency inside the DTO package |
The ACS project is on Jackson 3 ( |
In the database ( |
A stored string has to survive a deploy that changes the DTO shape; only JSON degrades gracefully when a field is added. The "only ever add fields" rule therefore governs the stored JSON exactly as it governs the queue payload — see fcc_acs_outbox. |
|
26 positional fields of overlapping types on one flat record, with no builder and no tests to catch a swapped pair, is exactly the shape that gets two |
What Contract 1.3 Changed
| Change | Effect on this service |
|---|---|
|
The wire type now matches the |
|
An ack is correlated on key and seq — see Matching an Ack: idempotencyKey and commandSeq. Nullable on the wire, so a pre-1.3 ACS still interoperates. |
|
A duplicate is no longer a synonym for an acceptance — see DUPLICATE Carries the Original Outcome. |
|
A vocabulary addition when 1.3 landed; consumed since M2, and mapped through |
|
Enforced before the command is built, at both save and activation time — at save time for the devices being added to the test, at activation time for the whole stored fleet. See PUT /FCC/item/details and POST /FCC/activate. A blank serial would otherwise come back as |
|
Taken from the result, with the activation snapshot as the fallback — see Where domain_id and hubb_id Come From. |
"A rejected command consumes its `commandSeq`" written down |
No behaviour change; the rule this service already followed is now stated in the contract — see Hazelcast Queues. |
What Contract 1.4 Changed
| Change | Effect on this service |
|---|---|
|
The only wire shape change in 1.4. |
|
|
Ack |
The longest value in the closed vocabulary, |
Deploy of contract 1.3+ must be joint; the |
Not new to 1.4, but restated because it still applies: |
What Contract 1.5 Changed
Three additive fields, none of them a shape change to an existing field — unlike 1.3’s Integer → Long widening, 1.5 needs no joint, queue-drained deploy.
| Change | Effect on this service |
|---|---|
|
Reported for a device the ACS halted between steps by a |
|
Carried on |
|
Only on a |
|
1.5 is additive, but not symmetric, and that asymmetry sets a deploy order. The wire DTOs cross Hazelcast by plain Java serialization (DTO Contract Rules), and the two new fields tell two different stories under it.
Order: this service first, the ACS second. An ACS upgraded to 1.5 ahead of this service will emit During a rolling deploy of this service itself, old and new instances consume the same Unlike the contract 1.3 |
The Pass/Fail Formula — the Single Shared Definition
This is stated once and is authoritative for both repositories.
It has two halves — speed and latency — and they share a resolution order, a status dictionary (SUCCESS \| FAILED \| CROSS_TALK \| OTHER) and the rule that CROSS_TALK is never a failure.
|
The ACS evaluates this exact same predicate internally, but purely as a private decision input for |
The Speed Half
FILE_BASED (the window the device transferred the file in)
speed_kbps = total_bytes * 8.0 / duration_ms // duration_ms = eom - bom
TIME_BASED (TR-143's full-loading window)
speed_kbps = test_bytes_under_full_loading * 8.0 * 1000.0 / period_of_full_loading_micros
both
speed_ok = speed_kbps >= tier_kbps * speedSuccessPercent / 100.0
cross_talk = wan_usage_dl_kbps >= thresholdDlKbps || (thresholdUlKbps != null && wan_usage_ul_kbps >= thresholdUlKbps)
Where duration_ms = eom - bom in milliseconds, computed as a double with no integer division anywhere in the chain, and tier_kbps is compared directly in Kbps — no division by 1024. Implemented, code-read, by FccSpeedStatusCalculator.evaluate (pure, no Spring, no I/O), called once per measurement from FccResultIngestService.
Two Formulas, One Verdict — and Why BOM..EOM Is Wrong for TIME_BASED
Which formula applies is decided by the run’s own fcc_activation.test_mode snapshot, never by re-reading the campaign. A finished run has to keep reporting what it measured even after somebody switches the campaign to the other mode, which is the rule every value on that snapshot follows (The fcc_activation Settings Snapshot). An absent mode is read as FILE_BASED: that is what every run dispatched before contract 1.6 actually was.
bom..eom IS NOT THE DENOMINATOR OF A TIME-BASED TEST, and using it is the one arithmetic mistake in this feature that produces a plausible number rather than an error. That window spans the WHOLE configured duration, TCP ramp-up included, while TR-143’s full-loading window exists precisely to exclude the ramp-up. Dividing by eom - bom would therefore under-report every time-based measurement and fail compliant lines — a report that accuses an ISP of breaching a tier it is meeting.
The units of the second formula, spelled out, because a factor of a thousand in the wrong place also looks plausible: bytes times eight are bits; period_of_full_loading_micros is in microseconds, so dividing by it and multiplying by a thousand is dividing by milliseconds; bits over milliseconds is Kbps, which is the unit tier_kbps, the thresholds and the stored speed_kbps column are already in. Worked: 12 500 000 bytes under full loading over 10 000 000 us (ten seconds) is 12500000 * 8 * 1000 / 10000000 = 10 000 Kbps — ten megabits, which is exactly 12.5 MB in ten seconds.
Everything that decides a VERDICT is shared. Only the numerator and the denominator differ by mode: cross-talk is still answered first, an unmeasurable iteration is still OTHER, and only then is the rate compared against the tier. The two modes therefore cannot drift into two different definitions of SUCCESS.
A time-based iteration with no full-loading block is OTHER with no speed, guarded exactly as the file-based pair is: an absent or non-positive period_of_full_loading_micros, or absent or non-positive full-loading bytes. A device can run a time-based transfer and still answer the TR-143 full-loading leaves as zero or not at all — an older or trimmed vendor model — and judging it against an invented window would be worse than declining to judge it. The ACS agrees on these inputs: FccSpeedPredicate.evaluateSpeedTimeBased answers INDETERMINATE rather than falling back to bom..eom, which keeps the "one formula on both sides" rule true and, since anything other than OK arms a failed retry, leaves the device one more chance inside the iteration to produce a usable figure.
Three rules matter more than the arithmetic itself:
-
No integer division, no
/1024. The legacy formula (ActionFccSpeedTaskHandler:77-80,178-180) truncates through integer division and additionally divides by 1024 where it should not. It is wrong and must not be copied. -
duration_ms ⇐ 0maps tostatus = OTHER, never toInfinity. Some devices report BOM/EOM at whole-second precision; a fast, small file can complete within the same reported second, which would otherwise produce a division by zero or a meaningless speed figure. In code, "unmeasurable" is broader than just the duration:total_bytesabsent or not positive, ortier_kbpsnull/0, maps toOTHERthe same way a non-positive duration does — there is simply nothing to compare against.total_bytes = 0is the case that looks measurable and is not, and it was reported asFAILEDuntil it was fixed. A device can answerDiagnosticsState = Completedwith a plausible multi-second BOM/EOM window and nothing transferred — an upload to a URL that accepts the request and discards it does exactly that. The arithmetic then gives0 Kbps, which is below every tier, so a compliance report said "the line is slower than contracted" where the truth was "nothing was transferred". It isOTHERfor the same reasonCROSS_TALKis not a failure, and it is also what the ACS’s own retry predicate always answered:FccSpeedPredicate.speedKbpsreturnsnullfortotal_bytes ⇐ 0, so the two sides disagreed about the same bytes. One transferred byte is still a measurement and still a breach if it is under the tier — only zero means nothing happened. -
CROSS_TALKis a reporting category, not a failure. When the WAN was already busy above the configured threshold at the time of the test, the iteration carries no usable speed figure and must not be counted againstspeedSuccessPercentin either direction.
The three outcomes are checked in a fixed order, and the order matters because more than one condition can be true for the same iteration:
-
Cross-talk first.
wan_usage_dl_kbps >= thresholdDlKbps, or (thresholdUlKbpsis set and)wan_usage_ul_kbps >= thresholdUlKbps→CROSS_TALK, regardless of whether the iteration would otherwise have been measurable. -
Unmeasurable second. Only checked once cross-talk has been ruled out:
duration_ms ⇐ 0, ortotal_bytesisnullor⇐ 0, ortier_kbpsisnull/0→OTHER. -
The speed comparison last. Only reached when the iteration is both not cross-talk and measurable:
speed_kbps >= tier_kbps * speedSuccessPercent / 100.0→SUCCESS, elseFAILED.speedSuccessPercentfalls back to the FT Configs runtime default (80) when the activation snapshot carries no explicit value (FccRuntimeDefaults.SPEED_SUCCESS_PERCENT).
speed_kbps itself is still computed (and stored) even for a CROSS_TALK or OTHER verdict when duration_ms > 0 and total_bytes is positive — only the verdict is short-circuited by the ordering above, not the throughput figure written to fcc_result_speed.speed_kbps. Where there is nothing to compute from, the column carries 0, which is the same value the honest arithmetic would have produced.
The Latency Half
ping_ok = avg_ms < latencySuccessMs && success_count > 0
cross_talk = wan_usage_dl_kbps >= thresholdDlKbps || (thresholdUlKbps != null && wan_usage_ul_kbps >= thresholdUlKbps)
Implemented, code-read, by FccLatencyStatusCalculator.evaluate — pure, no Spring, no I/O — and called once per PING measurement from FccResultIngestService.
latencySuccessMs falls back to FccRuntimeDefaults.LATENCY_SUCCESS_MS (100) when the activation snapshot carries no explicit value.
In practice thresholdUlKbps is always null for a latency run, because the snapshot writes the upload columns null for LATENCY (The Snapshot Is Type-Driven) — the cross-talk clause is written to handle it anyway, so the two calculators stay literally the same predicate.
The resolution order is identical to the speed calculator’s, and that is a hard rule rather than a coincidence:
-
Cross-talk first →
CROSS_TALK, regardless of whether the iteration would otherwise have been measurable. -
Unmeasurable second:
avg_ms == nullorsuccess_count == null→OTHER. -
The criterion last →
SUCCESSorFAILED.
A compliance report reads the two tables as one set of iterations, so a measurement that would be CROSS_TALK as a download must not come out as FAILED merely because it was a ping.
Reordering the branches here would make the same line condition mean two different things depending on which diagnostic happened to run on it.
|
"Not measurable" is This is also why the |
packets_sent is reconstructed as successCount + failureCount and never read from the configured repetitions — see fcc_result_latency.
An absent counter contributes 0 to that arithmetic, because the two packet columns are non-Nullable Int32, while the three round-trip values pass a null through unchanged because theirs are Nullable(Int32).
Contract 1.5 adds FccResultDto.pingRepetitions / .pingDataBlockSize next to this, and neither feeds packets_sent. They carry what the device actually did — the repetition count and data block size it ran the ping with — stored verbatim in the new fcc_result_latency.repetitions / .data_block_size columns, falling back to the activation snapshot (ping_repetitions / ping_data_block_size) when the wire value is null. packets_sent stays exactly what it has always been, the sum of successCount and failureCount: that sum is what the device reports having done, the new repetitions column is what it was asked for, and a run that ended early must not be able to claim otherwise by having the two collapse into one figure. See fcc_result_latency for the column-level detail.
The relevant TR-181 object is IPPing, and the three parameters the mapping reads back are:
Device.IP.Diagnostics.IPPing.SuccessCount
Device.IP.Diagnostics.IPPing.FailureCount
Device.IP.Diagnostics.IPPing.AverageResponseTime
TR-181 guarantees SuccessCount + FailureCount = NumberOfRepetitions; the configured value is what was asked for, the sum is what the device reports having done, and a run that ended early must not claim otherwise.
WAN Usage Sampling
wan_usage_kbps = (bytes_2 - bytes_1) * 8.0 / gap_ms, where a wraparound of the 32-bit byte counter (bytes_2 < bytes_1) means the sample pair is discarded and resampled, and gap_ms comes from each side’s own monotonic clock (System.currentTimeMillis()), never from a database column. This sampling runs on the ACS side, outside this repository; ft-fcc-service only receives the resulting wan_usage_dl_kbps / wan_usage_ul_kbps figures on the wire and compares them against the cross-talk thresholds — for a latency run as well as for a speed one, which is why fcc_test_setting_latency carries a threshold_dl at all.
End-to-End Sequence
Stop and Drain
The closing half: a user stop, the STOP command, the ACS acknowledging it with an in-flight count, the device states draining that count back to zero, and the stop-grace timeout as the alternative branch.
A pause is the same diagram with stop_reason = USER_PAUSE and the campaign landing in PAUSED.