feat: rewrite fixed_fallback with background goroutine for independent retry and zero-latency switchback#1009
feat: rewrite fixed_fallback with background goroutine for independent retry and zero-latency switchback#1009itoywh wants to merge 28 commits into
Conversation
70b09f3 to
38e4373
Compare
|
Hi maintainers, I've rebased this PR onto the latest I've been running this in production for a while and it works reliably. Would appreciate a review when you have time. Thanks! 🙏 |
Testing update from v2.0-custom deploymentI'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)
// Before (broken for sub-second timeouts)
elapsed := time.Now().Unix() - deadSince.Load()
// After
elapsed := time.Now().UnixNano() - deadSince2. Non-atomic multi-field read race
// Before (race-prone)
fixedFallbackDeadSince atomic.Int64
fixedFallbackRetryCount atomic.Int64
// After (atomic across both fields)
fixedFallbackMu sync.Mutex
fixedFallbackDeadSince int64
fixedFallbackRetryCount int3. Emergency probes should fire on every timeout tickOriginal logic only fired emergency probes when 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. |
…, 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
…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
d5e6436 to
e44174c
Compare
…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.
…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
|
我觉得其实应该引入dae-next那样的priority的概念,比如: 可以自由搭配出各种效果。 |
- 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
|
Updated with three review fixes:
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).
|
Review fix: tighten param range to 1-3, drop unused parsePolicyName (points 5)
Point 4 (control_plane_core.go |
…arsePolicyName" This reverts commit 17e8aa4.
|
Reverted the param range change — the 4th param (fallback_policy) IS used in production configs (e.g. The |
Remove unused fixedFallbackStopCh channel (never closed) and dead field fixedFallbackLastLogTime (never read/written).
|
Removed dead code (review points A & B)
Kept Point C (single-networkType monitoring blind spot): acknowledged as a design trade-off, not a bug — not modified. |
|
Fixed error message consistency: |
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 originalfixed_fallback()(this PR's initial version) improved this but still had three major flaws:Solution: v2 — Two-Layer Architecture
The rewrite introduces a background goroutine that independently drives retries, completely decoupled from natural traffic.
How It Works
Two-Layer Architecture
Four Key Event Flows
Node Go Dead (two paths)
Natural Traffic During Death
Zero traffic wasted on dead node. Natural traffic does NOT participate in retry counting.
Background Retry Probing
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
Zero-latency switch back: recovery within probe RTT (~150ms) for brief glitches, or within ticker cycle for sustained failures.
Retry Counting Semantics
The immediate probe counts toward the retries budget, so retries=3 means immediate + 2 tickers = 3 total probes.
Key Implementation Details
Configuration
Basic: 3s x 3 retry, random fallback
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
Behavior: Prefer dialer[1]. Dies -> immediate fallback, goroutine immediate probe + 1 ticker retry.
Conservative: Long retry window
Behavior: Prefer dialer[0]. Goroutine immediate probe + 4 ticker retries = ~4 minutes before giving up.
Minimal: Safety net
Behavior: Same as fixed(0) with safety net . defaults timeout=3s, retries=3, fallback=min_moving_avg.
Parameter Reference
Benefits vs Original Approach
Changes Summary
Closes #1020, Closes #1022