Repository: patroni/patroni

Issue:
Infinite recursion in handling of replicatefrom tags
### What happened?

I installed Patroni and created a Patroni cluster.
After issuing a switchover I ran into an unexpected state where the leader shows it replicates from (replicatefrom) the replica (which was once the primary).
And replica shows it is not replicating (where in fact it is replicating fine).

### How can we reproduce it (as minimally and precisely as possible)?

I installed Patroni, created a fully functional Patroni cluster and issu'ed a switchover.
After that patronictl topology show'ed:
```
[root@server1 ~]# patronictl -c /etc/patroni/mycluster.yml topology
+ Cluster: mycluster (7368804937979002203) -------+----+-----------+--------------------------+
| Member    | Host    | Role    | State     | TL | Lag in MB | Tags                     |
+-----------+---------+---------+-----------+----+-----------+--------------------------+
| server2   | server2 | Leader  | running   |  2 |           | {replicatefrom: server1} |
| + server1 | server1 | Replica | streaming |  2 |         0 | {replicatefrom: false}   |
+-----------+---------+---------+-----------+----+-----------+--------------------------+
```


### What did you expect to happen?

I installed Patroni, created a fully functional Patroni cluster and issu'ed a switchover.
After that patronictl topology show'ed:
```
[root@server1 ~]# patronictl -c /etc/patroni/mycluster.yml topology
+ Cluster: mycluster (7368804937979002203) -------+----+-----------+--------------------------+
| Member    | Host    | Role    | State     | TL | Lag in MB | Tags                     |
+-----------+---------+---------+-----------+----+-----------+--------------------------+
| server2   | server2 | Leader  | running   |  2 |           | {replicatefrom: false} |
| + server1 | server1 | Replica | streaming |  2 |         0 | {replicatefrom: server2}   |
+-----------+---------+---------+-----------+----+-----------+--------------------------+
```


### Patroni/PostgreSQL/DCS version

-   Patroni version: patroni-3.3.0-1PGDG.rhel9.noarch
-   PostgreSQL version: 15.6
-   DCS (and its version): patroni-etcd-3.3.0-1PGDG.rhel9.noarch


### Patroni configuration file

