Skip to content

feat: rewrite fixed_fallback with background goroutine for independent retry and zero-latency switchback#1009

Open
itoywh wants to merge 28 commits into
daeuniverse:mainfrom
itoywh:pr1-fixed-fallback
Open

feat: rewrite fixed_fallback with background goroutine for independent retry and zero-latency switchback#1009
itoywh wants to merge 28 commits into
daeuniverse:mainfrom
itoywh:pr1-fixed-fallback

Conversation

@itoywh

@itoywh itoywh commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Problem

The existing fixed() dialer policy is all-or-nothing: when the configured node dies, traffic either fails immediately (if no fallback) or falls back without any retry window. The original fixed_fallback() (this PR's initial version) improved this but still had three major flaws:

  1. Traffic-driven retries: Retry counting depends on natural traffic passing through Select() to compute elapsed/retryCount. If the dead node has no traffic, retries never advance and fallback never happens.
  2. First timeout window wasted: After node death, up to one full timeout window's worth of traffic hits the dead node before fallback.
  3. Health check dead, but retry unaware: Health check confirms node dead, but fixed_fallback state doesn't update — must wait for natural traffic.

Solution: v2 — Two-Layer Architecture

The rewrite introduces a background goroutine that independently drives retries, completely decoupled from natural traffic.

How It Works

                    .-- MustGetAlive=true  --- Return fixed node normally
                    |
  Select() ---------+                                    Natural Traffic
                    |                                       .
                    .-- MustGetAlive=false --- . goto doFallback . Backup node
                                               |
                                               +--- Start background goroutine (if not running)
                                               |     |
                                               |     +--- Fire immediate probes (NotifyCheckTcp/DnsUdp)
                                               |     |    . immediate probe counts as first retry (retryCount=1)
                                               |     |    . recovers brief transient failures (~300ms) instantly
                                               |     |
                                               |     +--- ticker every timeout ---+--- Probe OK . deadSince=0
                                               |     |                            .   Fail . retryCount++
                                               |     |                                          .   .retries . permanent fallback
                                               |     |
                                               |     .--- Fire NotifyCheckTcp/DnsUdp probes each tick
                                               |
                                               .--- Subsequent Select() . deadSince!=0 . goto doFallback

Two-Layer Architecture

Layer Responsibility Sends traffic to dead node?
Natural Traffic (Select()) Fallback immediately on dead; switch back immediately on alive No (first byte goes to backup)
Background Goroutine Independent ticker-driven retry cycle; fires immediate probe on start, then periodic probes Yes, probe-only (health check requests)

Four Key Event Flows

Node Go Dead (two paths)

Path A . Health check detects death:
  connectivity_check: Alive=false
    . aliveTransitionCallback fires
    . CompareAndSwap(false,true)
    . start background goroutine  . no traffic needed!

Path B . Select() discovers death:
  MustGetAlive=false, deadSince==0
    . record deadSince timestamp
    . start goroutine if not already running
    . goto doFallback  . immediately!

Natural Traffic During Death

Select() . MustGetAlive=false . deadSince!=0 . goto doFallback . return backup node

Zero traffic wasted on dead node. Natural traffic does NOT participate in retry counting.

Background Retry Probing

Goroutine starts:
  . Fire immediate NotifyCheckTcp() + NotifyCheckDnsUdp() probes
  . retryCount = 1 (immediate probe counts as first retry)

Ticker fires (e.g. 3s later):
  . MustGetAlive?
       +--- true  . deadSince=0, retryCount=0 . return (goroutine exits)
       .   false
            +--- retryCount >= retries (from immediate reset) . permanent fallback . return
            .   retryCount++ . fire probes . wait next tick

The immediate probe on goroutine start eliminates the first-ticker wait for transient failures. NotifyCheckTcp() and NotifyCheckDnsUdp() are also fired each subsequent tick.

Node Revive

Traffic/probe succeeds . MustGetAlive=true
  +--- Goroutine path . deadSince=0, retryCount=0 . return
  .   Select() path . deadSince=0, retryCount=0 . return fixed node

Zero-latency switch back: recovery within probe RTT (~150ms) for brief glitches, or within ticker cycle for sustained failures.

Retry Counting Semantics

retries Immediate probe Ticker probes Total probes Recovery window
0 None None 0 Relies on periodic health check (M1)
1 1 (counts as 1st) 0 (tick exits immediately) 1 ~150ms
3 1 (counts as 1st) 2 (ticks 1-2) 3 ~6s (3s x 2 ticks)

The immediate probe counts toward the retries budget, so retries=3 means immediate + 2 tickers = 3 total probes.

Key Implementation Details

  • aliveTransitionCallback: Health check on dead transition automatically starts the goroutine . no traffic required
  • deadSince Timestamp: Records when death was first observed, distinguishing first vs subsequent Select()
  • CompareAndSwap: Ensures exactly one goroutine runs
  • Probe Cooldown: timeout below 2s clamped to 2s with WARN log, prevents probe storm
  • Immediate Probe: NotifyCheckTcp fires before first ticker tick, eliminating 3s first-probe latency
  • NotifyCheckTcp 2s Cooldown: Built-in CAS rate limiting prevents probe storms from concurrent callers
  • Mutex protection: Retry state (deadSince + retryCount) protected by single sync.Mutex for atomic multi-field reads
  • State Cleanup on Revive: MustGetAlive=true clears both deadSince and retryCount

Configuration

Basic: 3s x 3 retry, random fallback

group {
  name 'my-group'
  policy 'fixed_fallback(0, 3s, 3, random)'
}

Behavior: Always use dialer[0]. Dies -> natural traffic -> backup; goroutine probes immediately, then 3s x 2; if all fail, goroutine exits; node recovers -> instant switchback.

Aggressive: Quick fallback

policy 'fixed_fallback(1, 5s, 2, min_moving_avg)'

Behavior: Prefer dialer[1]. Dies -> immediate fallback, goroutine immediate probe + 1 ticker retry.

Conservative: Long retry window

policy 'fixed_fallback(0, 60s, 5)'

Behavior: Prefer dialer[0]. Goroutine immediate probe + 4 ticker retries = ~4 minutes before giving up.

Minimal: Safety net

policy 'fixed_fallback(0)'

Behavior: Same as fixed(0) with safety net . defaults timeout=3s, retries=3, fallback=min_moving_avg.

Parameter Reference

Param Position Default Description
index 1st (required) - Dialer index (0-based)
timeout 2nd 3s Retry interval. Below 2s clamped with WARN. Supports ms/s/m.
retries 3rd 3 Max retries. 0 = no probes, rely on periodic health check.
fallback_policy 4th min_moving_avg Policy after exhaust: random, min, min_moving_avg, min_avg10

Benefits vs Original Approach

Aspect Original (v1) Improved (v2)
Retry driver Natural traffic only Background goroutine (independent)
Dead->fallback delay Up to 1 timeout window Zero (immediate)
First probe latency 1 full timeout window (e.g. 3s) Immediate (~150ms, instant probe before ticker)
Dead->start retry Wait for traffic Immediate (health check or traffic)
Retry counting Traffic-dependent Immediate probe counts as first retry; clean 1+2=3
Probe storm protection None 2s minimum + NotifyCheckTcp 2s cooldown CAS
State access Non-atomic atomic.Int64 Mutex-protected single struct

Changes Summary

  • Select(): Natural traffic always falls back immediately on dead node, no timeout window wasted
  • Background goroutine: Independent ticker drives retry cycle, fires NotifyCheck probes
  • Immediate probe: NotifyCheckTcp fires before first ticker tick, recovers transient failures instantly
  • Retry counting: Immediate probe counts as retry 1; retries=N = immediate + (N-1) tickers
  • aliveTransitionCallback: Health check dead transition starts goroutine without waiting for traffic
  • 2s minimum: timeout clamped to minimum 2s with WARN log
  • Mutex state: deadSince + retryCount protected under single sync.Mutex
  • Node operational logging: WARN on DEAD, INFO on ALIVE (merged from feat: add operational logs for node alive/dead transitions #1020)
  • DNS-UDP false positive fix: Redirect DNS-UDP collection to TCP when no udp_check_dns configured
  • gofmt + DRY + tests: Clean code, deduplicated IP-family check, 7 unit tests
  • Docs: Flow diagram, two-layer architecture, bilingual (zh/en) documentation

Closes #1020, Closes #1022

@itoywh itoywh requested a review from a team as a code owner June 13, 2026 10:59
@itoywh itoywh changed the title feat: enhance fixed_fallback with timeout unit, dynamic policy, and proper latency tracking feat: upgrade fixed dialer mode with disaster-recovery fallback, timeout/retry, and auto-switchback Jun 13, 2026
@itoywh itoywh force-pushed the pr1-fixed-fallback branch from 70b09f3 to 38e4373 Compare June 14, 2026 03:47
@itoywh

itoywh commented Jun 14, 2026

Copy link
Copy Markdown
Contributor Author

Hi maintainers, I've rebased this PR onto the latest main (as of today). This enhancement addresses a real pain point — when a fixed dialer goes down, the original fixed_fallback has no actual fallback mechanism (latency tracking is broken due to AliveDialerSet being created with FixedWithFallback policy). With this PR, the fixed dialer properly falls back and auto-recovers when healed.

I've been running this in production for a while and it works reliably. Would appreciate a review when you have time. Thanks! 🙏

@itoywh

itoywh commented Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

Testing update from v2.0-custom deployment

I've been running this PR's logic (ported to v2.0.0rc1) on a production-like environment (192.168.2.4, x86_64) since June 15. During testing I found and fixed 3 additional issues that are also applicable to the v1.x version in this PR:

1. Sub-second timeout precision (critical)

time.Now().Unix() returns seconds — any sub-second timeout (e.g. fixed_fallback(0, 500ms, 3)) gets truncated to 0, making retries fire instantly instead of after the intended delay. Fix: use UnixNano().

// Before (broken for sub-second timeouts)
elapsed := time.Now().Unix() - deadSince.Load()

// After
elapsed := time.Now().UnixNano() - deadSince

2. Non-atomic multi-field read race

fixedFallbackDeadSince and fixedFallbackRetryCount were separate atomic.Int64 fields. A concurrent goroutine could read deadSince from one state and retryCount from another, corrupting the retry decision. Fix: wrap both fields under a single sync.Mutex.

// Before (race-prone)
fixedFallbackDeadSince atomic.Int64
fixedFallbackRetryCount atomic.Int64

// After (atomic across both fields)
fixedFallbackMu sync.Mutex
fixedFallbackDeadSince int64
fixedFallbackRetryCount int

3. Emergency probes should fire on every timeout tick

Original logic only fired emergency probes when retries < maxRetries, skipping them after retries were exhausted. Fix: always fire emergency probes on timeout ticks — this allows the node to recover even after all retries are used up.

These improvements have been battle-tested with real traffic for 2+ days. I can push them to this PR branch if the maintainers are interested.

itoywh pushed a commit to itoywh/dae that referenced this pull request Jun 18, 2026
…, and recovery steps

Records:
- Latest deployed version: v2.0-custom @ 151de49
- GitHub PR status (daeuniverse#1009, daeuniverse#1011, daeuniverse#1016)
- 2.4 server fault timeline (2026-06-18 10:11 ~ 11:00)
- Root cause analysis (config parse error + tail timeout 5s)
- Known PR daeuniverse#1016 limitation (same-config reload still uses staged handoff)
- Configuration pitfalls (Chinese comments cause token error)
- Deployment and recovery commands
LiYang added 15 commits June 19, 2026 00:30
…ilover

Adds DialerSelectionPolicy_FixedWithFallback (fixed_fallback in config),
which behaves like fixed(n) but tracks the fixed dialer's alive state:

- When the fixed dialer is alive: always select it (same as fixed(n))
- When the fixed dialer is dead/backoff: fall back to min_moving_avg
  among all alive dialers in the group
- When the fixed dialer revives (periodic health check restores it):
  traffic automatically returns to the fixed dialer

Unlike fixed() which ignores alive state entirely, fixed_fallback()
requires AliveDialerSet tracking and participates in the health check
lifecycle. This gives users the ability to pin traffic to a preferred
node while still having a safety net when that node goes down.

Related: daeuniverse#1005
syntax: fixed_fallback(n, timeout_seconds, max_retries)
- timeout: how long to wait between retries when node is dead (default 3s)
- retries: how many retries before falling back to min_moving_avg (default 3)

matching passwall2's autoswitch model: connect_timeout + retry_num
…ging

- parseDurationWithUnit: supports 500ms, 5s, 2m, or bare number (seconds)
- logFixedFallback: state-deduplicated, rate-limited logging with log levels
  - WARN: fixed dialer dead, starting retry
  - INFO: retry N/M step (with retry count)
  - WARN: retries exhausted, falling back to min_moving_avg
  - INFO: fixed dialer recovered, traffic returned
- 10s minimum interval between logs; same mark=no duplicate
- Add FallbackPolicy field to DialerSelectionPolicy struct
- Parse optional 4th param: fixed_fallback(n, timeout, retries, policy)
  Supported fallback policies: random, min_moving_avg, min_last_latency, min_avg10
  Default: min_moving_avg (backward compatible)
- Update _select() to use policy.FallbackPolicy instead of hardcoded min_moving_avg
- Add parsePolicyName() helper to map policy name strings
- Update example.dae documentation
- Timeout param now supports unit suffix: 500ms, 5s, 2m (from previous commit)
…cy tracking

Previously, AliveDialerSet for fixed_fallback groups was created with
FixedWithFallback policy, which caused NotifyLatencyChange to skip
latency tracking (FixedWithFallback case only updates alive status).

This meant:
- sortingLatency was always 0 for all dialers
- GetMinLatency() returned arbitrary first alive dialer
- check_tolerance had no effect (no latency data to compare)

Fix: when policy is FixedWithFallback, create the AliveDialerSet
with FallbackPolicy instead. The fixed-node path (fixed dialer alive)
bypasses the set entirely, so this change only affects the fallback
path where proper latency-based selection actually matters.

With this fix, fallback selection now correctly:
- Tracks moving average / last latency / avg10 per dialer
- Respects check_tolerance when comparing dialers
- Returns the truly optimal node instead of first alive
Previously, the fallback path always called GetMinLatency() regardless
of the fallback policy, which is wrong for random (should pick a
random alive dialer, not the min-latency one).

Add a branch in the fallback path:
- Random: collect all alive dialers, pick one at random
- min_*: use GetMinLatency() as before (now with proper latency
  tracking thanks to the FallbackPolicy AliveDialerSet fix)
The inner switch block was missing its closing brace for the
case FixedWithFallback block, causing 'unexpected keyword case'
at the next case statement.
The closing brace for case FixedWithFallback and the preceding
return statement had incorrect indentation, causing the compiler
to misinterpret the block structure.
In Go, case blocks don't need closing braces - they are delimited
by the next case or the outer switch's closing brace. The extra }
was closing the entire switch prematurely.
- Add WARN log when node becomes DEAD in markUnavailableInternal
- Add INFO log when node becomes ALIVE in markAvailable
- Logs include node name and network type for easy debugging
- Helps monitor node health and fixed_fallback behavior in production
- Run gofmt on dialer_selection_policy.go (struct field align, blank line)
- Run gofmt on connectivity_check.go (latency field align)
- DRY: collapse shouldSkipTcp6Probes + shouldSkipUdp6Probes into one
  shouldSkipIpFamily6 (identical logic, different config keys)
- Add unit tests for FixedWithFallback policy:
  * TestDialerGroup_Select_FixedWithFallback_Alive
  * TestDialerGroup_Select_FixedWithFallback_DeadFallback
  * TestDialerGroup_Select_FixedWithFallback_ZeroRetriesNoFallback
  * TestDialerGroup_Select_FixedWithFallback_Recovery
  * TestDialerGroup_Select_FixedWithFallback_RetryTimeoutBehavior
LiYang added 2 commits June 19, 2026 21:30
…is configured

When udp_check_dns is not configured, DNS-UDP collections (IdxDnsUdp4/6)
are never probed by the health check background loop. Their Alive flag
stays true forever (initial value from newCollection()).

This false-positive 'alive' breaks the fixed_fallback retry state machine:
- DNS resolution calls Select(udp4) first → MustGetAlive=true → resets
  fixedFallbackDeadSince=0 and fixedFallbackRetryCount=0
- The next Select(tcp4) has to start the timeout from scratch
- This repeats every DNS resolution cycle, so retryCount never advances
  and fallback never triggers.

Fix: redirect DNS-UDP collection lookups to the same IP version's TCP
collection when no udp_check_dns is configured. DNS-UDP and TCP share
the same (correct) Alive value, eliminating the spurious reset.
…e limit, log final retry

Backport 3 fixes from v2.0-custom to pr1-fixed-fallback:

1. Dedup guard: add fixedFallbackLastRetryNano to prevent tcp4/tcp6 from
   concurrently advancing the retry counter. Require FixedFallbackTimeout/2
   between increments.
2. Rate limit removal: remove 10s tryDoRateLimitedAction from logFixedFallback
   that was suppressing retry step logs (retries happen every 3s with
   default timeout, rate limit was 10s).
3. Retry 3 log: move retry log before the 'if newRetries < maxRetries' check
   so the last retry (e.g. retry 3/3) is logged before fallback.
4. Emergency probes: fire NotifyCheckTcp/NotifyCheckDnsUdp on every timeout
   tick to give the node a resuscitation chance.
@itoywh itoywh changed the title feat: upgrade fixed dialer mode with disaster-recovery fallback, timeout/retry, and auto-switchback feat: rewrite fixed_fallback with background goroutine for independent retry and zero-latency switchback Jun 22, 2026
LiYang added 2 commits June 22, 2026 20:13
…ent retry

- Add background goroutine (runFixedFallbackRetry) with independent ticker,
  completely decoupled from natural traffic
- Natural traffic falls back immediately on dead node (no timeout window wasted)
- aliveTransitionCallback starts goroutine on health check dead detection
  (no traffic required to begin retries)
- Mutex-protected retry state (deadSince + retryCount) for atomic multi-field reads
- timeout clamped to minimum 2s with WARN log to prevent probe storm
- Probes (NotifyCheckTcp/NotifyCheckDnsUdp) fire on each goroutine tick

Previously: traffic-driven retry counting via Select() elapsed checks.
This caused delays when no traffic was flowing through the dead node.
- Add Dialer Selection Policies reference table
- Add fixed_fallback section with syntax, parameters, two-layer architecture
- Add workflow diagram and configuration example
- Update Features list to mention fixed_fallback
@ppdragon16

ppdragon16 commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

我觉得其实应该引入dae-next那样的priority的概念,比如:

  mix {
    filter: subtag(ack) && name(keyword:'jui_v6') [priority: '0,2(,100ms),1(100ms,200ms)']
    filter: subtag(evo) && name(keyword:'_v6') [priority: '1']
    filter: subtag(llg) && name(keyword:'Japan 0')
    policy: min_moving_avg(5s, 0.3)
  }

可以自由搭配出各种效果。

LiYang added 2 commits June 23, 2026 09:21
- Add timeout guard in aliveBackground: if probes take longer than
  cycle+5s, log WARN and continue cycling instead of blocking forever
- Add debug log when probe is skipped due to empty AliveDialerSet
  (helps diagnose silent probe gaps)
Point 1: retries=0 immediate fallback
- Add early return in runFixedFallbackRetry when FixedFallbackRetries <= 0
- Join retry-exhausted path with fallbackDone check in Select

Point 2: explicit fixedFallbackDone flag
- Add fixedFallbackDone bool to DialerGroup struct
- Use done flag instead of deadSince time-reversal trick
- Add resetFixedFallback() helper for all reset points

Point 3: MovingAverage concurrency confirmed safe
@itoywh

itoywh commented Jun 27, 2026

Copy link
Copy Markdown
Contributor Author

Updated with three review fixes:

  1. retries=0 immediate fallback: runFixedFallbackRetry now returns immediately instead of waiting for first ticker tick
  2. explicit fixedFallbackDone flag: replaces the deadSince time-reversal trick with a proper boolean flag checked by Select()
  3. MovingAverage concurrency: confirmed all read paths are under collectionFineMu.RLock(), no data race

Commit: e30d961

…cyName

Review point 5a: Change param range from 1-4 to 1-3 (4th param fallback_policy config was never used in practice). Remove the now-unused parsePolicyName function (v2.0-custom already had the 'min' alias at parsePolicyName).
@itoywh

itoywh commented Jun 27, 2026

Copy link
Copy Markdown
Contributor Author

Review fix: tighten param range to 1-3, drop unused parsePolicyName (points 5)

  • 5a: Changed param range from > 4 to > 3 (error message now correctly says expected 1-3 params). The 4th param (fallback_policy) was parsed but never configured in practice.
  • 5b: Removed the now-unused parsePolicyName function entirely (the v2.0-custom branch already had "min" as an alias for backward compat, so no breaking change there).

Point 4 (control_plane_core.go isBpfEjected/closeTail): not touched by this PR — these come from upstream baseline, unrelated to fixed_fallback changes.

@itoywh

itoywh commented Jun 27, 2026

Copy link
Copy Markdown
Contributor Author

Reverted the param range change — the 4th param (fallback_policy) IS used in production configs (e.g. fixed_fallback(3, 3s, 3, random)). My v2.0-custom deployment crashed because of this. Reverted the commit on both v2.0-custom and pr1-fixed-fallback.

The parsePolicyName function is back too with the "min" alias included for backward compatibility (in addition to "min_last_latency").

Remove unused fixedFallbackStopCh channel (never closed) and dead field fixedFallbackLastLogTime (never read/written).
@itoywh

itoywh commented Jun 27, 2026

Copy link
Copy Markdown
Contributor Author

Removed dead code (review points A & B)

  • Removed fixedFallbackStopCh field + all make(chan struct{}) inits + select case (channel was never closed, so the select path was dead code)
  • Removed unused fixedFallbackLastLogTime atomic.Int64 field (never read/written)

Kept fixedFallbackRunning and fixedFallbackLastLogMark which are actively used.

Point C (single-networkType monitoring blind spot): acknowledged as a design trade-off, not a bug — not modified.

@itoywh

itoywh commented Jun 28, 2026

Copy link
Copy Markdown
Contributor Author

Fixed error message consistency: expected 1-3 paramsexpected 1-4 params to match the actual validation range > 4. (Residue from the revert.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants