Repository: patroni/patroni

Issue:
Patroni 4.0.5 Cascade Replication Configuration with Synchronous Standby Recovery Causes Write Blocking
### What happened?

Description

Scenario Configuration
Three-node Patroni cluster setup:
Node1, Node2, Node3 (primary)
Node2 configured as synchronous standby
Node1 tagged with replicatefrom: node2 (cascade replication)
Global Patroni configuration:
synchronous_mode: true
synchronous_node_count: 2

Steps to Reproduce
Shutdown Node2 server (hard stop)
Patroni detects Node2 failure, demotes it to asynchronous, and eventually removes it from the cluster
Node1 is promoted to synchronous standby for Node3 (primary)
Start Node2 server and Patroni service
Node1 rejoins the cluster and follows Node2 per tag configuration (cascade replication restored)
Node2 is re-established as synchronous standby for Node3

Issue: PostgreSQL primary (Node3) incorrectly configures both Node1 and Node2 as synchronous standbys, causing write transactions to block indefinitely
synchronous_standby_names = '2 (node1,node2)'
Expected Behavior
When Node1 is configured as a cascade standby of Node2, PostgreSQL primary should only consider Node2 as a synchronous standby to maintain consistency in the replication path. Synchronous replication configuration should reflect the actual replication topology:

Node3 (primary) → Node2 (synchronous standby) → Node1 (cascade standby)



### How can we reproduce it (as minimally and precisely as possible)?

Here's a **minimal, precise reproduction steps** section you can include in the GitHub issue to help developers replicate the problem:


**Reproduction Steps**  
1. **Initial Cluster Setup**  
   - Deploy a 3-node Patroni cluster (Node1, Node2, Node3) with PostgreSQL 14.10  
   - Configure etcd 3.5.21 as DCS  
   - Global Patroni configuration:  
     ```yaml  
     synchronous_mode: true  
     synchronous_node_count: 2  
     ```  
   - Node-specific configurations:  
     - Node3: No special tags (primary)  
     - Node2: No special tags (serves as initial synchronous standby)  
     - Node1: Add tag `replicatefrom: node2`  

2. **Verify Initial Topology**  
   - Confirm cluster is healthy with Node3 as primary  
   - Check `synchronous_standby_names` on Node3 shows Node2 as synchronous standby  
   - Validate replication flow:  
     ```  
     Node3 (primary) → Node2 (synchronous standby) → Node1 (cascade standby)  
     ```  

3. **Trigger Failure Scenario**  
   - **Step 1**: Stop Node2 server (hard shutdown, e.g., `systemctl stop patroni && poweroff`)  
   - **Wait** for Patroni to detect Node2 failure (monitor logs for demotion messages)  
   - **Verify**: Node1 is promoted to synchronous standby for Node3  
     - Check Node3 logs for:  
       ```log  
       INFO: Assigning synchronous standby status to ['node1']  
       ```  

4. **Recover Node2**  
   - **Step 2**: Start Node2 server  
   - **Step 3**: Start Patroni service on Node2 (`systemctl start patroni`)  
   - **Wait** for Node2 to rejoin the cluster and sync with Node3  

5. **Observe Failure**  
   - **Monitor Node3 logs** during Node2 recovery. Look for:  
     ```log  
     INFO: Assigning synchronous standby status to ['node1', 'node2']  
     ```  
   - **Trigger write operations** on Node3 (e.g., `INSERT INTO test VALUES (1);`)  
   - **Result**: Writes will block indefinitely due to incorrect synchronous standby configuration  


**Key Observations During Reproduction**  
- The issue **may not occur immediately** on the first try. Repeat steps 3-5 2-3 times if needed  
- Timing is critical:  
  - Faster Node2 recovery increases probability of misconfiguration  
  - Slower recovery may allow Patroni to self-correct  
- Check `pg_stat_replication` on Node3 during failure:  
  ```sql  
  SELECT application_name, sync_state FROM pg_stat_replication;  
  -- Expected: Only Node2 should be 'sync'  
  -- Actual: Both Node1 and Node2 may show as 'sync' during failure  
  ```  


This minimal reproduction focuses on the critical failure path while excluding unnecessary complexity. Let me know if you need further refinements!

### What did you expect to happen?

**Expected Behavior**  
When Node1 is configured as a **cascade standby** of Node2 (via `replicatefrom: node2`), PostgreSQL should **only consider Node2 as a synchronous standby** for Node3 (primary). The replication topology and synchronous configuration should reflect the actual data flow:  