```yaml
---
scope: cluster1
namespace: /tpa/
name: server1
restapi:
  listen: 0.0.0.0:8008
  connect_address: server1:8008
  authentication:
    username: patroni
    password: *****
bootstrap:
  pg_hba:
  - host replication replicator 127.0.0.1/32 md5
  - host all all 0.0.0.0/0 md5
  initdb:
  - encoding: UTF8
  - data-checksums
  - auth-local: peer
  - auth-host: scram-sha-256
  dcs:
    postgresql:
      parameters:
        tcp_keepalives_idle: 53s
        tcp_keepalives_interval: 7s
        tcp_keepalives_count: '9'
        temp_buffers: 16MB
        work_mem: 16MB
        maintenance_work_mem: 64MB
        autovacuum_work_mem: '-1'
        bgwriter_delay: 200ms
        bgwriter_lru_maxpages: '100'
        bgwriter_lru_multiplier: '2.0'
        effective_io_concurrency: '1'
        log_destination: syslog
        syslog_facility: LOCAL6
        syslog_ident: postgres
        log_timezone: UTC
        log_checkpoints: 'on'
        log_connections: 'off'
        log_disconnections: 'off'
        log_duration: 'off'
        log_hostname: 'off'
        log_lock_waits: 'on'
        log_statement: ddl
        log_temp_files: '8192'
        log_min_duration_statement: 5s
        log_line_prefix: '%t [%u@%r/%a/%d:%p]: [%l] '
        seq_page_cost: '1.0'
        random_page_cost: '4.0'
        cpu_tuple_cost: '0.01'
        cpu_index_tuple_cost: '0.005'
        cpu_operator_cost: '0.0025'
        effective_cache_size: 3820MB
        default_statistics_target: '100'
        constraint_exclusion: partition
        archive_command: /bin/true
        archive_timeout: 6h
        wal_sender_timeout: 60s
        wal_receiver_timeout: 60s
        hot_standby_feedback: 'on'
        max_standby_streaming_delay: 30s
        autovacuum: 'on'
        log_autovacuum_min_duration: 5s
        autovacuum_naptime: 1min
        autovacuum_vacuum_threshold: '50'
        autovacuum_analyze_threshold: '50'
        autovacuum_vacuum_scale_factor: '0.2'
        autovacuum_analyze_scale_factor: '0.1'
        autovacuum_vacuum_cost_delay: 20ms
        autovacuum_vacuum_cost_limit: '-1'
        vacuum_cost_delay: '0'
        vacuum_cost_limit: '200'
        fsync: 'on'
        full_page_writes: 'on'
        commit_delay: '0'
        commit_siblings: '5'
        checkpoint_warning: 2min
        checkpoint_timeout: 10min
        checkpoint_completion_target: '0.9'
        transform_null_equals: 'off'
        array_nulls: 'on'
        backslash_quote: safe_encoding
        escape_string_warning: 'on'
        lo_compat_privileges: 'off'
        quote_all_identifiers: 'off'
        standard_conforming_strings: 'on'
        synchronize_seqscans: 'on'
        synchronous_commit: 'on'
        max_wal_size: 2210MB
        min_wal_size: 736MB
        wal_compression: 'off'
        password_encryption: scram-sha-256
        ssl_min_protocol_version: TLSv1.2
        auto_explain.log_min_duration: 60s
        auto_explain.log_analyze: 'on'
        auto_explain.log_buffers: 'on'
        auto_explain.log_timing: 'on'
        auto_explain.log_verbose: 'on'
        auto_explain.log_format: text
        auto_explain.log_nested_statements: 'on'
        pg_stat_statements.max: '1000'
        pg_stat_statements.track: all
        pg_stat_statements.track_utility: 'on'
        pg_stat_statements.save: 'on'
        shared_preload_libraries: ''
        unix_socket_directories: /var/run/edb-pge, /tmp
        max_connections: '300'
        shared_buffers: 1910MB
        max_prepared_transactions: '0'
        max_worker_processes: '16'
        logging_collector: 'off'
        archive_mode: 'on'
        max_wal_senders: '4'
        max_replication_slots: '4'
        ssl: 'on'
        ssl_ca_file: ''
        ssl_crl_file: ''
        ssl_cert_file: /etc/tpa/ota.crt
        ssl_key_file: /etc/tpa/ota.key
        ssl_ciphers: HIGH:!3DES:!aNULL
        ssl_prefer_server_ciphers: 'on'
        autovacuum_max_workers: '3'
        autovacuum_freeze_max_age: '200000000'
        autovacuum_multixact_freeze_max_age: '400000000'
        wal_level: logical
        wal_log_hints: 'on'
        wal_buffers: '-1'
        wal_keep_size: '16'
        track_commit_timestamp: 'off'
      use_pg_rewind: true
      use_slots: true
    ttl: 30
    loop_wait: 10
    retry_timeout: 10
    maximum_lag_on_failover: 1048576
    ignore_slots:
    - name: backup_server3
      type: physical
    slots:
      server1:
        type: physical
      server2:
        type: physical
postgresql:
  listen: 0.0.0.0:5432
  connect_address: server1:5432
  data_dir: /pgdata/data
  bin_dir: /usr/edb/pge15/bin
  pgpass: /etc/patroni/.pgpass
  authentication:
    replication:
      username: replication
      password: *****
    superuser:
      username: postgres
      password: *****
    rewind:
      username: rewind
      password: *****
  parameters:
    unix_socket_directories: /var/run/edb-pge,/tmp
  create_replica_methods:
  - basebackup
  basebackup:
    checkpoint: fast
  pg_hba:
  - ''
  - local   all             postgres                                peer
  - local   replication     all                                     peer
  - local   all             all                                     peer
  - host    all             all             127.0.0.1/32            scram-sha-256
  - host    all             all             ::1/128                 scram-sha-256
  - host    replication     all             127.0.0.1/32            scram-sha-256
  - host    replication     all             ::1/128                 scram-sha-256
  - hostssl all all 1.2.3.4/32 scram-sha-256
  - hostssl replication all 1.2.3.5/32 scram-sha-256
  - hostssl all all 1.2.3.5/32 scram-sha-256
  - hostssl replication all 1.2.3.5/32 scram-sha-256
  - hostssl postgres barman 1.2.3.6/32 scram-sha-256
  - hostssl replication streaming_barman 1.2.3.6/32 scram-sha-256
  - ''
  - hostssl all all 0.0.0.0/0 scram-sha-256
  - ''
watchdog:
  mode: 'off'
log:
  dir: /var/log/patroni
tags:
  nofailover: false
  noloadbalance: false
  clonefrom: false
  replicatefrom: false
  nosync: false
etcd3:
  hosts:
  - server1:2379
  - server2:2379
  - server3:2379
```


