Messaging

This page documents the Hazelcast wire contract fixed in 00-common-contract.md — version 1.5 — and implemented in the com.friendly.fcc.dto package (byte-identical to the copy in ft-acs). 1.4’s one wire change is FccActivationConfigDto.domainId, the test’s domain, which the ACS now echoes back on FccResultDto.domainId — see What Contract 1.4 Changed. 1.5 adds three fields, all additive: a ninth device state STOPPED, FccRetryPolicyDto.stopDrainGraceSec on the outbound ACTIVATE, and FccResultDto.pingRepetitions / .pingDataBlockSize on an inbound PING result — see What Contract 1.5 Changed.

All four inbound queues are consumed as of M2. FccQueueListenerRunner starts one take() thread per registered FccQueueConsumer bean, and there is now one for each: FccAckListener, FccResultListener, FccDeviceStateListener and FccActivationEventListener. Everything on this page describes shipped code; nothing is marked TBD any more.

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

fccCommandQueue

ft-fcc-service → ACS

FccCommandDto

1000

None

fccCommandAck

ACS → ft-fcc-service

FccCommandAckDto

1000

None

fccResultQueue

ACS → ft-fcc-service

FccResultBatchDto

10000

100 items or 5s, whichever first

fccDeviceStateQueue

ACS → ft-fcc-service

FccDeviceStateBatchDto

10000

100 items or 5s, whichever first

fccActivationEventQueue

ACS → ft-fcc-service