```
Node3 (primary) → Node2 (synchronous standby) → Node1 (cascade standby)
```

**Specific Expectations**:  
1. **Synchronous Standby Configuration**:  
   - `synchronous_standby_names` on Node3 should **never include Node1** when Node1 is replicating from Node2  
   - Patroni should dynamically update synchronous standbys based on the **active replication topology**, not just node presence  

2. **Write Transaction Behavior**:  
   - Write transactions on Node3 should wait for confirmation only from Node2 (the direct synchronous standby)  
   - Node1's replication status should be irrelevant to transaction commit semantics, as it receives data via Node2  

3. **State Transition Logic**:  
   - When Node2 recovers and rejoins the cluster:  
     - Node1 should automatically resume replicating from Node2 (per `replicatefrom` tag)  
     - Patroni should **demote Node1 from synchronous status** on Node3  
     - Node3 should reconfigure `synchronous_standby_names` to include only Node2  

4. **Logging Consistency**:  
   - Node3 logs should show clean transitions between synchronous standbys:  
     ```log  
     INFO: Assigning synchronous standby status to ['node2']  # After Node2 recovery  
     ```  
   - No intermediate states where both Node1 and Node2 are incorrectly marked as synchronous standbys  


**Why This Matters**  
- **Data Consistency**: Synchronous replication ensures write transactions are durable on at least one standby before committing. Misconfigured synchronous standbys can violate this guarantee.  
- **Availability**: Write blocking occurs when the primary waits for acknowledgments from nodes that aren’t part of the direct replication path.  
- **Topology Integrity**: Patroni should accurately reflect the operational replication hierarchy in its metadata and PostgreSQL configuration.

### Patroni/PostgreSQL/DCS version

Patroni Version: 4.0.5
PostgreSQL Version: 14.10
DCS Type/Version: etcd 3.5.21
OS: Red Hat 1.23.4-1.el9 linux/amd64

### Patroni configuration file

```yaml
scope: patroni_cluster
namespace: /patroni/
name: node1

restapi:
  listen: 10.211.55.97:8008
  connect_address: 10.211.55.97:8008

etcd3:
  hosts: 10.211.55.98:2379,10.211.55.100:2379,10.211.55.101:2379

#zookeeper:
#  hosts: ['192.168.31.101:2181', '192.168.31.102:2181', '192.168.31.103:2181']

bootstrap:
  dcs:
    ttl: 30
    loop_wait: 10
    retry_timeout: 10
    maximum_lag_on_failover: 1048576
    maximum_lag_on_syncnode: -1
    primary_start_timeout: 300
    synchronous_mode: false
    synchronous_mode_strict: false
    failsafe_mode: true
    postgresql:
      use_pg_rewind: true
      parameters:
        wal_level: replica
        hot_standby: "on"
        max_connections: 100
        max_worker_processes: 8
        max_replication_slots: 10
        max_prepared_transactions: 0
        max_locks_per_transaction: 64
        wal_log_hints: "on"
        track_commit_timestamp: "off"
        wal_keep_size: 5000
        max_wal_senders: 10
        max_replication_slots: 10
        archive_mode: "off"
        archive_timeout: 1800s
        logging_collector: "on"

postgresql:
  listen: 10.211.55.97:5432
  connect_address: 10.211.55.97:5432
  data_dir: /data/pgsql
  bin_dir: /usr/local/pgsql14/bin

  authentication:
    replication:
      username: postgres
      password: postgres
    superuser:
      username: postgres
      password: postgres
    rewind:
      username: postgres
      password: postgres

  pg_hba:
  - local   all             all                                     trust
  - host    all             all             0.0.0.0/0               trust
  - host    all             all             ::1/128                 trust
  - local   replication     all                                     trust
  - host    replication     all             0.0.0.0/0               trust
  - host    replication     all             ::1/128                 trust

tags:
    nofailover: false
    noloadbalance: false
    clonefrom: false
    nosync: false
    replicatefrom: node2

log:
  level: DEBUG
  traceback_level: INFO
  dir: /data/hac

watchdog:
  mode: off
```

### patronictl show-config

```yaml
loop_wait: 10
maximum_lag_on_failover: 1048576
maximum_lag_on_syncnode: -1
postgresql:
  parameters:
    archive_mode: 'off'
    archive_timeout: 1800s
    hot_standby: 'on'
    max_connections: 100
    max_locks_per_transaction: 64
    max_prepared_transactions: 0
    max_replication_slots: 10
    max_wal_senders: 10
    max_worker_processes: 8
    track_commit_timestamp: 'off'
    wal_keep_size: 5000
    wal_level: replica
    wal_log_hints: 'on'
  use_pg_rewind: true
remove_data_directory_on_rewind_failure: true
retry_timeout: 3
synchronous_mode: true
synchronous_mode_strict: false
synchronous_node_count: 2
ttl: 30
```