### patronictl show-config

```yaml
ignore_slots:
- name: backup_server3
  type: physical
loop_wait: 10
maximum_lag_on_failover: 1048576
postgresql:
  parameters:
    archive_command: /bin/true
    archive_mode: 'on'
    archive_timeout: 6h
    array_nulls: 'on'
    auto_explain.log_analyze: 'on'
    auto_explain.log_buffers: 'on'
    auto_explain.log_format: text
    auto_explain.log_min_duration: 60s
    auto_explain.log_nested_statements: 'on'
    auto_explain.log_timing: 'on'
    auto_explain.log_verbose: 'on'
    autovacuum: 'on'
    autovacuum_analyze_scale_factor: '0.1'
    autovacuum_analyze_threshold: '50'
    autovacuum_freeze_max_age: '200000000'
    autovacuum_max_workers: '3'
    autovacuum_multixact_freeze_max_age: '400000000'
    autovacuum_naptime: 1min
    autovacuum_vacuum_cost_delay: 20ms
    autovacuum_vacuum_cost_limit: '-1'
    autovacuum_vacuum_scale_factor: '0.2'
    autovacuum_vacuum_threshold: '50'
    autovacuum_work_mem: '-1'
    backslash_quote: safe_encoding
    bgwriter_delay: 200ms
    bgwriter_lru_maxpages: '100'
    bgwriter_lru_multiplier: '2.0'
    checkpoint_completion_target: '0.9'
    checkpoint_timeout: 10min
    checkpoint_warning: 2min
    commit_delay: '0'
    commit_siblings: '5'
    constraint_exclusion: partition
    cpu_index_tuple_cost: '0.005'
    cpu_operator_cost: '0.0025'
    cpu_tuple_cost: '0.01'
    default_statistics_target: '100'
    effective_cache_size: 3820MB
    effective_io_concurrency: '1'
    escape_string_warning: 'on'
    fsync: 'on'
    full_page_writes: 'on'
    hot_standby_feedback: 'on'
    lo_compat_privileges: 'off'
    log_autovacuum_min_duration: 5s
    log_checkpoints: 'on'
    log_connections: 'off'
    log_destination: syslog
    log_disconnections: 'off'
    log_duration: 'off'
    log_hostname: 'off'
    log_line_prefix: '%t [%u@%r/%a/%d:%p]: [%l] '
    log_lock_waits: 'on'
    log_min_duration_statement: 5s
    log_statement: ddl
    log_temp_files: '8192'
    log_timezone: UTC
    logging_collector: 'off'
    maintenance_work_mem: 64MB
    max_connections: '300'
    max_prepared_transactions: '0'
    max_replication_slots: '4'
    max_standby_streaming_delay: 30s
    max_wal_senders: '4'
    max_wal_size: 2210MB
    max_worker_processes: '16'
    min_wal_size: 736MB
    password_encryption: scram-sha-256
    pg_stat_statements.max: '1000'
    pg_stat_statements.save: 'on'
    pg_stat_statements.track: all
    pg_stat_statements.track_utility: 'on'
    quote_all_identifiers: 'off'
    random_page_cost: '4.0'
    seq_page_cost: '1.0'
    shared_buffers: 1910MB
    shared_preload_libraries: ''
    ssl: 'on'
    ssl_ca_file: ''
    ssl_cert_file: /etc/tpa/cluster1.crt
    ssl_ciphers: HIGH:!3DES:!aNULL
    ssl_crl_file: ''
    ssl_key_file: /etc/tpa/cluster1.key
    ssl_min_protocol_version: TLSv1.2
    ssl_prefer_server_ciphers: 'on'
    standard_conforming_strings: 'on'
    synchronize_seqscans: 'on'
    synchronous_commit: 'on'
    syslog_facility: LOCAL6
    syslog_ident: postgres
    tcp_keepalives_count: '9'
    tcp_keepalives_idle: 53s
    tcp_keepalives_interval: 7s
    temp_buffers: 16MB
    track_commit_timestamp: 'off'
    transform_null_equals: 'off'
    unix_socket_directories: /tmp
    vacuum_cost_delay: '0'
    vacuum_cost_limit: '200'
    wal_buffers: '-1'
    wal_compression: 'off'
    wal_keep_size: '16'
    wal_level: logical
    wal_log_hints: 'on'
    wal_receiver_timeout: 60s
    wal_sender_timeout: 60s
    work_mem: 16MB
  use_pg_rewind: false
  use_slots: true
retry_timeout: 10
slots:
  server1:
    type: physical
  server2:
    type: physical
ttl: 30
```