FccActivationEventDto

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). Never add() — an add() that silently drops on a full queue is a known anti-pattern in an older FT service and is not to be repeated here. A false result never drops the message: the outbox row simply stays PENDING — see The Transactional Outbox.

  • Consumer: one dedicated, non-daemon take()-loop thread per queue (FccQueueListenerRunner, one thread per registered FccQueueConsumer bean; since M2 that is all four: FccAckListener, FccResultListener, FccDeviceStateListener, FccActivationEventListener). An interrupt ends the loop (the shutdown signal). A HazelcastSerializationException or ClassCastException on one message is logged, counted (fcc.queue.message.unreadable) and skipped — that message only, the thread keeps taking. Any other Throwable is logged (throttled the same way as The Transactional Outbox's dispatcher, via the shared FccFailureThrottle), 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 first ftfcc blip 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 shared FTQueueConsumer helper from ftdm-core is explicitly not used here: it sleeps 2 seconds on every iteration and only catches InterruptedException.

  • Ordering: commandSeq is monotonic per testId (stored in fcc_test.command_seq, incremented in the same transaction as the outbox row — see fcc_test). The ACS discards a command whose commandSeq is not strictly greater than the last one it applied for that test, answering REJECTED(STALE_SEQ).

  • A rejected command consumes its commandSeq. The seq is incremented for every new command this service publishes, whatever the ACS then answers — a REJECTED does not hand the number back. An ack-timeout republish is not a new command: it goes out with the same idempotencyKey and the same commandSeq (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 make STALE_SEQ fire on the corrected command that was supposed to fix the problem.

  • Idempotency: idempotencyKey is the activation’s UUID. A repeat of a key the ACS has already processed gets DUPLICATE, not a second execution — and, since contract 1.3, the reason of that DUPLICATE carries the outcome of the original processing, which this service unwraps and applies; see DUPLICATE Carries the Original Outcome.

  • Wire identifier types: activationId and testId are Long on every DTO that carries them (contract 1.3; they were Integer up to 1.2, changed before the first deploy). This matches the BIGINT primary keys on both sides, so nothing on the queue/command path narrows an id — there is no int conversion 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 in FccCommandMapper, 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 Integer and still narrows the Long primary keys down to it, unguarded: FccTestService narrows the test id into FccIdResponse, and FccItemRequest / FccItemsRequest take campaign ids as Integer on 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 near Integer.MAX_VALUE rows in fcc_test or fcc_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

STALE_SEQ

commandSeq was not greater than the last one applied for this test.

UNSUPPORTED

Command type is not supported by this ACS version (PAUSE, UPDATE_CONFIG in v1).

UNSUPPORTED_CHUNKING

deviceChunks != 1 (chunking is not implemented in v1; FccCommandDto.deviceChunk/deviceChunks are always 1/1).

INVALID_CONFIG

The FccActivationConfigDto failed the ACS’s own validation.

ACS_DISABLED

The ACS consumer flag (fcc.command.consumer.enabled) is off. In practice the ack never arrives at all in this case, rather than arriving as a REJECTED.

INTERNAL

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 — ACCEPTED/DUPLICATE per DUPLICATE Carries the Original Outcome, REJECTED per The Closed REJECTED Reason Vocabulary.

Lower

A stale ack answering an earlier send of the same key. Logged at WARN with both seq values and ignored — no state change at all. This is the check that stops a late ACCEPTED from resurrecting a run that has since been written off (failed by the age-based stalled check or by an exhausted outbox row, Ack Timeout and the Republication Window).

Higher

Should not happen — the ACS answered a send this service has no record of. Logged at WARN and applied anyway: whatever else it is, it is the freshest information available about that key, and discarding it would leave the activation stuck with nothing better coming.

null

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

ACCEPTED (or null/blank)

Acceptance. The activation moves to RUNNING and windowEndAt is recalculated from the actual start, exactly as for a plain ACCEPTED.

REJECTED:<reason>

The rejection it names, with <reason> taken from the closed vocabulary of The Closed REJECTED Reason Vocabulary. A DUPLICATE reading REJECTED:INVALID_CONFIG fails the activation with INVALID_CONFIG — that is also what lands in failure_reason, without the REJECTED: wrapper.

A bare REJECTED, or REJECTED: with nothing after the colon

Still a rejection — the ACS did say it refused the original — but with no reason to name, so the activation fails with UNKNOWN.

REJECTED:<reason> where <reason> is one this version does not know

Still the rejection it names. The unknown string is stored verbatim in failure_reason; only the enum degrades to UNKNOWN, for the closed-vocabulary check and the log line.

Anything else — a reason that names neither outcome

Logged at WARN and treated as ACCEPTED.

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 REJECTED: envelope is applied as a failure even when the reason behind the colon is one this version has never seen, because the ACS has already said plainly that it refused the original.

A REJECTED:<reason>, by contrast, is applied as a failure, even though it arrives on a DUPLICATE. Before 1.3 a DUPLICATE was handled identically to ACCEPTED, so a republished command whose original had been rejected moved the activation to RUNNING and left the operator with a campaign that would never produce a single measurement.

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 STOPPED needed no change here, and that is worth stating. STOPPED ([device-state-wire-dictionary]) is reported by the ACS for a device it halted between steps — the WAN sample or the diagnostic was never sent — and is its own category, exactly like CROSS_TALK: never a failure, never folded into error. The recount above only ever counts rows still SENT, so a device reporting STOPPED has already left that count by the same door FINISHED and ERROR leave it by. A run whose remaining in-flight devices all report STOPPED drains and closes normally, with no branch added for the new 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.

PLANNED is deliberately not a stalled state, and reading it as one is a data-loss bug rather than a cosmetic widening.

Nothing was ever sent for a PLANNED row — it carries the unassigned command_seq and owns no outbox row (FccActivationHandler.planReactivation) — so there is no ack that could have timed out and no command to cancel. Claiming one would write the campaign’s next daily run off as ACS_UNAVAILABLE half an hour after its window opened, publish a STOP for an activationId the ACS has never heard of (burning a command sequence), and feed ACTIVATION_FAILED to a campaign that may be RUNNING a perfectly healthy newer run.

The case the wider predicate would otherwise cover — a run whose ACTIVATE never reached the queue at all — is already covered by the outbox’s own attempt budget: FccOutboxPublisher exhausts ft.fcc.outbox-max-attempts and calls FccStalledActivationService.failAbandoned for exactly that row (The Transactional Outbox).

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

ACTIVATION_STARTED

EVENT_STARTED

ACTIVATION_STARTED

Run PLANNED/DISPATCHING → RUNNING with started_at and a re-anchored window_end_at; from RUNNING it is an idempotent confirmation. The campaign stays RUNNING.

ACTIVATION_FINISHED

EVENT_FINISHED

ACTIVATION_FINISHED

Run → FINISHED (or → STOPPED if it was already STOPPING). This is the event that carries the campaign’s cadence: to SCHEDULED_FOR_REACTIVATION with the PLAN_REACTIVATION effect while seq < reactivation_days, or to COMPLETED when there is nothing left to run — see Reactivation: Daily, and Missed Slots Are Skipped.

ACTIVATION_FAILED

EVENT_FAILED

ACTIVATION_FAILED

Run → FAILED(ERROR), campaign → ERROR. The ACS’s reason is stored verbatim in fcc_activation.failure_reason, truncated at 255. The closed vocabulary is ACS_RESTART, ACS_UNAVAILABLE, INTERNAL, but the column is free text on purpose — there is no local vocabulary to substitute from and an invented value would be indistinguishable from one the ACS really reported. A null reason leaves the column as it is rather than writing a placeholder.

ACTIVATION_STOPPED

EVENT_STOPPED

ACTIVATION_STOPPED, conditionally

Run → STOPPING or STOPPED, split on the reported inFlightCount — see The STOPPING Drain: Recounted, Never Decremented. The campaign is often not touched at all; see below.

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_reason is USER_STOP or USER_PAUSE. The operator stopped it and the REST endpoint has already moved the campaign to NOT_ACTIVE or PAUSED. A campaign the operator paused and then stopped keeps USER_PAUSE on 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 ACTIVATION_STOPPED on top of a pause is the single way a pause silently degrades into a stop. RUNNING + ACTIVATION_STOPPED → STOPPED is a perfectly valid transition, so nothing downstream would refuse it — the campaign would simply end up STOPPED, and the one distinction between a pause and a stop would be gone.

That rule has exactly one implementation, FccActivationTransitionService.isUserInitiatedStop, used by all three call sites that could trip over it: this event path, the drain recount in FccDeviceStateService, and the stop-grace watchdog in FccActivationClosingService.

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.

base + 24h may already be in the past — this service was down for two days, or an ACS only reported the finish when it came back. A poller that claimed a window which opened yesterday would fire every missed reactivation back to back, burning the campaign’s remaining runs in minutes and filling a compliance report with measurements taken in one afternoon.

nextWindowStartAt therefore steps forward one 24-hour period at a time until the instant is strictly after now, resuming the daily rhythm at the next real slot and simply losing the missed ones — which is what "a daily test" means. The loop is capped at ten years of steps (MAX_CATCH_UP_STEPS = 3660); a corrupt started_at far in the past turns into one WARN and a run tomorrow rather than a spin.

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

state = 'RUNNING' AND window_end_at IS NOT NULL AND window_end_at ⇐ now - ft.fcc.watchdog.finish-grace

Run → FINISHED(WATCHDOG), campaign gets ACTIVATION_FINISHED — which plans the next daily reactivation exactly as a reported completion would. Logged at WARN.

Stop grace

state = 'STOPPING' AND stopped_at IS NOT NULL AND stopped_at ⇐ now - ft.fcc.stop-grace

Run → STOPPED, whatever it still owes. The campaign is left alone when the stop was user-initiated. Logged at INFO, with the in-flight figure, because those devices are still measuring and this is the only record of how many measurements arrive for a run that is already closed.

ft.fcc.watchdog.finish-grace and ft.fcc.stop-grace are two knobs on purpose, and they are not interchangeable.

finish-grace bounds a RUNNING campaign overrunning its window. It exists because the end of the window and the end of the last iteration are not the same instant — the ACS finishes the diagnostic it has already started, then reports — and closing on the window boundary alone would relabel a perfectly normal completion as a watchdog kill. It is a property of how long a single diagnostic takes.

stop-grace bounds a STOPPING campaign draining its in-flight devices. It is a property of how fast the ACS reacts to a command.

Tying them together would mean that making stops more patient also delays every watchdog close. Both default to 15m, which is a coincidence of the defaults and not a relationship.

Since contract 1.5, stop-grace is no longer a purely local knob: every ACTIVATE now carries it to the ACS as config.retry.stopDrainGraceSec (What Contract 1.5 Changed), so changing it also changes what the ACS is told to do on the next activation, not only how long this service waits locally. finish-grace still travels nowhere — STOP carries no config at all, and neither does a RUNNING run’s window.

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 activationId, cpeId or state

fcc.device.state.invalid

A malformed message, logged at DEBUG and not WARN: this check sits inside a loop over a batch that can carry the campaign’s whole fleet, so one malformed message from an ACS could otherwise produce up to twenty thousand WARN lines from a single consume. The first two fields are the unique key of fcc_device_state and the third is NOT NULL, so this points at the ACS; what an operator needs is the aggregate skipped count apply() logs and the fcc.device.state.invalid counter, with the per-row detail available only when DEBUG is turned on to find the offending device.

activationId names a run this service does not know

fcc.device.state.unroutable

Not malformed at all, logged at DEBUG. It is the mirror image of the contract’s ACTIVATION_GONE reason — the same disagreement seen from this side — and there is simply no row to attach the state to.

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

UNSUPPORTED

SKIPPED

The CPE does not implement the diagnostic the run asked for.

BUSY

SKIPPED

The CPE is busy with another diagnostic and could not accept this one.

TIMEOUT

ERROR

The CPE did not answer within the configured diagnostic timeout (config.diagTimeoutSec).

NO_WAN_IFACE

ERROR

No WAN interface could be resolved to measure on.

DEVICE_FAULT

ERROR

The CPE reported a fault of its own.

ACTIVATION_GONE

ERROR

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.

USER_STOP

STOPPED

Added in contract 1.5, alongside the STOPPED device state itself ([device-state-wire-dictionary]). The operator stopped the run before this device had a diagnostic in flight. Not a fault, exactly like ACTIVATION_GONE.

UNKNOWN

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.

ABANDONED fails the activation in the same transaction. FccOutboxPublisher calls FccStalledActivationService.failAbandoned right after markAbandoned: the run goes FAILED with a failure_reason naming the exhausted outbox row, the owning fcc_test moves to ERROR, and a fresh STOP (its own idempotencyKey, command_seq = fcc_test.command_seq + 1) is queued, because a row that was SENT at least once may well have reached an ACS that is now measuring. It is idempotent by state: a run that has meanwhile started or been closed is left alone.

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 FccOutboxPublisher abandons on attempt 10 without waiting after it, so attempts 7-9 add three more 30-minute waits on top of that, for roughly 113 minutes to ABANDONED — comfortably past the 30-minute activation-max-delay ceiling. The two paths are deliberately independent: an installation that raises activation-max-delay well past the outbox ladder’s total is covered by the ABANDONED path, and an ACS that simply never answers is covered by the age-based one.

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):

  1. an ordered, capped, read-only select of candidate ids;

  2. 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

ORDER BY …​ LIMIT ? — LIMIT is the only row cap MySQL has.

Oracle

SELECT id FROM (SELECT id …​ ORDER BY …​) WHERE ROWNUM ⇐ ? — Oracle evaluates ROWNUM as rows are produced, so a ROWNUM predicate in the same query block as the ORDER BY caps first and sorts afterwards: an arbitrary N rows rather than the N oldest, which can starve a backlog indefinitely. The cap therefore sits outside an ordered inline view.

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

PING

fcc_result_latency, judged by FccLatencyStatusCalculator.

DOWNLOAD, UPLOAD

fcc_result_speed, judged by FccSpeedStatusCalculator.

null

fcc_result_speed, with the empty-string direction that column has always carried. It is not a ping, and inventing one out of an absent field would put a row into a table whose entire latency block it cannot fill.

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 MergeTree cannot be taken back: there is no uniqueness to violate, a reader collapses it on the natural key (which both tables document it must do anyway), and every extra copy skews the regulatory report it is counted into. At the thirty-minute backoff ceiling, one table stuck behind a schema mismatch or a bad row used to re-write the other table’s healthy rows on every retry — roughly forty-eight duplicate copies a day. Only the half that actually failed is requeued now.

The first failure is the one reported in the batch’s last_error, and one flush still counts as one failed attempt regardless of whether one table failed or both — counting it twice would double the backoff exponent on every tick a whole ClickHouse outage is behind.

Known limitation: the backoff is still shared across both tables. Splitting the inserts stops the duplication, but consecutiveFailures and retryNotBefore are one counter and one instant for the whole flush, not one per table. While fcc_result_latency alone is unwritable — a schema behind on V3, say — the healthy fcc_result_speed rows in the same buffer are not written immediately either: they wait for the next backoff tick like the failing half, up to the ladder’s 30-minute ceiling. No row is lost or duplicated by this — only delayed — and a per-table backoff is a possible improvement, deliberately not made in M4.

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.

FccDegradedState is therefore written from three independent places: ClickHouseHealthIndicator’s own cached probe, `ClickHouseMigrationRunner at (and after) startup, and FccResultIngestService itself on a batch-insert failure or an exhausted buffer. Any one of them can turn the service DEGRADED, and clearing it back to UP is not the health indicator’s job alone: FccResultIngestService sets UP itself after every successful flush, and ClickHouseMigrationRunner sets UP after a successful migrate — including the 60-second retry tick that keeps trying while the status is not UP — so a ClickHouse outage that resolves through either of those paths clears DEGRADED without waiting on the health indicator’s own probe. ClickHouseHealthIndicator is still the only one of the three that runs unconditionally on a timer — the other two only run, and only therefore only clear the state, when there is a flush or a migration attempt to run — see Health for the three-state status this sets and why SCHEMA_BEHIND and UNAVAILABLE are told apart. /actuator/health reports whichever the health indicator’s own probe currently sees, since it is re-run (subject to its own cache TTL) independently of what the ingest path or the migration runner last set.

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

receivedAt >= stopped_at

after_stop = 1 on the result row

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.

receivedAt > stopped_at + ft.fcc.stop-grace

fcc_result_dead_letter, reason = LATE

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 LATE measurement is parked, never discarded, and never resent.

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 LATE row is written straight as DISCARDED and the resend read filters reason <> 'LATE' on top of that — see A LATE Row Is Parked Straight Into DISCARDED.

Two details of the comparison matter:

  • It uses the arrival instant the row has carried since accept, never Instant.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 (offer returned false)

CLICKHOUSE_UNAVAILABLE, PENDING

fcc.result.parked

A failed flush could not put every row back

CLICKHOUSE_UNAVAILABLE, PENDING

fcc.result.parked

The @PreDestroy shutdown flush left rows behind

CLICKHOUSE_UNAVAILABLE, PENDING

fcc.result.parked

The measurement arrived past its run’s stop grace

LATE, DISCARDED

fcc.result.late + fcc.result.parked

There is no fcc.result.dropped counter any more, and its absence is deliberate. It was documented as "results lost because the buffer was exhausted" — a data-loss alarm — and every path that still incremented it now parks the row instead, so it had quietly become a duplicate of fcc.result.parked while every existing alert on it kept reading it as loss. The two things it used to conflate each have their own counter: fcc.result.parked (recoverable, in the dead letter) and fcc.result.lost (genuinely gone, not even parked). An installation carrying an alert on fcc.result.dropped must move it to fcc.result.lost.

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

domainId

null, and any value that is not strictly positive. Contract §6 is explicit that 0 is not a domain; 0 is also the "unresolvable" sentinel this service writes into the non-Nullable fcc_result_speed.domain_id column when neither source yields a domain. A wire 0 is therefore indistinguishable from "the ACS could not resolve one" and must not be allowed to beat a snapshot value that does resolve, so it is ignored exactly as a null is. Since contract 1.4 the wire value is not the ACS’s own guess: it is fcc_test.domain_id, put on the outbound ACTIVATE command by this service (FccCommandMapper.toActivateCommand, third parameter) and simply echoed back by the ACS — see [contract-1-4-domain-answered]. The snapshot fallback stays in place, because a 1.3 ACS still sends null.

hubbId

null only. An empty string is a legitimate value: a campaign may genuinely have no hubb id, and that is precisely how it is expressed. Treating blank as absent would let the snapshot’s hubb id silently overwrite an ACS that deliberately said "none".

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 FccResultDto.domainId? Settled by contract 1.4: the test’s.

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: FccActivationConfigDto had a hubbId and no domainId, and neither did FccCommandDto. The ACS had therefore never been told the test’s domain and could not have been echoing it back — whatever a 1.3 ACS put in the field was of its own deriving. The most plausible source was cpe.location_id — the device’s domain, which is not the same thing as the test’s: a super-domain operator can legitimately build a test out of sub-domain devices, in which case fcc_test.domain_id and cpe.location_id differ by design. That ambiguity is exactly why the field’s provenance could not be trusted before 1.4.

Contract 1.4 closes it: FccActivationConfigDto gains a domainId component, populated by this service from fcc_test.domain_id (FccCommandMapper.toActivateCommand) and echoed by the ACS verbatim into FccResultDto.domainId. From a 1.4 ACS the field is therefore unambiguously the test’s domain — the same value the fallback already resolved (fcc_test.domain_id, reached through the fcc_activation snapshot), so the wire value and the snapshot fallback now agree by construction.

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 domain_id means the same thing on both sides of it.

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

record …​ implements Serializable, never Lombok @Data

The wire format is Java serialization on the Hazelcast queue; a record is the FT standard shape for a DTO.

Explicit @Serial private static final long serialVersionUID = 1L; on every record, from the first commit

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 (tools.jackson), this service is on Jackson 2; the DTOs must not need either, because on the queue they travel as Java serialization, not JSON.

In the database (fcc_acs_outbox.payload, and the ACS’s own fcc_result_spool.payload / fcc_sa_activation.config_json), the same DTOs are stored as JSON, not Java serialization

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.

FccActivationConfigDto is split into nested records by meaning (FccWanThresholdsDto, FccTiersDto, FccSpeedTargetsDto, FccPingTargetsDto, FccScheduleDto, FccRetryPolicyDto, FccSuccessCriteriaDto)

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 Integer fields transposed.

What Contract 1.3 Changed

Change Effect on this service

activationId / testId are Long everywhere (were Integer)

The wire type now matches the BIGINT keys on both sides; no id is narrowed on the way out or back in. This is the one change that breaks the "never retype a field" rule above, and it was only permissible because it landed before the first deploy of either side — with nothing in a queue, a spool or an outbox to read back, there was no compatibility to preserve. It is not a precedent; from the first deploy on, the rule is absolute.

FccCommandAckDto.commandSeq added

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.

DUPLICATE.reason carries the original outcome

A duplicate is no longer a synonym for an acceptance — see DUPLICATE Carries the Original Outcome.

FccDeviceStateDto.stateReason gained ACTIVATION_GONE

A vocabulary addition when 1.3 landed; consumed since M2, and mapped through FccDeviceStateReasonEnum.fromWire — see Device State Reasons.

FccDeviceDto.serial is mandatory

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 REJECTED(INVALID_CONFIG) from the ACS, long after the operator left the screen.

FccResultDto.domainId / hubbId added

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

FccActivationConfigDto.domainId added

The only wire shape change in 1.4. FccCommandMapper.toActivateCommand now takes the test’s domain id as a parameter and puts it into the ACTIVATE command’s config; FccActivationHandler supplies test.getDomainId(). STOP carries no config and is unaffected. This also settles what FccResultDto.domainId means — see [contract-1-4-domain-answered]. No database change accompanies it: fcc_activation gains no domain_id column, because a republish re-sends the stored fcc_acs_outbox.payload JSON rather than re-running the mapper, so the domain travels with the payload and needs no snapshot column — see fcc_acs_outbox.

STALE_SEQ vs. DUPLICATE precedence clarified at equal commandSeq

STALE_SEQ means the ACS discards a command whose commandSeq is strictly less than the last one applied for that testId. When the seq is equal, the idempotencyKey duplicate check fires first — so an ack-timeout republish, which goes out with the same key and the same seq (Ack Timeout and the Republication Window), gets DUPLICATE, never STALE_SEQ. No behaviour change here either; this is a clarification of wording this service already relied on.

Ack reason length bounded at 64 characters

The longest value in the closed vocabulary, REJECTED:UNSUPPORTED_CHUNKING, is 29 characters, comfortably under the new cap. Already satisfied on this side without a migration: fcc_activation.failure_reason is VARCHAR(255) in changelog_create_fcc_activation.xml, and both FccAckService.FAILURE_REASON_MAX_LENGTH and FccStalledActivationService.FAILURE_REASON_MAX_LENGTH already truncate at 255 — well past 64.

Deploy of contract 1.3+ must be joint; the fcc* queues must be drained first

Not new to 1.4, but restated because it still applies: Integer → Long under an unchanged serialVersionUID (see What Contract 1.3 Changed) makes messages already sitting in a queue unreadable by a peer running the other shape. Any deploy up to and including 1.4 requires the fcc* queues to be empty before rollout, on both sides.

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

FccDeviceStateEnum.STOPPED added (ninth constant)

Reported for a device the ACS halted between steps by a STOP — its WAN sample or its diagnostic was never sent. Not a failure and not an error: its own state, exactly like CROSS_TALK, and never counted as a failure anywhere — see [device-state-wire-dictionary]. fcc_device_state.state needed no migration (already VARCHAR(32)); only the changelog’s XML comment naming the dictionary was updated, which is checksum-neutral because Liquibase computes a changeset’s checksum from the parsed change objects, never from a comment — see fcc_device_state. POST /FCC/devices/status counted it as a ninth counter, stopped, until DEV-1381 #9 reduced that answer to six categories, where it is folded into notSent — nothing was sent to the device - and never into failed; see POST /FCC/devices/status. The STOPPING drain recount (The STOPPING Drain: Recounted, Never Decremented) needed no change at all: it only ever counts rows still SENT, and a device reporting STOPPED has already left that count.

FccRetryPolicyDto.stopDrainGraceSec added

Carried on ACTIVATE as config.retry.stopDrainGraceSec, filled by FccCommandMapper from ft.fcc.stop-grace converted to whole seconds (default 15m → 900). Tells the ACS how long to hold in-flight results after a STOP — the same window this service applies locally, so the two now agree by contract instead of by coincidence (STOP, What the Watchdog Closes, and With Which Grace). It is the second documented exception to FccCommandMapper’s rule that every config value comes from the activation snapshot, never from live configuration — the first is `domainId, contract 1.4 (What Contract 1.4 Changed). fcc_activation deliberately gets no column for it: it is not a parameter of the measurement, it is a property of how this service behaves after a STOP. Reading it live does not reopen the drift the snapshot rule exists to close, for the same reason domainId does not: a republish re-sends the stored fcc_acs_outbox.payload JSON and never runs the mapper again, so the grace on the wire stays the one captured when the activation was dispatched — changing ft.fcc.stop-grace reaches the next activation, never a run already in flight. STOP still carries no config at all; only ACTIVATE carries the grace.

FccResultDto.pingRepetitions / .pingDataBlockSize added

Only on a PING result: what the device actually ran the diagnostic with, as opposed to fcc_test_setting_latency.repetitions / .data_block_size, which is what it was configured with. Stored verbatim in two new fcc_result_latency columns added by the Flyway migration V3__add-ping-actuals-to-fcc-result-latency.sql, falling back to the activation snapshot when the wire value is null and left NULL when neither source has one — see fcc_result_latency. Does not feed packets_sent, which stays successCount + failureCount exactly as before — see Result Ingest and The Latency Half.

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. FccResultDto.pingRepetitions / .pingDataBlockSize are genuinely safe in both directions: a record’s deserialization constructor matches stream fields to canonical-constructor parameters by name and silently drops whatever it does not recognise, and serialVersionUID stays 1L — an old reader ignores the two new fields, a new reader given an old message just sees them come back null.

FccDeviceStateEnum.STOPPED, the ninth constant, is not. A JVM still running the 1.4 DTOs that receives a STOPPED value throws InvalidObjectException on deserialization, surfacing here as HazelcastSerializationException. By the time that happens, FccQueueListenerRunner has already performed a destructive take() (Hazelcast Queues), and it treats an unreadable message as not retryable — inFlight is cleared, not held for another attempt — so the entire FccDeviceStateBatchDto is discarded, up to the campaign’s twenty thousand device states, counted only as fcc.queue.message.unreadable.

Order: this service first, the ACS second. An ACS upgraded to 1.5 ahead of this service will emit STOPPED to instances that cannot read it yet.

During a rolling deploy of this service itself, old and new instances consume the same fccDeviceStateQueue, and any batch containing a STOPPED state that an old instance happens to take() is lost whole, not just the offending row. That is a real cost of a mixed window, and it argues for a short one.

Unlike the contract 1.3 Integer → Long widening (What Contract 1.4 Changed), this does not require the fcc* queues to be drained first: the loss is bounded to device-state batches that actually carry the new constant, and device state is current-state, not history — it is re-reported on the next batch, so a discarded one is recovered by the next — whereas a lost measurement is gone for good. Watch fcc.queue.message.unreadable during the rollout window; a run of it there, and nowhere else, is this failure mode.

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 FAILED_RETRY — it never publishes the verdict. The status stored by ft-fcc-service in fcc_result_speed / fcc_result_latency is the only pass/fail verdict that ever reaches a report. This is why FccResultDto (the raw measurement DTO on the wire) has no status field at all — see fcc_result_speed.

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 ⇐ 0 maps to status = OTHER, never to Infinity. 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_bytes absent or not positive, or tier_kbps null/0, maps to OTHER the same way a non-positive duration does — there is simply nothing to compare against.

    total_bytes = 0 is the case that looks measurable and is not, and it was reported as FAILED until it was fixed. A device can answer DiagnosticsState = Completed with 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 gives 0 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 is OTHER for the same reason CROSS_TALK is not a failure, and it is also what the ACS’s own retry predicate always answered: FccSpeedPredicate.speedKbps returns null for total_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_TALK is 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 against speedSuccessPercent in 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:

  1. Cross-talk first. wan_usage_dl_kbps >= thresholdDlKbps, or (thresholdUlKbps is set and) wan_usage_ul_kbps >= thresholdUlKbps → CROSS_TALK, regardless of whether the iteration would otherwise have been measurable.

  2. Unmeasurable second. Only checked once cross-talk has been ruled out: duration_ms ⇐ 0, or total_bytes is null or ⇐ 0, or tier_kbps is null/0 → OTHER.

  3. The speed comparison last. Only reached when the iteration is both not cross-talk and measurable: speed_kbps >= tier_kbps * speedSuccessPercent / 100.0 → SUCCESS, else FAILED. speedSuccessPercent falls 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:

  1. Cross-talk first → CROSS_TALK, regardless of whether the iteration would otherwise have been measurable.

  2. Unmeasurable second: avg_ms == null or success_count == null → OTHER.

  3. The criterion last → SUCCESS or FAILED.

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.

success_count == 0 is FAILED, not OTHER.

"Not measurable" is avgMs == null || successCount == null, and nothing else. A success_count of zero means every packet was lost, which is a genuine failure and the worst outcome a ping has. Confusing the two would move the most severe result a campaign can produce into OTHER, where no report counts it.

This is also why the success_count > 0 half of the criterion is not redundant next to the average comparison: a ping that lost every packet reports no average at all, and an absent average must never be read as a fast one.

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

fcc-messaging-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.

fcc-stop-drain-sequence