### Patroni log files

```shell
2025-06-03 09:52:07,649 INFO: Lock owner: node3; I am node3
2025-06-03 09:52:07,656 INFO: Updating synchronous privilege temporarily from ['node2'] to []
2025-06-03 09:52:07,660 INFO: Assigning synchronous standby status to []
2025-06-03 09:52:07,776 INFO: no action. I am (node3), the leader with the lock
2025-06-03 09:52:17,663 INFO: no action. I am (node3), the leader with the lock
2025-06-03 09:52:27,664 INFO: no action. I am (node3), the leader with the lock
2025-06-03 09:52:37,655 INFO: no action. I am (node3), the leader with the lock
2025-06-03 09:52:47,650 INFO: Lock owner: node3; I am node3
2025-06-03 09:52:47,656 INFO: Assigning synchronous standby status to ['node1']
2025-06-03 09:52:49,775 INFO: Synchronous standby status assigned to ['node1']
2025-06-03 09:52:49,781 INFO: no action. I am (node3), the leader with the lock
2025-06-03 09:52:57,657 INFO: no action. I am (node3), the leader with the lock
2025-06-03 09:53:07,661 INFO: no action. I am (node3), the leader with the lock
2025-06-03 09:53:17,653 INFO: no action. I am (node3), the leader with the lock
2025-06-03 09:53:27,653 INFO: no action. I am (node3), the leader with the lock
2025-06-03 09:53:37,652 INFO: Lock owner: node3; I am node3
2025-06-03 09:53:37,659 INFO: Assigning synchronous standby status to ['node1', 'node2']
2025-06-03 09:53:39,784 INFO: Synchronous standby status assigned to ['node2']
2025-06-03 09:53:39,789 INFO: no action. I am (node3), the leader with the lock
```

### PostgreSQL log files

```shell
synchronous_standby_names = '2 (node1,node2)'
```

### Have you tried to use GitHub issue search?

- [x] Yes

### Anything else we need to know?

_No response_


Discussion:

Patch:
diff --git a/patroni/ha.py b/patroni/ha.py
index 5e95c4d28..11fddbde0 100644
--- a/patroni/ha.py
+++ b/patroni/ha.py
@@ -860,7 +860,7 @@ def _check_timeout(offset: float = 0) -> bool:
                                                                       voters=sync.voters,
                                                                       numsync=sync_state.numsync,
                                                                       sync=sync_state.sync,
-                                                                      numsync_confirmed=sync_state.numsync_confirmed,
+                                                                      numsync_confirmed=len(sync_state.sync_confirmed),
                                                                       active=sync_state.active,
                                                                       sync_wanted=sync_wanted,
                                                                       leader_wanted=self.state_handler.name):
@@ -906,7 +906,7 @@ def _process_multisync_replication(self) -> None:
 
         current_state = self.state_handler.sync_handler.current_state(self.cluster)
         picked = current_state.active
-        allow_promote = current_state.sync
+        allow_promote = current_state.sync_confirmed
         voters = CaseInsensitiveSet(sync.voters)
 
         if picked == voters and voters != allow_promote:
@@ -917,7 +917,7 @@ def _process_multisync_replication(self) -> None:
                 return logger.warning("Updating sync state failed")
             voters = CaseInsensitiveSet(sync.voters)
 
-        if picked == voters:
+        if picked == voters == current_state.sync and current_state.numsync == len(picked):
             return
 
         # update synchronous standby list in dcs temporarily to point to common nodes in current and picked
@@ -941,7 +941,7 @@ def _process_multisync_replication(self) -> None:
         if picked and picked != CaseInsensitiveSet('*') and allow_promote != picked:
             # Wait for PostgreSQL to enable synchronous mode and see if we can immediately set sync_standby
             time.sleep(2)
-            allow_promote = self.state_handler.sync_handler.current_state(self.cluster).sync
+            allow_promote = self.state_handler.sync_handler.current_state(self.cluster).sync_confirmed
 
         if allow_promote and allow_promote != sync_common:
             if self.dcs.write_sync_state(self.state_handler.name, allow_promote, 0, version=sync.version):