### Patroni log files

```shell
2024-05-14 14:02:27,039 INFO: received switchover request with leader=server1 candidate=None scheduled_at=None
2024-05-14 14:02:27,469 INFO: Got response from server2 http://server2:8008/patroni: {"state": "running", "postmaster_start_time": "2024-05-14 13:59:58.068563+02:00", "role": "replica", "server_version": 150006, "xlog": {"received_location": 201575712, "replayed_location": 201575712, "replayed_timestamp": null, "paused": false}, "timeline": 1, "replication_state": "streaming", "dcs_last_seen": 1715688140, "tags": {"replicatefrom": "server1"}, "database_system_identifier": "7368804937979002203", "patroni": {"version": "3.3.0", "scope": "ota", "name": "server2"}}
2024-05-14 14:02:27,615 INFO: Got response from server2 http://server2:8008/patroni: {"state": "running", "postmaster_start_time": "2024-05-14 13:59:58.068563+02:00", "role": "replica", "server_version": 150006, "xlog": {"received_location": 201575712, "replayed_location": 201575712, "replayed_timestamp": null, "paused": false}, "timeline": 1, "replication_state": "streaming", "dcs_last_seen": 1715688147, "tags": {"replicatefrom": "server1"}, "database_system_identifier": "7368804937979002203", "patroni": {"version": "3.3.0", "scope": "ota", "name": "server2"}}
2024-05-14 14:02:27,519 INFO: Lock owner: server1; I am server1
2024-05-14 14:02:27,661 INFO: switchover: demoting myself
2024-05-14 14:02:27,661 INFO: Demoting self (graceful)
2024-05-14 14:02:29,046 INFO: Leader key released
2024-05-14 14:02:29,047 INFO: Lock owner: None; I am server1
2024-05-14 14:02:29,047 INFO: not healthy enough for leader race
2024-05-14 14:02:29,047 INFO: switchover: demote in progress
2024-05-14 14:02:29,310 INFO: Lock owner: server2; I am server1
2024-05-14 14:02:29,311 INFO: switchover: demote in progress
2024-05-14 14:02:31,050 INFO: closed patroni connections to postgres
2024-05-14 14:02:31,475 INFO: postmaster pid=45937
2024-05-14 14:02:31,519 INFO: Lock owner: server2; I am server1
2024-05-14 14:02:31,565 INFO: switchover: demote in progress
2024-05-14 14:02:41,520 INFO: Lock owner: server2; I am server1
2024-05-14 14:02:41,520 INFO: establishing a new patroni heartbeat connection to postgres
```

