Repository: zalando/patroni

Issue:
Replication slots don't prevent recycling of WAL when standby goes offline
When standby goes offline for a while the member key will expire and Patroni will happily remove its replication slot.  Having an archive works around this, but it would be nice to make simple deployments more resilient too. Probably needs some kind of persistent flag, or offline_ttl for cluster members so Patroni knows they will be coming back and doesn't remove their replication slots. And a corresponding administration command to kick them out when they actually need to go away.



Discussion:
Is this problem solved?
This issue is still active, patches are welcome.

While this is not done, `wal_keep_segments` is a decent workaround. I think in most cases an upper limit for how far behind a replication slot can be would be needed anyway because having the master go down due to full disk is worse than having to reinit a standby. And you need to keep the disk space available for that purpose, so it doesn't matter too much if you are using that disk space even if you don't actually need to.

Resolving patch:
diff --git a/patroni/dcs/__init__.py b/patroni/dcs/__init__.py
--- a/patroni/dcs/__init__.py
+++ b/patroni/dcs/__init__.py
@@ -449,21 +449,21 @@ def is_paused(self):
     def is_synchronous_mode(self):
         return self.check_mode('synchronous_mode')
 
-    def get_replication_slots(self, name, role):
+    def get_replication_slots(self, my_name, role):
         # if the replicatefrom tag is set on the member - we should not create the replication slot for it on
         # the current master, because that member would replicate from elsewhere. We still create the slot if
         # the replicatefrom destination member is currently not a member of the cluster (fallback to the
         # master), or if replicatefrom destination member happens to be the current master
         use_slots = self.config and self.config.data.get('postgresql', {}).get('use_slots', True)
         if role in ('master', 'standby_leader'):
-            slot_members = [m.name for m in self.members if use_slots and m.name != name and
-                            (m.replicatefrom is None or m.replicatefrom == name or
+            slot_members = [m.name for m in self.members if use_slots and m.name != my_name and
+                            (m.replicatefrom is None or m.replicatefrom == my_name or
                              not self.has_member(m.replicatefrom))]
             permanent_slots = (self.config and self.config.permanent_slots or {}).copy()
         else:
             # only manage slots for replicas that replicate from this one, except for the leader among them
             slot_members = [m.name for m in self.members if use_slots and
-                            m.replicatefrom == name and m.name != self.leader.name]
+                            m.replicatefrom == my_name and m.name != self.leader.name]
             permanent_slots = {}
 
         slots = {slot_name_from_member_name(name): {'type': 'physical'} for name in slot_members}
@@ -484,22 +484,21 @@ def get_replication_slots(self, name, role):
                 logger.error("Slot name may only contain lower case letters, numbers, and the underscore chars")
                 continue
 
-            if name in slots:
-                logger.error("Permanent replication slot {'%s': %s} is conflicting with" +
-                             " physical replication slot for cluster member", name, value)
-                continue
-
-            value = deepcopy(value)
-            if not value:
-                value = {'type': 'physical'}
-
+            value = deepcopy(value) if value else {'type': 'physical'}
             if isinstance(value, dict):
                 if 'type' not in value:
                     value['type'] = 'logical' if value.get('database') and value.get('plugin') else 'physical'
 
-                if value['type'] == 'physical' or value['type'] == 'logical' \
-                        and value.get('database') and value.get('plugin'):
-                    slots[name] = value
+                if value['type'] == 'physical':
+                    if name != my_name:  # Don't try to create permanent physical replication slot for yourself
+                        slots[name] = value
+                    continue
+                elif value['type'] == 'logical' and value.get('database') and value.get('plugin'):
+                    if name in slots:
+                        logger.error("Permanent logical replication slot {'%s': %s} is conflicting with" +
+                                     " physical replication slot for cluster member", name, value)
+                    else:
+                        slots[name] = value
                     continue
 
             logger.error("Bad value for slot '%s' in permanent_slots: %s", name, permanent_slots[name])


Test patch:
diff --git a/tests/test_postgresql.py b/tests/test_postgresql.py
--- a/tests/test_postgresql.py
+++ b/tests/test_postgresql.py
@@ -281,8 +281,8 @@ def test_follow(self):
     @patch.object(Postgresql, 'is_running', Mock(return_value=True))
     def test_sync_replication_slots(self):
         self.p.start()
-        config = ClusterConfig(1, {'slots': {'ls': {'database': 'a', 'plugin': 'b'},
-                                             'A': 0, 'test_3': 0, 'b': {'type': 'logical', 'plugin': '1'}}}, 1)
+        config = ClusterConfig(1, {'slots': {'test_3': {'database': 'a', 'plugin': 'b'},
+                                             'A': 0, 'ls': 0, 'b': {'type': 'logical', 'plugin': '1'}}}, 1)
         cluster = Cluster(True, config, self.leader, 0, [self.me, self.other, self.leadermem], None, None, None)
         with mock.patch('patroni.postgresql.Postgresql._query', Mock(side_effect=psycopg2.OperationalError)):
             self.p.slots_handler.sync_replication_slots(cluster)