Skip to content

Commit d1e57bf

Browse files
Merge pull request #136 from aiven/kathiabarahona/disable_backups_until
Support backup intervals greater than 24 hours #136
2 parents 053aa7a + 01d1328 commit d1e57bf

File tree

3 files changed

+102
-21
lines changed

3 files changed

+102
-21
lines changed

myhoard/controller.py

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -851,6 +851,17 @@ def _create_restore_coordinator_if_missing(self):
851851
else:
852852
self.log.info("Newly created restore coordinator is already in completed state")
853853

854+
def _previous_normalized_backup_timestamp(self) -> Optional[str]:
855+
normalized_backup_times = [
856+
stream.state["normalized_backup_time"]
857+
for stream in self.backup_streams
858+
if stream.state["normalized_backup_time"]
859+
]
860+
861+
if not normalized_backup_times:
862+
return None
863+
return max(normalized_backup_times)
864+
854865
def _current_normalized_backup_timestamp(self) -> str:
855866
"""Returns the closest historical backup time that current time matches to (or current time if it matches).
856867
E.g. if backup hour is 13, backup minute is 50, current time is 15:40 and backup interval is 60 minutes,
@@ -861,11 +872,19 @@ def _current_normalized_backup_timestamp(self) -> str:
861872
backup_interval_minutes = self.backup_settings["backup_interval_minutes"]
862873
backup_hour = self.backup_settings["backup_hour"]
863874
backup_minute = self.backup_settings["backup_minute"]
864-
if normalized.hour < backup_hour or (normalized.hour == backup_hour and normalized.minute < backup_minute):
865-
normalized = normalized - datetime.timedelta(days=1)
866-
normalized = normalized.replace(hour=backup_hour, minute=backup_minute, second=0, microsecond=0)
867-
while normalized + datetime.timedelta(minutes=backup_interval_minutes) < now:
875+
876+
previous_normalized = self._previous_normalized_backup_timestamp()
877+
# in case we have a previous backup time, we can use it as starting point
878+
if previous_normalized:
879+
normalized = datetime.datetime.fromisoformat(previous_normalized)
880+
else:
881+
if normalized.hour < backup_hour or (normalized.hour == backup_hour and normalized.minute < backup_minute):
882+
normalized = normalized - datetime.timedelta(days=1)
883+
normalized = normalized.replace(hour=backup_hour, minute=backup_minute, second=0, microsecond=0)
884+
885+
while normalized + datetime.timedelta(minutes=backup_interval_minutes) <= now:
868886
normalized = normalized + datetime.timedelta(minutes=backup_interval_minutes)
887+
869888
return normalized.isoformat()
870889

871890
def _determine_unapplied_remote_binlogs(self, stream):
@@ -1177,23 +1196,14 @@ def _mark_failed_restore_backup_as_broken(self) -> None:
11771196

11781197
def _mark_periodic_backup_requested_if_interval_exceeded(self):
11791198
normalized_backup_time = self._current_normalized_backup_timestamp()
1180-
most_recent_scheduled = None
1181-
last_normalized_backup_time = None
1182-
if self.backup_streams:
1183-
normalized_backup_times = [
1184-
stream.state["normalized_backup_time"]
1185-
for stream in self.backup_streams
1186-
if stream.state["normalized_backup_time"]
1187-
]
1188-
if normalized_backup_times:
1189-
last_normalized_backup_time = max(normalized_backup_times)
1190-
scheduled_streams = [
1191-
stream
1192-
for stream in self.backup_streams
1193-
if stream.state["backup_reason"] == BackupStream.BackupReason.scheduled
1194-
]
1195-
if scheduled_streams:
1196-
most_recent_scheduled = max(stream.created_at for stream in scheduled_streams)
1199+
last_normalized_backup_time = self._previous_normalized_backup_timestamp()
1200+
1201+
scheduled_streams_created_at = [
1202+
stream.created_at
1203+
for stream in self.backup_streams
1204+
if stream.state["backup_reason"] == BackupStream.BackupReason.scheduled
1205+
]
1206+
most_recent_scheduled = max(scheduled_streams_created_at) if scheduled_streams_created_at else None
11971207

11981208
# Don't create new backup unless at least half of interval has elapsed since scheduled last backup. Otherwise
11991209
# we would end up creating a new backup each time when backup hour/minute changes, which is typically undesired.

requirements.dev.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,6 @@ pytest-mock
1717
pytest-timeout
1818
pytest-xdist
1919
responses
20+
time-machine
2021
types-PyMySQL
2122
types-requests

test/test_controller.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from unittest.mock import MagicMock, patch
1818

1919
import contextlib
20+
import datetime
2021
import os
2122
import pytest
2223
import random
@@ -1287,6 +1288,75 @@ def test_collect_binlogs_to_purge():
12871288
log.info.assert_any_call("Binlog %s has been replicated to all servers, purging", 3)
12881289

12891290

1291+
def test_periodic_backup_based_on_exceeded_intervals(time_machine, master_controller) -> None:
1292+
# pylint: disable=protected-access
1293+
time_machine.move_to("2023-01-02T18:00:00")
1294+
1295+
# By default backup_hour = 3, backup_interval_minutes = 1440
1296+
m_controller, master = master_controller
1297+
1298+
m_controller.switch_to_active_mode()
1299+
m_controller.start()
1300+
1301+
def streaming_binlogs(controller: Controller, expected_completed_backups: int):
1302+
assert controller.backup_streams
1303+
assert controller.backup_streams[0].active_phase == BackupStream.ActivePhase.binlog
1304+
1305+
complete_backups = [backup for backup in controller.state["backups"] if backup["completed_at"]]
1306+
assert len(complete_backups) == expected_completed_backups
1307+
1308+
def flush_binlogs():
1309+
with mysql_cursor(**master.connect_options) as cursor:
1310+
cursor.execute("FLUSH BINARY LOGS")
1311+
1312+
# write some data for the first backup
1313+
flush_binlogs()
1314+
# first backup for 2023-01-02 3:00 should be generated at 6pm (time we started the service)
1315+
while_asserts(lambda: streaming_binlogs(m_controller, 1), timeout=10)
1316+
1317+
# generate more data for second backup
1318+
flush_binlogs()
1319+
1320+
# second backup ideally should be done at 2023-01-03 03:00, but following the
1321+
# "half of interval" rule (at least 12 hours of difference between most recent scheduled and current time)
1322+
# therefore, it should be actually scheduled at 2023-01-03 06:00
1323+
time_machine.move_to("2023-01-03T03:00:00+00:00")
1324+
1325+
expected_normalized_time = datetime.datetime(2023, 1, 3, 3, tzinfo=datetime.timezone.utc)
1326+
assert m_controller._current_normalized_backup_timestamp() == expected_normalized_time.isoformat()
1327+
1328+
# no new backup should be scheduled
1329+
time.sleep(1)
1330+
min_created_at = datetime.datetime(2023, 1, 3, 3, tzinfo=datetime.timezone.utc).timestamp()
1331+
assert not any(bs.created_at >= min_created_at for bs in m_controller.backup_streams)
1332+
1333+
time_machine.move_to("2023-01-03T06:00:00+00:00")
1334+
while_asserts(lambda: streaming_binlogs(m_controller, 2), timeout=10)
1335+
1336+
# generate more data for third backup
1337+
flush_binlogs()
1338+
1339+
# After second backup, the next scheduled one should be at 2023-02-04 03:00:00, but let's change
1340+
# backup_interval_minutes to 2880 (48 hours)
1341+
m_controller.backup_settings["backup_interval_minutes"] = 2880
1342+
1343+
time_machine.move_to("2023-01-04T06:00:00+00:00")
1344+
1345+
expected_normalized_time = datetime.datetime(2023, 1, 3, 3, tzinfo=datetime.timezone.utc)
1346+
assert m_controller._current_normalized_backup_timestamp() == expected_normalized_time.isoformat()
1347+
1348+
time.sleep(1)
1349+
# no new backup should be scheduled
1350+
min_created_at = datetime.datetime(2023, 1, 4, 3, tzinfo=datetime.timezone.utc).timestamp()
1351+
assert not any(bs.created_at >= min_created_at for bs in m_controller.backup_streams)
1352+
1353+
time_machine.move_to("2023-01-05T03:00:00+00:00")
1354+
expected_normalized_time = datetime.datetime(2023, 1, 5, 3, tzinfo=datetime.timezone.utc)
1355+
1356+
assert m_controller._current_normalized_backup_timestamp() == expected_normalized_time.isoformat()
1357+
while_asserts(lambda: streaming_binlogs(m_controller, 3), timeout=10)
1358+
1359+
12901360
@patch.object(RestoreCoordinator, "MAX_BASEBACKUP_ERRORS", 2)
12911361
@patch.object(BasebackupRestoreOperation, "restore_backup", side_effect=Exception("failed restoring basebackup"))
12921362
def test_backup_marked_as_broken_after_failed_restoration(

0 commit comments

Comments
 (0)