And after first time getting into this state (we reproduced a few times before we issued #3064) switchover does not work anymore reporting this in log files:
```
2024-04-19 12:05:49,813 ERROR: Unexpected exception
Traceback (most recent call last):
  File "/usr/lib/python3.9/site-packages/patroni/ha.py", line 1980, in run_cycle
    info = self._run_cycle()
  File "/usr/lib/python3.9/site-packages/patroni/ha.py", line 1761, in _run_cycle
    self.state_handler.reset_cluster_info_state(self.cluster, self.patroni)
  File "/usr/lib/python3.9/site-packages/patroni/postgresql/__init__.py", line 465, in reset_cluster_info_state
    and cluster.should_enforce_hot_standby_feedback(self, tags))
  File "/usr/lib/python3.9/site-packages/patroni/dcs/__init__.py", line 1177, in should_enforce_hot_standby_feedback
    if self._has_permanent_logical_slots(postgresql, member):
  File "/usr/lib/python3.9/site-packages/patroni/dcs/__init__.py", line 1161, in _has_permanent_logical_slots
    slots = self.get_replication_slots(postgresql, member, role='replica').values()
  File "/usr/lib/python3.9/site-packages/patroni/dcs/__init__.py", line 981, in get_replication_slots
    permanent_slots: Dict[str, Any] = self._get_permanent_slots(postgresql, member, role)
  File "/usr/lib/python3.9/site-packages/patroni/dcs/__init__.py", line 1063, in _get_permanent_slots
    if global_config.is_standby_cluster or self.get_slot_name_on_primary(postgresql.name, tags) is None:
  File "/usr/lib/python3.9/site-packages/patroni/dcs/__init__.py", line 1203, in get_slot_name_on_primary
    print(f'{name} replicates from {replicatefrom}')
  File "/usr/lib/python3.9/site-packages/patroni/dcs/__init__.py", line 1203, in get_slot_name_on_primary
    print(f'{name} replicates from {replicatefrom}')
  File "/usr/lib/python3.9/site-packages/patroni/dcs/__init__.py", line 1203, in get_slot_name_on_primary
    print(f'{name} replicates from {replicatefrom}')
  [Previous line repeated 979 more times]
  File "/usr/lib/python3.9/site-packages/patroni/dcs/__init__.py", line 1200, in get_slot_name_on_primary
    if tags.nostream:
  File "/usr/lib/python3.9/site-packages/patroni/tags.py", line 99, in nostream
    return parse_bool(self.tags.get('nostream')) or False
  File "/usr/lib/python3.9/site-packages/patroni/utils.py", line 182, in parse_bool
    value = str(value).lower()
RecursionError: maximum recursion depth exceeded while getting the str of an object
```


### PostgreSQL log files

```shell
No relevant log available
```


### Have you tried to use GitHub issue search?

- [X] Yes

### Anything else we need to know?

I already submitted this issue as #3064, but that issues was closed with a comment which did not satisfy the issue as reported.
I added extra comments, but as it was already closed, no one would ever notiice and I don;t have permissions to reopen, other than by submitting a new issue...


Discussion:
My response to [@CyberDem0n 's comment](https://github.com/zalando/patroni/issues/3064#issuecomment-2110380643):


That may be so, but after getting into this unexpected state, switchover no longer worked with the following error ending up in patroni log file:
```
2024-04-19 12:05:49,813 ERROR: Unexpected exception
Traceback (most recent call last):
  File "/usr/lib/python3.9/site-packages/patroni/ha.py", line 1980, in run_cycle
    info = self._run_cycle()
  File "/usr/lib/python3.9/site-packages/patroni/ha.py", line 1761, in _run_cycle
    self.state_handler.reset_cluster_info_state(self.cluster, self.patroni)
  File "/usr/lib/python3.9/site-packages/patroni/postgresql/__init__.py", line 465, in reset_cluster_info_state
    and cluster.should_enforce_hot_standby_feedback(self, tags))
  File "/usr/lib/python3.9/site-packages/patroni/dcs/__init__.py", line 1177, in should_enforce_hot_standby_feedback
    if self._has_permanent_logical_slots(postgresql, member):
  File "/usr/lib/python3.9/site-packages/patroni/dcs/__init__.py", line 1161, in _has_permanent_logical_slots
    slots = self.get_replication_slots(postgresql, member, role='replica').values()
  File "/usr/lib/python3.9/site-packages/patroni/dcs/__init__.py", line 981, in get_replication_slots
    permanent_slots: Dict[str, Any] = self._get_permanent_slots(postgresql, member, role)
  File "/usr/lib/python3.9/site-packages/patroni/dcs/__init__.py", line 1063, in _get_permanent_slots
    if global_config.is_standby_cluster or self.get_slot_name_on_primary(postgresql.name, tags) is None:
  File "/usr/lib/python3.9/site-packages/patroni/dcs/__init__.py", line 1203, in get_slot_name_on_primary
    print(f'{name} replicates from {replicatefrom}')
  File "/usr/lib/python3.9/site-packages/patroni/dcs/__init__.py", line 1203, in get_slot_name_on_primary
    print(f'{name} replicates from {replicatefrom}')
  File "/usr/lib/python3.9/site-packages/patroni/dcs/__init__.py", line 1203, in get_slot_name_on_primary
    print(f'{name} replicates from {replicatefrom}')
  [Previous line repeated 979 more times]
  File "/usr/lib/python3.9/site-packages/patroni/dcs/__init__.py", line 1200, in get_slot_name_on_primary
    if tags.nostream:
  File "/usr/lib/python3.9/site-packages/patroni/tags.py", line 99, in nostream
    return parse_bool(self.tags.get('nostream')) or False
  File "/usr/lib/python3.9/site-packages/patroni/utils.py", line 182, in parse_bool
    value = str(value).lower()
RecursionError: maximum recursion depth exceeded while getting the str of an object
```
We recovered from this situation by changing the code:
```
[root@srv0213 ~]# diff "/usr/lib/python3.9/site-packages/patroni/dcs/__init__.py"*
1186c1183,1183
<             return any(self.should_enforce_hot_standby_feedback(postgresql, m) for m in members)
---
>             try:
>                 return any(self.should_enforce_hot_standby_feedback(postgresql, m) for m in members)
>             except RecursionError:
>                 False
1202,1204c1205,1211
<         replicatefrom = self.get_member(tags.replicatefrom, False) if tags.replicatefrom else None
<         return self.get_slot_name_on_primary(replicatefrom.name, replicatefrom) \
<             if isinstance(replicatefrom, Member) else slot_name_from_member_name(name)
---
>         print(name)
>         try:
>             replicatefrom = self.get_member(tags.replicatefrom, False) if tags.replicatefrom else None
>             return self.get_slot_name_on_primary(replicatefrom.name, replicatefrom) \
>                 if isinstance(replicatefrom, Member) else slot_name_from_member_name(name)
>         except RecursionError:
>              return slot_name_from_member_name(name)
```
I can issue a PR, but it seems like a hack more than a proper fix, and I don;t know if you want this to be expanded in a fix and what that should encompass...
Thanks @CyberDem0n 
@hughcapet please let me know if there is something I can do...




Patch:
diff --git a/docs/yaml_configuration.rst b/docs/yaml_configuration.rst
index 0929404d6..b99476416 100644
--- a/docs/yaml_configuration.rst
+++ b/docs/yaml_configuration.rst
@@ -395,7 +395,7 @@ Tags
 ----
 -  **clonefrom**: ``true`` or ``false``. If set to ``true`` other nodes might prefer to use this node for bootstrap (take ``pg_basebackup`` from). If there are several nodes with ``clonefrom`` tag set to ``true`` the node to bootstrap from will be chosen randomly. The default value is ``false``.
 -  **noloadbalance**: ``true`` or ``false``. If set to ``true`` the node will return HTTP Status Code 503 for the ``GET /replica`` REST API health-check and therefore will be excluded from the load-balancing. Defaults to ``false``.
--  **replicatefrom**: The IP address/hostname of another replica. Used to support cascading replication.
+-  **replicatefrom**: The name of another replica to replicate from. Used to support cascading replication.
 -  **nosync**: ``true`` or ``false``. If set to ``true`` the node will never be selected as a synchronous replica.
 -  **nofailover**: ``true`` or ``false``, controls whether this node is allowed to participate in the leader race and become a leader. Defaults to ``false``, meaning this node _can_ participate in leader races. 
 -  **failover_priority**: integer, controls the priority that this node should have during failover. Nodes with higher priority will be preferred over lower priority nodes if they received/replayed the same amount of WAL. However, nodes with higher values of receive/replay LSN are preferred regardless of their priority. If the ``failover_priority`` is 0 or negative - such node is not allowed to participate in the leader race and to become a leader (similar to ``nofailover: true``).
diff --git a/patroni/dcs/__init__.py b/patroni/dcs/__init__.py
index 971cc3c91..33a39464f 100644
--- a/patroni/dcs/__init__.py
+++ b/patroni/dcs/__init__.py
@@ -10,7 +10,7 @@
 from random import randint
 from threading import Event, Lock
 from typing import Any, Callable, Collection, Dict, Iterator, List, \
-    NamedTuple, Optional, Tuple, Type, TYPE_CHECKING, Union
+    NamedTuple, Optional, Set, Tuple, Type, TYPE_CHECKING, Union
 from urllib.parse import urlparse, urlunparse, parse_qsl
 
 import dateutil.parser
@@ -913,7 +913,9 @@ def is_physical_slot(value: Union[Any, Dict[str, Any]]) -> bool:
 
         :returns: ``True`` if *value* is a physical replication slot, otherwise ``False``.
         """
-        return not value or isinstance(value, dict) and value.get('type', 'physical') == 'physical'
+        return not value \
+            or (isinstance(value, dict) and not Cluster.is_logical_slot(value)
+                and value.get('type', 'physical') == 'physical')
 
     @staticmethod
     def is_logical_slot(value: Union[Any, Dict[str, Any]]) -> bool:
@@ -1179,6 +1181,10 @@ def should_enforce_hot_standby_feedback(self, postgresql: 'Postgresql', member:
 
         if global_config.use_slots:
             name = member.name if isinstance(member, Member) else postgresql.name
+
+            if not self.get_slot_name_on_primary(name, member):
+                return False
+
             members = [m for m in self.members if m.replicatefrom == name and m.name != self.leader_name]
             return any(self.should_enforce_hot_standby_feedback(postgresql, m) for m in members)
         return False
@@ -1197,11 +1203,18 @@ def get_slot_name_on_primary(self, name: str, tags: Tags) -> Optional[str]:
 
         :returns: the slot name on the primary that is in use for physical replication on this node.
         """
-        if tags.nostream:
-            return None
-        replicatefrom = self.get_member(tags.replicatefrom, False) if tags.replicatefrom else None
-        return self.get_slot_name_on_primary(replicatefrom.name, replicatefrom) \
-            if isinstance(replicatefrom, Member) else slot_name_from_member_name(name)
+        seen_nodes: Set[str] = set()
+        while True:
+            seen_nodes.add(name)
+            if tags.nostream:
+                return None
+            replicatefrom = self.get_member(tags.replicatefrom, False) \
+                if tags.replicatefrom and tags.replicatefrom != name else None
+            if not isinstance(replicatefrom, Member):
+                return slot_name_from_member_name(name)
+            if replicatefrom.name in seen_nodes:
+                return None
+            name, tags = replicatefrom.name, replicatefrom
 
     @property
     def timeline(self) -> int:


Test patch:
diff --git a/tests/test_slots.py b/tests/test_slots.py
index a7112f81a..717585817 100644
--- a/tests/test_slots.py
+++ b/tests/test_slots.py
@@ -186,6 +186,32 @@ def test_nostream_slot_processing(self):
             cluster.get_slot_name_on_primary(self.p.name, stream_node),
             'test_4')
 
+    def test_get_slot_name_on_primary(self):
+        node1 = Member(0, 'node1', 28, {
+            'state': 'running', 'conn_url': 'postgres://replicator:rep-pass@127.0.0.1:5436/postgres',
+            'tags': {'replicatefrom': 'node2'}
+        })
+        node2 = Member(0, 'node2', 28, {
+            'state': 'running', 'conn_url': 'postgres://replicator:rep-pass@127.0.0.1:5436/postgres',
+            'tags': {'replicatefrom': 'node1'}
+        })
+        cluster = Cluster(True, None, self.leader, Status.empty(), [self.leadermem, node1, node2],
+                          None, SyncState.empty(), None, None)
+        self.assertIsNone(cluster.get_slot_name_on_primary('node1', node1))
+
+    def test_should_enforce_hot_standby_feedback(self):
+        node1 = Member(0, 'postgresql0', 28, {
+            'state': 'running', 'conn_url': 'postgres://replicator:rep-pass@127.0.0.1:5436/postgres',
+            'tags': {'replicatefrom': 'postgresql1'}
+        })
+        node2 = Member(0, 'postgresql1', 28, {
+            'state': 'running', 'conn_url': 'postgres://replicator:rep-pass@127.0.0.1:5436/postgres',
+            'tags': {'replicatefrom': 'postgresql0'}
+        })
+        cluster = Cluster(True, None, self.leader, Status.empty(), [self.leadermem, node1, node2],
+                          None, SyncState.empty(), None, None)
+        self.assertFalse(cluster.should_enforce_hot_standby_feedback(self.p, node1))
+
     @patch.object(Postgresql, 'is_primary', Mock(return_value=False))
     def test__ensure_logical_slots_replica(self):
         self.p.set_role('replica')