diff --git a/patroni/postgresql/sync.py b/patroni/postgresql/sync.py
index 2fc2aaa4e..31f34e33b 100644
--- a/patroni/postgresql/sync.py
+++ b/patroni/postgresql/sync.py
@@ -7,7 +7,7 @@
 
 from .. import global_config
 from ..collections import CaseInsensitiveDict, CaseInsensitiveSet
-from ..dcs import Cluster
+from ..dcs import Cluster, Member
 from ..psycopg import quote_ident
 from .misc import PostgresqlState
 
@@ -167,17 +167,16 @@ class _SyncState(NamedTuple):
     :ivar sync_type: possible values: ``off``, ``priority``, ``quorum``
     :ivar numsync: how many nodes are required to be synchronous (according to ``synchronous_standby_names``).
                    Is ``0`` if ``synchronous_standby_names`` value is invalid or contains ``*``.
-    :ivar numsync_confirmed: how many nodes are known to be synchronous according to the ``pg_stat_replication`` view.
-                             Only nodes that caught up with the :attr:`SyncHandler._primary_flush_lsn` are counted.
-    :ivar sync: collection of synchronous node names. In case of quorum commit all nodes listed
-                in ``synchronous_standby_names``, otherwise nodes that are confirmed to be synchronous according
-                to the ``pg_stat_replication`` view.
+    :ivar sync: collection of synchronous node names from ``synchronous_standby_names``.
+    :ivar sync_confirmed: collection of synchronous node names from ``synchronous_standby_names`` that are
+                          confirmed to be synchronous according to the ``pg_stat_replication`` view.
+                          Only nodes that caught up with the :attr:`SyncHandler._primary_flush_lsn` are counted.
     :ivar active: collection of node names that are streaming and have no restrictions to become synchronous.
     """
     sync_type: str
     numsync: int
-    numsync_confirmed: int
     sync: CaseInsensitiveSet
+    sync_confirmed: CaseInsensitiveSet
     active: CaseInsensitiveSet
 
 
@@ -229,8 +228,11 @@ def __init__(self, postgresql: 'Postgresql', cluster: Cluster) -> None:
             'remote_write': 'write'
         }.get(postgresql.synchronous_commit(), 'flush') + '_lsn'
 
-        members = CaseInsensitiveDict({m.name: m for m in cluster.members if m.name.lower() != postgresql.name.lower()})
-        for row in postgresql.pg_stat_replication():
+        members = CaseInsensitiveDict({m.name: m for m in cluster.members
+                                       if m.is_running and not m.nosync and m.name.lower() != postgresql.name.lower()})
+        replication = CaseInsensitiveDict({row['application_name']: row for row in postgresql.pg_stat_replication()
+                                           if row[sort_col] is not None and row['application_name'] in members})
+        for row in replication.values():
             member = members.get(row['application_name'])
 
             # We want to consider only rows from ``pg_stat_replication` that:
@@ -238,7 +240,8 @@ def __init__(self, postgresql: 'Postgresql', cluster: Cluster) -> None:
             # 2. can be mapped to a ``Member`` of the ``Cluster``:
             #   a. ``Member`` doesn't have ``nosync`` tag set;
             #   b. PostgreSQL on the member is known to be running and accepting client connections.
-            if member and row[sort_col] is not None and member.is_running and not member.nosync:
+            #   c. ``Member`` isn't supposed to stream from another standby (``replicatefrom`` tag).
+            if member and row[sort_col] is not None and not self._should_cascade(members, replication, member):
                 self.append(_Replica(row['pid'], row['application_name'],
                                      row['sync_state'], row[sort_col],
                                      bool(member.nofailover), member.sync_priority))
@@ -251,6 +254,27 @@ def __init__(self, postgresql: 'Postgresql', cluster: Cluster) -> None:
         # up-to-date replica otherwise with cluster LSN if there is only one replica.
         self.max_lsn = max(self, key=lambda x: x.lsn).lsn if len(self) > 1 else postgresql.last_operation()
 
+    @staticmethod
+    def _should_cascade(members: CaseInsensitiveDict, replication: CaseInsensitiveDict, member: Member) -> bool:
+        """Check whether *member* is supposed to cascade from another standby node.
+
+        :param members: members that are eligible to stream (state=running and don't have nosync tag)
+        :param replication: state of ``pg_stat_replication``, already filtered by member names from *members*
+        :param member: member that we want to check
+
+        :returns: ``True`` if provided member should stream from other standby node in
+                  the cluster (according to ``replicatefrom`` tag), because some standbys
+                  in a chain already streaming from the primary, otherwise ``False``
+        """
+        if not member.replicatefrom or member.replicatefrom not in members:
+            return False
+
+        member = members[member.replicatefrom]
+        if not member.replicatefrom:
+            return member.name in replication
+
+        return _ReplicaList._should_cascade(members, replication, member)
+
 
 class SyncHandler(object):
     """Class responsible for working with the `synchronous_standby_names`.
@@ -350,8 +374,7 @@ def current_state(self, cluster: Cluster) -> _SyncState:
         self._process_replica_readiness(cluster, replica_list)
 
         active = CaseInsensitiveSet()
-        sync_nodes = CaseInsensitiveSet()
-        numsync_confirmed = 0
+        sync_confirmed = CaseInsensitiveSet()
 
         sync_node_count = global_config.synchronous_node_count if self._postgresql.supports_multiple_sync else 1
         sync_node_maxlag = global_config.maximum_lag_on_syncnode
@@ -365,24 +388,20 @@ def current_state(self, cluster: Cluster) -> _SyncState:
                     # there is a chance that a non-promotable node is ahead of a promotable one.
                     if not replica.nofailover or len(active) < sync_node_count:
                         if replica.application_name in self._ready_replicas:
-                            numsync_confirmed += 1
+                            sync_confirmed.add(replica.application_name)
                         active.add(replica.application_name)
                 else:
                     active.add(replica.application_name)
                     if replica.sync_state == 'sync' and replica.application_name in self._ready_replicas:
-                        sync_nodes.add(replica.application_name)
-                        numsync_confirmed += 1
+                        sync_confirmed.add(replica.application_name)
                     if len(active) >= sync_node_count:
                         break
 
-        if global_config.is_quorum_commit_mode:
-            sync_nodes = CaseInsensitiveSet() if self._ssn_data.has_star else self._ssn_data.members
-
         return _SyncState(
             self._ssn_data.sync_type,
-            0 if self._ssn_data.has_star else self._ssn_data.num,
-            numsync_confirmed,
-            sync_nodes,
+            self._ssn_data.num,
+            CaseInsensitiveSet() if self._ssn_data.has_star else self._ssn_data.members,
+            sync_confirmed,
             active)
 
     def set_synchronous_standby_names(self, sync: Collection[str], num: Optional[int] = None) -> None:


Test patch:
diff --git a/tests/test_ha.py b/tests/test_ha.py
index 34901b48c..2828656df 100644
--- a/tests/test_ha.py
+++ b/tests/test_ha.py
@@ -981,7 +981,6 @@ def test_manual_failover_process_no_leader_in_synchronous_mode(self):
         with patch('patroni.ha.logger.warning') as mock_warning:
             self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'other', None),
                                                                      sync=('leader1', 'postgresql0'))
