This is an automated email from the ASF dual-hosted git repository.

masaori335 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficserver.git


The following commit(s) were added to refs/heads/master by this push:
     new 38076cc460 Bound the ring walk of ParentConsistentHash::selectParent 
(#13368)
38076cc460 is described below

commit 38076cc46040d88e10c7b7a3a95bde3b4f4e996e
Author: Masaori Koshiba <[email protected]>
AuthorDate: Fri Aug 7 09:54:42 2026 +0900

    Bound the ring walk of ParentConsistentHash::selectParent (#13368)
    
    * Bound the ring walk of ParentConsistentHash::selectParent
    
    When every parent in a consistent_hash pool is down, selectParent walked the
    whole hash ring taking the global host_status_rwlock on every hop. The ring
    holds 1024 replica nodes per parent (num_parents * 1024 nodes) and the
    chash_lookup() gate withholds wrap_around until the ring is traversed 
twice, so
    one all-down selection cost ~2 * num_parents * 1024 
HostStatus::getHostStatus()
    calls (~49k for 24 parents) -- inline ET_NET CPU that starved the loopback
    health probe and drove the VIP flap in inc-p1s2-260703.
    
    Track the distinct parents examined on each ring: skip the locked 
getHostStatus
    read for a parent already seen, and force wrap_around once every distinct 
parent
    has been rejected. The expensive locked read is now paid at most once per 
parent
    (O(num_parents)); the ring still advances ~O(N*logN) cheap, lock-free hops 
to
    reach every distinct parent. Selection order and the retry-window logic are
    unchanged.
    
    The seen-parent tracking is sized to num_parents (std::vector<bool>), not
    MAX_PARENTS: the parent.config parser does not cap num_parents at 
MAX_PARENTS, so
    a fixed [MAX_PARENTS] array would overflow the stack for pools larger than 
64.
    
    Add consistent_hash_ring_walk.test.py: an all-down 100-parent pool (marked 
down
    via HostStatus, >MAX_PARENTS on purpose) must report "getHostStatus calls: 
100",
    proving the walk reads each parent once instead of walking the full ring.
    
    * Keep the parent seen-flags out of the heap in selectParent
    
    selectParent() runs inline on ET_NET for every transaction, so the two
    std::vector<bool> allocations per call are pure overhead for what is a
    64-flag bitmap in the ordinary case.
    
    ts::LocalBuffer<bool, MAX_PARENTS> keeps both rings' flags on the stack
    (80 bytes each) and falls back to the heap only for a pool larger than
    MAX_PARENTS, which stays necessary because the parent.config parser does
    not cap num_parents.
---
 src/proxy/ParentConsistentHash.cc                  |  45 ++++++-
 .../parent_proxy/consistent_hash_ring_walk.test.py | 143 +++++++++++++++++++++
 2 files changed, 184 insertions(+), 4 deletions(-)

diff --git a/src/proxy/ParentConsistentHash.cc 
b/src/proxy/ParentConsistentHash.cc
index fad0d94e6f..29be36b511 100644
--- a/src/proxy/ParentConsistentHash.cc
+++ b/src/proxy/ParentConsistentHash.cc
@@ -20,10 +20,12 @@
   See the License for the specific language governing permissions and
   limitations under the License.
  */
+#include <algorithm>
 #include <atomic>
 #include "proxy/HostStatus.h"
 #include "proxy/ParentConsistentHash.h"
 #include "tscore/HashSip.h"
+#include "tsutil/LocalBuffer.h"
 
 namespace
 {
@@ -151,6 +153,19 @@ ParentConsistentHash::selectParent(bool first_call, 
ParentResult *result, Reques
   HostStatus                &pStatus   = HostStatus::instance();
   TSHostStatus               host_stat = TSHostStatus::TS_HOST_STATUS_INIT;
 
+  // Bound the all-down ring walk: read each distinct parent's status at most 
once.
+  // Stack resident up to MAX_PARENTS -- the parent.config parser does not cap 
num_parents, so a bigger
+  // pool falls back to the heap rather than overflowing the stack.
+  int const                          num_parents_in_ring[2] = 
{result->rec->num_parents, result->rec->num_secondary_parents};
+  ts::LocalBuffer<bool, MAX_PARENTS> 
primary_seen(num_parents_in_ring[PRIMARY]);
+  ts::LocalBuffer<bool, MAX_PARENTS> 
secondary_seen(num_parents_in_ring[SECONDARY]);
+  bool *const                        seen_parent[2]    = {primary_seen.data(), 
secondary_seen.data()};
+  int                                seen_count[2]     = {0, 0};
+  int                                host_status_calls = 0; // getHostStatus() 
calls this selection (== distinct parents examined)
+
+  std::fill_n(seen_parent[PRIMARY], num_parents_in_ring[PRIMARY], false);
+  std::fill_n(seen_parent[SECONDARY], num_parents_in_ring[SECONDARY], false);
+
   Dbg(dbg_ctl_parent_select, "ParentConsistentHash::%s(): Using a consistent 
hash parent selection strategy.", __func__);
   ink_assert(numParents(result) > 0 || result->rec->go_direct == true);
 
@@ -220,8 +235,14 @@ ParentConsistentHash::selectParent(bool first_call, 
ParentResult *result, Reques
   // 
----------------------------------------------------------------------------------------------------
 
   // didn't find a parent or the parent is marked unavailable or the parent is 
marked down
-  HostStatRec *hst = (pRec) ? pStatus.getHostStatus(pRec->hostname) : nullptr;
-  host_stat        = (hst) ? hst->status : TSHostStatus::TS_HOST_STATUS_UP;
+  HostStatRec *hst = nullptr;
+  if (pRec) {
+    hst = pStatus.getHostStatus(pRec->hostname);
+    host_status_calls++;
+    seen_parent[last_lookup][pRec->idx] = true;
+    seen_count[last_lookup]++;
+  }
+  host_stat = (hst) ? hst->status : TSHostStatus::TS_HOST_STATUS_UP;
   if (firstCall) {
     result->first_choice_status = host_stat;
   }
@@ -234,6 +255,8 @@ ParentConsistentHash::selectParent(bool first_call, 
ParentResult *result, Reques
     }
   }
   if (!pRec || (pRec && !pRec->available.load()) || host_stat == 
TS_HOST_STATUS_DOWN) {
+    // All-down walk: ~O(N*logN) lock-free ring hops to reach every distinct 
parent, but <= N (num_parents) getHostStatus reads (see
+    // seen_parent below).
     do {
       // check if the host is retryable.  It's retryable if the retry window 
has elapsed
       // and the global host status is HOST_STATUS_UP
@@ -312,7 +335,21 @@ ParentConsistentHash::selectParent(bool first_call, 
ParentResult *result, Reques
         Dbg(dbg_ctl_parent_select, "No available parents.");
         break;
       }
-      hst       = (pRec) ? pStatus.getHostStatus(pRec->hostname) : nullptr;
+      // Read each distinct parent's status at most once; force wrap when all 
are rejected.
+      if (pRec && seen_parent[last_lookup][pRec->idx]) {
+        if (seen_count[last_lookup] >= num_parents_in_ring[last_lookup]) {
+          wrap_around[last_lookup] = true;
+        }
+        host_stat = TS_HOST_STATUS_DOWN;
+        continue;
+      }
+      hst = nullptr;
+      if (pRec) {
+        hst = pStatus.getHostStatus(pRec->hostname);
+        host_status_calls++;
+        seen_parent[last_lookup][pRec->idx] = true;
+        seen_count[last_lookup]++;
+      }
       host_stat = (hst) ? hst->status : TSHostStatus::TS_HOST_STATUS_UP;
       // if the config ignore_self_detect is set to true and the host is down 
due to SELF_DETECT reason
       // ignore the down status and mark it as available
@@ -324,7 +361,7 @@ ParentConsistentHash::selectParent(bool first_call, 
ParentResult *result, Reques
     } while (!pRec || !pRec->available.load() || host_stat == 
TS_HOST_STATUS_DOWN);
   }
 
-  Dbg(dbg_ctl_parent_select, "Additional parent lookups: %d", lookups);
+  Dbg(dbg_ctl_parent_select, "Additional parent lookups: %d, getHostStatus 
calls: %d", lookups, host_status_calls);
 
   // 
----------------------------------------------------------------------------------------------------
   // Validate and return the final result.
diff --git a/tests/gold_tests/parent_proxy/consistent_hash_ring_walk.test.py 
b/tests/gold_tests/parent_proxy/consistent_hash_ring_walk.test.py
new file mode 100644
index 0000000000..a279c2c7c7
--- /dev/null
+++ b/tests/gold_tests/parent_proxy/consistent_hash_ring_walk.test.py
@@ -0,0 +1,143 @@
+"""
+Verify ParentConsistentHash::selectParent() does not waste work walking an
+all-down consistent_hash pool.
+
+A consistent-hash ring has 1024 replica nodes per parent (default
+ATSConsistentHash replicas, DEFAULT_PARENT_WEIGHT=1.0), so num_parents*1024 
ring
+nodes total. When every parent is down, selectParent must walk the ring to
+conclude so -- but it must read each parent's HostStatus (the global
+host_status_rwlock) at most ONCE, not once per replica node. selectParent 
tracks
+the distinct parents examined and stops as soon as all are rejected, so an
+all-down selection over N parents costs exactly N getHostStatus calls -- not 
the
+~2*N*1024 (two ring passes) the naive walk cost, which put ~8-13 ms of inline
+ET_NET CPU per request and wedged the vipd health probe in inc-p1s2-260703.
+
+Proven by the per-selection "getHostStatus calls: <N>" debug line
+(ParentConsistentHash.cc): it must equal the parent count, not a five-figure 
ring
+walk.
+
+Pool size > MAX_PARENTS (64) on purpose: num_parents is NOT capped at
+MAX_PARENTS by the config parser, so the per-selection "seen parent" tracking
+must be sized to the actual pool (a fixed [MAX_PARENTS] array would overflow).
+Running a >64-parent pool to completion (no crash, correct 502, bounded count)
+guards that sizing.
+
+no_dns_just_forward_to_parent=1 lets ATS skip origin resolution and go straight
+to parent selection, so this test needs no DNS server and no origin -- every
+parent is HostStatus-DOWN => PARENT_FAIL => 502 before any connect.
+"""
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+import os
+
+from ports import get_port
+
+Test.Summary = '''
+An all-down consistent_hash parent pool (marked down via HostStatus) drives
+selectParent to read each distinct parent's HostStatus exactly once
+(O(num_parents)), not once per ring replica node (~2 * num_parents * 1024).
+'''
+Test.ContinueOnFail = True
+
+# > MAX_PARENTS (64): exercises the pool-sized "seen parent" tracking and would
+# overflow a fixed [MAX_PARENTS] array. Distinct names matter -- 
consistent_hash
+# keys the ring on hostname, so identical names would collapse to one ring 
node.
+NUM_PARENTS = 100
+
+
+class ParentDownRingWalkTest:
+    """All parents HostStatus-DOWN => selectParent reads each parent once => 
502."""
+
+    parent_hostnames = [f'deadparent{i:03d}' for i in range(1, NUM_PARENTS + 
1)]
+
+    def __init__(self):
+        self._setupTS()
+
+    def _setupTS(self):
+        self.ts = Test.MakeATSProcess('ts', enable_cache=False)
+
+        # A dead (reserved-but-unbound) port per parent. They are never 
actually
+        # connected -- every parent is HostStatus-DOWN so selectParent returns
+        # PARENT_FAIL before any connect -- but parent.config needs a port.
+        self._parent_ports = []
+        for i in range(len(self.parent_hostnames)):
+            name = f'dead_parent_port_{i}'
+            get_port(self.ts, name)
+            self._parent_ports.append(getattr(self.ts.Variables, name))
+
+        self.ts.Disk.records_config.update(
+            {
+                # Enable only the parent_select debug ctl so the per-selection
+                # "getHostStatus calls: <N>" line is emitted.
+                'proxy.config.diags.debug.enabled': 1,
+                'proxy.config.diags.debug.tags': 'parent_select',
+                # Skip origin DNS: forward straight to parent selection. No DNS
+                # server / origin needed for this isolation.
+                'proxy.config.http.no_dns_just_forward_to_parent': 1,
+                # self_detect off: this test populates the HostStatus map via
+                # `traffic_ctl host down` below, not via self_detect (the
+                # deadparents never resolve to this box anyway).
+                'proxy.config.http.parent_proxy.fail_threshold': 10,
+                'proxy.config.http.parent_proxy.retry_time': 300,
+                'proxy.config.http.parent_proxy.self_detect': 0,
+                'proxy.config.url_remap.remap_required': 0,
+            })
+
+        # Consistent-hash pool of distinct parent hostnames, go_direct=false 
so an
+        # all-down pool yields 502 (not a direct-to-origin fallback).
+        self._parents = list(zip(self.parent_hostnames, self._parent_ports))
+        parent_list = ', '.join(f'{host}:{port}|1' for host, port in 
self._parents)
+        self.ts.Disk.parent_config.AddLine(
+            f'dest_domain=. parent="{parent_list}" round_robin=consistent_hash 
'
+            'go_direct=false parent_is_proxy=true')
+
+        # The all-down selection is a single findParent call. selectParent 
reads
+        # each distinct parent's HostStatus at most once and stops once all are
+        # rejected, so the per-selection "getHostStatus calls" count equals the
+        # parent count -- not the ~2*N*1024 five-figure ring walk it cost 
before.
+        self.ts.Disk.traffic_out.Content += Testers.ContainsExpression(
+            r'getHostStatus calls: %d\b' % NUM_PARENTS,
+            'selectParent read each distinct parent once (O(num_parents)), not 
the full ring.')
+        # It must NOT take the HostStatus lock once per ring replica node (the 
old
+        # bug reported a five-figure count).
+        self.ts.Disk.traffic_out.Content += Testers.ExcludesExpression(
+            r'getHostStatus calls: [0-9]{5}', 'selectParent must not read 
HostStatus once per ring replica node.')
+
+    def run(self):
+        traffic_ctl = os.path.join(self.ts.Variables.BINDIR, 'traffic_ctl')
+
+        # 1. Bring the whole parent pool DOWN via HostStatus (one RPC call).
+        #    Populates hosts_statuses (=> every getHostStatus takes the rwlock)
+        #    and forces host_stat==DOWN (=> the full ring walk, no early exit).
+        down = Test.AddTestRun('Mark the entire parent pool down via 
HostStatus')
+        down.Processes.Default.StartBefore(self.ts)
+        down.Processes.Default.Command = f'{traffic_ctl} host down ' + ' 
'.join(self.parent_hostnames)
+        down.Processes.Default.Env = self.ts.Env
+        down.Processes.Default.ReturnCode = 0
+
+        # 2. One request through the all-down pool -> bounded walk -> 502. A 
crash
+        #    here (e.g. seen-parent tracking overflowing on a >64 pool) fails 
the test.
+        load = Test.AddTestRun('Request through the all-down pool -> bounded 
selection -> 502')
+        load.MakeCurlCommand(
+            f'-s -o /dev/null -w "%{{http_code}}" --proxy 
127.0.0.1:{self.ts.Variables.port} http://example.com/ring-walk-probe',
+            ts=self.ts)
+        load.Processes.Default.Streams.stdout = 
Testers.ContainsExpression('502', 'All parents down => 502.')
+        load.Processes.Default.ReturnCode = 0
+        load.StillRunningAfter = self.ts
+
+
+ParentDownRingWalkTest().run()

Reply via email to