7.9 KiB
CtrlDialer State Machine
States
| State | Description |
|---|---|
| (none) | Router has no entry in the states map. Either unknown, already connected, or no matching endpoints. |
| NeedsDial | Router needs a connection. Sitting on the retry queue with a scheduled nextDial time. |
| Dialing | A dial attempt has been submitted to the worker pool and is in progress. |
| Connected | Dial succeeded. Remains tracked until the fast-failure check confirms the connection survived past FastFailureWindow. |
Address Rotation
Each routerDialState tracks a list of ctrl channel listener addresses and a current
index (addrIndex). When a router advertises multiple addresses, the dialer rotates
through them on each failure so that an unreachable address does not block attempts to
the others.
- On dial failure (including pool-full):
addrIndexadvances to the next address. - On normal disconnect (connection survived past
FastFailureWindow):addrIndexresets to 0 so the next dial cycle starts from the first address. - On evaluate: the address list is refreshed from the datastore and
addrIndexis clamped if the list shrank.
State Transitions
┌─────────────────────────────────────────────────┐
│ │
▼ │
┌──────┐ ┌───────────┐ ┌─────────┐ ┌───────────┐ ┌──────┴─────┐
│(none)│───▶│ NeedsDial │───▶│ Dialing │───▶│ Connected │───▶│ (removed) │
└──────┘ └───────────┘ └─────────┘ └───────────┘ └────────────┘
▲ │ │ │
│ │ │ │
│ └───────────┘ │
│ dial failed / │
│ pool full │
│ │
└────────────────────────────────┘
connection lost (normal or fast failure)
Triggers and Actions
Entry into tracking — (none) -> NeedsDial
A routerDialState is created and added to the states map when any of these occur
and the router has matching ctrl channel listener endpoints:
| Trigger | Source |
|---|---|
routerDisconnectedEvent |
Controller-initiated channel close handler, or RouterDisconnected presence callback |
routerUpdatedEvent |
Router entity updated or created in the datastore |
scan() |
Initial scan at startup, or periodic hourly scan |
New entries from scan() have their nextDial spread across a time window (50ms apart)
to avoid thundering herd on controller startup.
NeedsDial -> Dialing
When evaluateRetryQueue() pops a state whose nextDial has arrived,
evaluateDialState() verifies the router still needs a dial and submits the work
to the dialPool via QueueOrError(). The dialActive atomic flag prevents
duplicate submissions.
Dialing -> NeedsDial (dial failed)
When doDial() completes with an error, a dialResultEvent is sent.
The event handler calls dialFailed(), which applies exponential backoff with jitter:
factor = RetryBackoffFactor + rand(-0.5, 0.5) (clamped to min 1.0)
retryDelay = retryDelay * factor
retryDelay = clamp(retryDelay, MinRetryInterval, MaxRetryInterval)
nextDial = now + retryDelay
addrIndex = (addrIndex + 1) % len(addresses)
The address is rotated so the next attempt tries a different ctrl channel listener. The state is pushed back onto the retry queue.
If the pool is full (QueueOrError returns error), the same backoff is applied
without ever entering Dialing.
Dialing -> Connected (dial succeeded)
When doDial() completes with no error, a dialResultEvent is sent.
The event handler calls dialSucceeded(), which:
- Sets status to Connected and records
connectedAt - Does not reset
retryDelay(preserved for fast-failure detection) - Schedules a re-check at
now + FastFailureWindowon the retry queue
Connected -> (removed) (connection survived)
When evaluateDialState() processes a Connected state after FastFailureWindow
has elapsed and finds the router is still connected via GetConnectedRouter():
- Resets
retryDelayanddialAttemptsto zero - Removes the entry from the
statesmap
Connected -> NeedsDial (fast failure)
If a routerDisconnectedEvent arrives while in Connected state and
time.Since(connectedAt) < FastFailureWindow, connectionLost() treats this as
a fast failure and calls dialFailed() — continuing the backoff progression rather
than resetting it. This prevents rapid-fire dial cycles when a router keeps
accepting TCP but immediately rejecting the channel.
Alternatively, if evaluateDialState() finds the router is no longer connected
after the FastFailureWindow check, it calls connectionLost() which applies the
same logic.
Connected -> NeedsDial (normal disconnect)
If connectionLost() is called and either:
- The state is not Connected, or
time.Since(connectedAt) >= FastFailureWindow
Then the backoff is fully reset (retryDelay = 0), the address index is reset to 0,
and nextDial is set to now for an immediate retry. The assumption is that a
long-lived connection dropping is a transient network issue, not a persistent rejection.
Any state -> (removed) (router deleted)
A routerDeletedEvent unconditionally removes the entry from the states map.
Any state -> (removed) (no endpoints)
If evaluateDialState() or routerUpdatedEvent finds that getRouterIdEndpointsNeedingDial()
returns no addresses (router disabled, connected elsewhere, or no matching listener groups),
the entry is removed from the states map.
External connection — routerConnectedEvent
When a router connects via its own outbound dial (not controller-initiated), the
routerConnectedEvent resets the state to Connected with zeroed backoff. The entry
stays in the map briefly until the next evaluateDialState() confirms it is connected
and removes it.
Configuration
| Field | Default | Description |
|---|---|---|
MinRetryInterval |
1s | Floor for backoff delay |
MaxRetryInterval |
5m | Ceiling for backoff delay |
RetryBackoffFactor |
1.5 | Multiplier per failure (jittered +/- 0.5) |
FastFailureWindow |
5s | Connections dying within this window trigger backoff instead of immediate retry |
DialDelay |
30s | Initial delay before the dialer starts after controller boot |