-            self.p.sync_handler.current_state = Mock(return_value=(CaseInsensitiveSet(), CaseInsensitiveSet()))
             self.ha.dcs.write_sync_state = Mock(return_value=SyncState.empty())
             self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
             self.assertEqual(mock_warning.call_args_list[0][0],
@@ -992,8 +991,6 @@ def test_manual_failover_process_no_leader_in_synchronous_mode(self):
         self.p.set_role(PostgresqlRole.REPLICA)
         self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, '', 'postgresql0', None),
                                                                  sync=('leader1', 'other'))
-        self.p.sync_handler.current_state = Mock(return_value=(CaseInsensitiveSet(['leader1']),
-                                                               CaseInsensitiveSet(['leader1'])))
         self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
 
     def test_manual_switchover_process_no_leader_in_synchronous_mode(self):
@@ -1368,7 +1365,8 @@ def test__process_multisync_replication(self):
         self.ha.is_synchronous_mode = true
 
         # Test sync standby not touched when picking the same node
-        self.p.sync_handler.current_state = Mock(return_value=_SyncState('priority', 1, 1,
+        self.p.sync_handler.current_state = Mock(return_value=_SyncState('priority', 1,
+                                                                         CaseInsensitiveSet(['other']),
                                                                          CaseInsensitiveSet(['other']),
                                                                          CaseInsensitiveSet(['other'])))
         self.ha.cluster = get_cluster_initialized_with_leader(sync=('leader', 'other'))
@@ -1379,7 +1377,8 @@ def test__process_multisync_replication(self):
         mock_cfg_set_sync.reset_mock()
 
         # Test sync standby is replaced when switching standbys
-        self.p.sync_handler.current_state = Mock(return_value=_SyncState('priority', 0, 0, CaseInsensitiveSet(),
+        self.p.sync_handler.current_state = Mock(return_value=_SyncState('priority', 0, CaseInsensitiveSet(),
+                                                                         CaseInsensitiveSet(),
                                                                          CaseInsensitiveSet(['other2'])))
         self.ha.dcs.write_sync_state = Mock(return_value=SyncState.empty())
         self.ha.run_cycle()
@@ -1387,7 +1386,8 @@ def test__process_multisync_replication(self):
         mock_cfg_set_sync.assert_not_called()
 
         # Test sync standby is replaced when new standby is joined
-        self.p.sync_handler.current_state = Mock(return_value=_SyncState('priority', 1, 1,
+        self.p.sync_handler.current_state = Mock(return_value=_SyncState('priority', 1,
+                                                                         CaseInsensitiveSet(['other2']),
                                                                          CaseInsensitiveSet(['other2']),
                                                                          CaseInsensitiveSet(['other2', 'other3'])))
         self.ha.dcs.write_sync_state = Mock(return_value=SyncState.empty())
@@ -1410,7 +1410,8 @@ def test__process_multisync_replication(self):
         self.ha.dcs.write_sync_state = Mock(return_value=SyncState.empty())
         self.ha.dcs.get_cluster = Mock(return_value=get_cluster_initialized_with_leader(sync=('leader', 'other')))
         # self.ha.cluster = get_cluster_initialized_with_leader(sync=('leader', 'other'))
-        self.p.sync_handler.current_state = Mock(return_value=_SyncState('priority', 1, 1,
+        self.p.sync_handler.current_state = Mock(return_value=_SyncState('priority', 1,
+                                                                         CaseInsensitiveSet(['other2']),
                                                                          CaseInsensitiveSet(['other2']),
                                                                          CaseInsensitiveSet(['other2'])))
         self.ha.run_cycle()
@@ -1435,8 +1436,8 @@ def test__process_multisync_replication(self):
         # Test sync set to '*' when synchronous_mode_strict is enabled
         mock_set_sync.reset_mock()
         mock_cfg_set_sync.reset_mock()
-        self.p.sync_handler.current_state = Mock(return_value=_SyncState('priority', 0, 0, CaseInsensitiveSet(),
-                                                                         CaseInsensitiveSet()))
+        self.p.sync_handler.current_state = Mock(return_value=_SyncState('priority', 0, CaseInsensitiveSet(),
+                                                                         CaseInsensitiveSet(), CaseInsensitiveSet()))
         with patch.object(global_config.__class__, 'is_synchronous_mode_strict', PropertyMock(return_value=True)):
             self.ha.run_cycle()
         mock_set_sync.assert_called_once_with(CaseInsensitiveSet('*'))
@@ -1547,7 +1548,7 @@ def test_enable_synchronous_mode(self):
         self.ha.is_synchronous_mode = true
         self.ha.has_lock = true
         self.p.name = 'leader'
-        self.p.sync_handler.current_state = Mock(return_value=_SyncState('priority', 0, 0,
+        self.p.sync_handler.current_state = Mock(return_value=_SyncState('priority', 0, CaseInsensitiveSet(),
                                                                          CaseInsensitiveSet(), CaseInsensitiveSet()))
         self.ha.dcs.write_sync_state = Mock(return_value=SyncState.empty())
         with patch('patroni.ha.logger.info') as mock_logger:
@@ -1564,7 +1565,7 @@ def test_inconsistent_synchronous_state(self):
         self.ha.has_lock = true
         self.p.name = 'leader'
         self.ha.cluster = get_cluster_initialized_without_leader(sync=('leader', 'a'))
-        self.p.sync_handler.current_state = Mock(return_value=_SyncState('priority', 0, 0,
+        self.p.sync_handler.current_state = Mock(return_value=_SyncState('priority', 0, CaseInsensitiveSet(),
                                                                          CaseInsensitiveSet(), CaseInsensitiveSet('a')))
         self.ha.dcs.write_sync_state = Mock(return_value=SyncState.empty())
         mock_set_sync = self.p.sync_handler.set_synchronous_standby_names = Mock()
@@ -1792,7 +1793,8 @@ def test__process_quorum_replication(self):
 
         mock_write_sync = self.ha.dcs.write_sync_state = Mock(return_value=None)
         # Test /sync key is attempted to set and failed when missing or invalid
-        self.p.sync_handler.current_state = Mock(return_value=_SyncState('quorum', 1, 1, CaseInsensitiveSet(['other']),
+        self.p.sync_handler.current_state = Mock(return_value=_SyncState('quorum', 1, CaseInsensitiveSet(['other']),
+                                                                         CaseInsensitiveSet(['other']),
                                                                          CaseInsensitiveSet(['other'])))
         self.ha.run_cycle()
         self.assertEqual(mock_write_sync.call_count, 1)
@@ -1812,9 +1814,11 @@ def test__process_quorum_replication(self):
         self.assertEqual(mock_write_sync.call_args_list[1][1], {'version': None})
         self.assertEqual(mock_set_sync.call_count, 0)
 
-        self.p.sync_handler.current_state = Mock(side_effect=[_SyncState('quorum', 1, 0, CaseInsensitiveSet(['foo']),
+        self.p.sync_handler.current_state = Mock(side_effect=[_SyncState('quorum', 1, CaseInsensitiveSet(['foo']),
+                                                                         CaseInsensitiveSet(),
                                                                          CaseInsensitiveSet(['other'])),
-                                                              _SyncState('quorum', 1, 1, CaseInsensitiveSet(['foo']),
+                                                              _SyncState('quorum', 1, CaseInsensitiveSet(['foo']),
+                                                                         CaseInsensitiveSet(['foo']),
                                                                          CaseInsensitiveSet(['foo']))])
         mock_write_sync = self.ha.dcs.write_sync_state = Mock(return_value=SyncState(1, 'leader', 'foo', 0))
         self.ha.cluster = get_cluster_initialized_with_leader(sync=('leader', 'foo'))
@@ -1829,8 +1833,9 @@ def test__process_quorum_replication(self):
         self.assertEqual(mock_set_sync.call_args_list[0][0], ('ANY 1 (other)',))
 
         # Test ANY 1 (*) when synchronous_mode_strict and no nodes available
-        self.p.sync_handler.current_state = Mock(return_value=_SyncState('quorum', 1, 0,
+        self.p.sync_handler.current_state = Mock(return_value=_SyncState('quorum', 1,
                                                                          CaseInsensitiveSet(['other', 'foo']),
+                                                                         CaseInsensitiveSet(),
                                                                          CaseInsensitiveSet()))
         mock_write_sync.reset_mock()
         mock_set_sync.reset_mock()
diff --git a/tests/test_sync.py b/tests/test_sync.py
index 8df4e98f5..2cd253f63 100644
--- a/tests/test_sync.py
+++ b/tests/test_sync.py
@@ -40,7 +40,8 @@ def test_pick_sync_standby(self):
         # sync node is a bit behind of async, but we prefer it anyway
         with patch.object(Postgresql, "_cluster_info_state_get", side_effect=[self.leadermem.name,
                                                                               'on', pg_stat_replication]):
-            self.assertEqual(self.s.current_state(self.cluster), ('priority', 1, 1,
+            self.assertEqual(self.s.current_state(self.cluster), ('priority', 1,
+                                                                  CaseInsensitiveSet([self.leadermem.name]),
                                                                   CaseInsensitiveSet([self.leadermem.name]),
                                                                   CaseInsensitiveSet([self.leadermem.name])))
 
@@ -49,7 +50,7 @@ def test_pick_sync_standby(self):
         for r in pg_stat_replication:
             r['write_lsn'] = r.pop('flush_lsn')
         with patch.object(Postgresql, "_cluster_info_state_get", side_effect=['', 'remote_write', pg_stat_replication]):
-            self.assertEqual(self.s.current_state(self.cluster), ('off', 0, 0, CaseInsensitiveSet(),
+            self.assertEqual(self.s.current_state(self.cluster), ('off', 0, CaseInsensitiveSet(), CaseInsensitiveSet(),
                                                                   CaseInsensitiveSet([self.leadermem.name])))
 
         # when there are no sync or potential candidates we pick async with the minimal replication lag
@@ -57,20 +58,20 @@ def test_pick_sync_standby(self):
             r.update(replay_lsn=3 - i, application_name=r['application_name'].upper())
         missing = pg_stat_replication.pop(0)
         with patch.object(Postgresql, "_cluster_info_state_get", side_effect=['', 'remote_apply', pg_stat_replication]):
-            self.assertEqual(self.s.current_state(self.cluster), ('off', 0, 0, CaseInsensitiveSet(),
+            self.assertEqual(self.s.current_state(self.cluster), ('off', 0, CaseInsensitiveSet(), CaseInsensitiveSet(),
                                                                   CaseInsensitiveSet([self.me.name])))
 
         # unknown sync node is ignored
         missing.update(application_name='missing', sync_state='sync')
         pg_stat_replication.insert(0, missing)
         with patch.object(Postgresql, "_cluster_info_state_get", side_effect=['', 'remote_apply', pg_stat_replication]):
-            self.assertEqual(self.s.current_state(self.cluster), ('off', 0, 0, CaseInsensitiveSet(),
+            self.assertEqual(self.s.current_state(self.cluster), ('off', 0, CaseInsensitiveSet(), CaseInsensitiveSet(),
                                                                   CaseInsensitiveSet([self.me.name])))
 
         # invalid synchronous_standby_names and empty pg_stat_replication
         with patch.object(Postgresql, "_cluster_info_state_get", side_effect=['a b', 'remote_apply', None]):
             self.p._major_version = 90400
-            self.assertEqual(self.s.current_state(self.cluster), ('off', 0, 0, CaseInsensitiveSet(),
+            self.assertEqual(self.s.current_state(self.cluster), ('off', 0, CaseInsensitiveSet(), CaseInsensitiveSet(),
                                                                   CaseInsensitiveSet()))
 
     @patch.object(Postgresql, 'last_operation', Mock(return_value=1))
@@ -80,16 +81,34 @@ def test_current_state_quorum(self):
 
         pg_stat_replication = [
             {'pid': 100, 'application_name': self.leadermem.name, 'sync_state': 'quorum', 'flush_lsn': 1},
-            {'pid': 101, 'application_name': self.other.name, 'sync_state': 'quorum', 'flush_lsn': 2}]
+            {'pid': 101, 'application_name': self.me.name, 'sync_state': 'quorum', 'flush_lsn': 2}]
 
         # sync node is a bit behind of async, but we prefer it anyway
         with patch.object(Postgresql, "_cluster_info_state_get",
-                          side_effect=['ANY 1 ({0},"{1}")'.format(self.leadermem.name, self.other.name),
+                          side_effect=['ANY 1 ({0},"{1}")'.format(self.leadermem.name, self.me.name),
+                                       'on', pg_stat_replication]):
+            self.assertEqual(self.s.current_state(self.cluster),
+                             ('quorum', 1, CaseInsensitiveSet([self.me.name, self.leadermem.name]),
+                              CaseInsensitiveSet([self.me.name, self.leadermem.name]),
+                              CaseInsensitiveSet([self.leadermem.name, self.me.name])))
+
+    @patch.object(Postgresql, 'last_operation', Mock(return_value=1))
+    def test_current_state_cascading(self):
+        pg_stat_replication = [
+            {'pid': 100, 'application_name': self.me.name, 'sync_state': 'async', 'flush_lsn': 1},
+            {'pid': 101, 'application_name': self.other.name, 'sync_state': 'sync', 'flush_lsn': 2}]
+
+        # nodes that are supposed to replicate from other standby nodes are not
+        # returned if at least one standby in a chain is streaming from primary
+        self.leadermem.data['tags'] = {'replicatefrom': self.me.name}
+        with patch.object(Postgresql, "_cluster_info_state_get",
+                          side_effect=['2 ({0},"{1}")'.format(self.leadermem.name, self.other.name),
                                        'on', pg_stat_replication]):
             self.assertEqual(self.s.current_state(self.cluster),
-                             ('quorum', 1, 2, CaseInsensitiveSet([self.other.name, self.leadermem.name]),
-                              CaseInsensitiveSet([self.leadermem.name, self.other.name])))
+                             ('priority', 2, CaseInsensitiveSet([self.other.name, self.leadermem.name]),
+                              CaseInsensitiveSet(), CaseInsensitiveSet([self.me.name])))
 
+    @patch('time.sleep', Mock())
     def test_set_sync_standby(self):
         def value_in_conf():
             with open(os.path.join(self.p.data_dir, 'postgresql.conf')) as f:
@@ -181,5 +200,5 @@ def test_do_not_prick_yourself(self):
         # the pg_stat_replication. We need to check that primary is not selected as the synchronous node.
         with patch.object(Postgresql, "_cluster_info_state_get", side_effect=[self.leadermem.name,
                                                                               'on', pg_stat_replication]):
-            self.assertEqual(self.s.current_state(cluster), ('priority', 1, 0, CaseInsensitiveSet(),
-                                                             CaseInsensitiveSet([self.me.name])))
+            self.assertEqual(self.s.current_state(cluster), ('priority', 1, CaseInsensitiveSet([self.leadermem.name]),
+                                                             CaseInsensitiveSet(), CaseInsensitiveSet([self.me.name])))