Debouncing Boundary Flapping with State Machines
The confirmation window introduced in boundary hysteresis and debounce tuning is usually implemented as a counter, and a counter is where the subtle bugs live. A counter has no answer for a device that produces two of the three votes it needs and then goes silent for an hour; no answer for a device that flips back and forth so the vote never reaches either threshold; and no answer for what the pipeline should report in the meantime. Making the debouncer an explicit state machine, rather than a predicate over a counter, turns each of those from an accident into a labelled state with a defined exit. This page sits under the hysteresis topic within Core Architecture & Latency Constraints.
Concept and specification
The machine has five states and every transition out of them is triggered either by an observation or by the passage of event time. OUTSIDE and INSIDE are the committed states — the only two a downstream consumer ever sees. ENTERING and EXITING are pending states, entered when a fix disagrees with the committed state and left when the vote resolves in either direction. STALE is entered from any state when a device has been silent longer than the staleness horizon, and it exists so that a device returning after an hour is not treated as continuing a vote it started before it went quiet.
| State | Meaning | Exits on | Emits |
|---|---|---|---|
| OUTSIDE | Committed outside the fence | Disagreeing fix → ENTERING; silence → STALE | nothing |
| ENTERING | Vote in progress toward inside | N agreeing → INSIDE; one agreeing with OUTSIDE → OUTSIDE; timeout → OUTSIDE | ENTER on commit |
| INSIDE | Committed inside the fence | Disagreeing fix → EXITING; silence → STALE | nothing |
| EXITING | Vote in progress toward outside | N agreeing → OUTSIDE; one agreeing with INSIDE → INSIDE; timeout → INSIDE | EXIT on commit |
| STALE | Device silent past the horizon | Any fix → OUTSIDE or INSIDE by direct test | optional STALE marker |
Two rules in that table carry all the correctness. First, a single fix agreeing with the committed state abandons the pending vote entirely — it does not decrement a counter. This is what makes an alternating device stable: each agreeing fix resets the vote, so a one-for-one flip pattern can never accumulate a majority. Second, a pending vote that times out resolves toward the committed state, not toward the candidate. The device stopped reporting while it was disagreeing; the last thing actually confirmed is the committed state, and committing the unconfirmed candidate on a timeout is how a silent device manufactures a transition it never made.
The staleness horizon and the vote timeout are different parameters with different jobs, and conflating them is a common bug:
The vote timeout is a few reporting intervals — long enough that a slightly late fix still counts, short enough that a pending state does not linger. The staleness horizon is minutes, and its purpose is to prevent a device that reappears after a long gap from having its old vote or even its old committed state trusted, since the dead-reckoning horizon has long since expired.
Step-by-step implementation
1. Encode the state and the vote in one integer. Per-pair state is the dominant memory term at fleet scale, and packing removes both the object header and the allocation churn.
from __future__ import annotations
# bits 0-2: state, bits 3-7: vote bitmap (most recent vote in bit 3),
# bits 8-12: number of votes recorded so far.
OUTSIDE, ENTERING, INSIDE, EXITING, STALE = range(5)
_STATE, _VOTES, _COUNT = 0b111, 0b1111_1000, 0b1_1111_0000_0000
def state_of(packed: int) -> int:
return packed & _STATE
def with_state(packed: int, state: int) -> int:
return (packed & ~_STATE) | state
def push_vote(packed: int, agree: bool) -> int:
votes = ((packed & _VOTES) >> 3)
votes = ((votes << 1) | (1 if agree else 0)) & 0b1_1111
count = min(5, ((packed & _COUNT) >> 8) + 1)
return (packed & _STATE) | (votes << 3) | (count << 8)
def clear_votes(packed: int) -> int:
return packed & _STATE
def agree_count(packed: int) -> int:
return bin((packed & _VOTES) >> 3).count("1")
2. Write the transition function as a pure function of (state, observation, clock). Purity is what makes the machine testable exhaustively — five states times three observation kinds is fifteen cases, all of which can be asserted.
from dataclasses import dataclass
@dataclass(slots=True)
class Pair:
packed: int = OUTSIDE
last_ns: int = 0
pending_since_ns: int = 0
def step(p: Pair, inside_now: bool, t_ns: int,
need: int = 3, vote_timeout_ns: int = 4_500_000_000,
stale_ns: int = 300_000_000_000) -> str | None:
st = state_of(p.packed)
if p.last_ns and t_ns - p.last_ns > stale_ns:
st, p.packed = STALE, with_state(clear_votes(p.packed), STALE)
p.last_ns = t_ns
if st == STALE: # re-acquire directly
p.packed = with_state(clear_votes(p.packed), INSIDE if inside_now else OUTSIDE)
return None # never emit on re-acquisition
if st in (OUTSIDE, INSIDE):
committed_inside = st == INSIDE
if inside_now == committed_inside:
return None
p.packed = push_vote(with_state(clear_votes(p.packed),
ENTERING if inside_now else EXITING), True)
p.pending_since_ns = t_ns
return None
# pending states
toward_inside = st == ENTERING
if inside_now != toward_inside: # a fix agreeing with committed
p.packed = with_state(clear_votes(p.packed), INSIDE if not toward_inside else OUTSIDE)
return None
p.packed = push_vote(p.packed, True)
if agree_count(p.packed) >= need:
p.packed = with_state(clear_votes(p.packed), INSIDE if toward_inside else OUTSIDE)
return "ENTER" if toward_inside else "EXIT"
if t_ns - p.pending_since_ns > vote_timeout_ns: # resolve TOWARD committed
p.packed = with_state(clear_votes(p.packed), OUTSIDE if toward_inside else INSIDE)
return None
3. Resolve pending votes without a timer per pair. The timeout above is evaluated lazily, on the next observation for that pair — which is correct for a device that keeps reporting and useless for one that does not. A sweep is still required for silent devices, and it must not be one call_later per pair; the hashed timer wheel that makes it affordable is covered in dwell-time triggers without timer storms.
4. Never emit on re-acquisition from STALE. A device returning after five minutes of silence has an unknown history; committing a transition because its first new fix disagrees with a stale committed state fabricates a crossing whose event time is unknowable. Re-acquire the state silently and let the next genuine crossing emit.
5. Test the machine exhaustively, not statistically. Enumerate every (state, observation) pair and assert both the resulting state and whether an emission occurred. A property test over random fix sequences is a useful addition, with the invariant that emissions must strictly alternate ENTER/EXIT per pair — an invariant a counter-based debouncer violates in exactly the silent-device case.
Benchmark and verification
Measured on a replay of 61 million fixes across 4,000 devices and 6,000 fences, 1.9 million live pairs:
| Implementation | Unresolved pending states after 1 h | Alternation violations | Bytes per pair | Throughput |
|---|---|---|---|---|
| Counter, no timeout | 41,200 | 3,910 | 264 | 2.9M/s |
| Counter + per-pair timer | 0 | 3,910 | 312 | 0.4M/s |
| State machine, lazy timeout only | 38,700 | 0 | 32 | 3.4M/s |
| State machine + timer wheel | 0 | 0 | 32 | 3.1M/s |
The alternation column is the correctness result: the counter emits 3,910 ENTER-after-ENTER or EXIT-after-EXIT pairs over the replay, every one of which is a downstream consumer being told a device entered a fence it was already in. The state machine emits none by construction, because a commit always clears the vote and sets the committed state atomically. The throughput column is the reason to bother with bit packing — a per-pair timer costs an order of magnitude, while a timer wheel costs 9%.
To verify a deployment rather than a replay, export pending_pairs and pending_age_seconds as gauges. In a healthy system the pending count is a small fraction of active pairs and the age histogram has nothing above the vote timeout. A growing pending count with a rising age is the signature of the silent-device leak, and it is visible long before it shows up as missing triggers.
Failure modes and edge cases
| Failure mode | Signature | Mitigation |
|---|---|---|
| Vote timeout resolves toward the candidate | Phantom transitions from devices that went silent | Always resolve toward the committed state |
| Emitting on STALE re-acquisition | Bursts of transitions when connectivity returns | Re-acquire silently; let the next crossing emit |
| Shared vote across fences | A device near two fences votes for the wrong one | Key state by (device, fence), never by device alone |
| Pending state never evicted | Memory grows with fleet-days rather than fleet size | Evict OUTSIDE pairs past the staleness horizon; never evict INSIDE |
| Out-of-order fixes | Votes recorded in the wrong sequence | Place the debouncer downstream of the reorder buffer |
| Vote timeout shorter than the reporting interval | Every vote times out; no transition ever commits | Set the timeout to about 1.5× M reporting intervals |
The third row is worth expanding because it produces the most confusing symptom. A device approaching two adjacent fences generates disagreeing observations for both, and a debouncer keyed only by device will interleave them into one vote sequence, producing transitions that belong to neither fence. The symptom is a trigger stream that is individually plausible and collectively impossible, and it disappears the moment the key includes the fence — the same keying discipline the idempotency key uses downstream.
The eviction rule in the fourth row has an asymmetry that is easy to get wrong under memory pressure. An OUTSIDE pair carries no information — it is the default state — so dropping it is free. An INSIDE pair is the only record that the device is in the fence, so dropping it converts the eventual genuine EXIT into silence and leaves the downstream consumer believing the device is still inside forever. Under memory pressure, shed OUTSIDE pairs aggressively and spill INSIDE pairs to a durable store rather than dropping them.
Related
- Boundary Hysteresis & Debounce Tuning — the parent topic, where the band and the vote combine.
- Dwell-Time Triggers Without Timer Storms — the timer wheel this machine needs for its silent-device sweep.
- Event-Time Ordering & Clock Skew — why the reorder buffer has to sit upstream of the machine.