Skip to content

Commit

Permalink
Allow autosave backups to be overwritten without timestamps
Browse files Browse the repository at this point in the history
fix docs
  • Loading branch information
jsouter committed Aug 7, 2024
1 parent 801cc9a commit 9b800b0
Show file tree
Hide file tree
Showing 4 changed files with 72 additions and 47 deletions.
4 changes: 2 additions & 2 deletions docs/examples/example_autosave_ioc.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@
# Create records, set some of them to autosave, also save some of their fields

builder.aOut("AO", autosave=True)
builder.aIn("AI", autosave_fields=["PREC", "SCAN"])
builder.aIn("AI", autosave_fields=["PREC", "EGU"])
builder.boolIn("BO")
builder.WaveformIn("WAVEFORMOUT", [0, 0, 0, 0], autosave=True)
minutes = builder.longOut("MINUTESRUN", autosave=True)

autosave.configure(
directory="/tmp/autosave-data/MY-DEVICE-PREFIX",
directory="/tmp/autosave-data",
name="MY-DEVICE-PREFIX",
save_period=20.0
)
Expand Down
13 changes: 7 additions & 6 deletions docs/how-to/use-autosave-in-an-ioc.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Autosave is disabled by default until `autosave.configure()` is called. The firs
set to 30.0 by default. The directory must exist, and should be configured with the appropriate
read/write permissions for the user running the IOC.

IOC developers should only need to interface with autosave via the `autosave.configre()`
IOC developers should only need to interface with autosave via the `autosave.configure()`
method and the ``autosave`` and ``autosave_fields`` keyword arguments,
all other module members are intended for internal use only.

Expand All @@ -38,18 +38,19 @@ Users are discouraged from manually editing the backup files while the
IOC is running so that the internal state of the autosave thread is consistent with
the backup file.

If autosave is enabled and active, a timestamped backup file of the latest existing backup file is created
when the IOC is restarted, e.g. ``<name>.softsave-240717-095004`` (timestamps are in the format yymmdd-HHMMSS).
This can be disabled by passing ``backup_on_load=False`` to `autosave.configure()`.
To disable any autosaving, comment out the `autosave.configre()` call or pass it the keyword argument
If autosave is enabled and active, a timestamped copy of the latest existing autosave backup file is created
when the IOC is restarted, e.g. ``<name>.softsav_240717-095004`` (timestamps are in the format yymmdd-HHMMSS).
If you only wish to store one backup of the autosave file at a time, ``timestamped_backups=False`` can be passed to `autosave.configure()`,
this will create a backup file named ``<name>.softsav.bu``.
To disable any autosaving, comment out the `autosave.configure()` call or pass it the keyword argument
``enabled=False``.

The resulting backup file after running the IOC for a minute is the following:

.. code-block::
AI.EGU: ''
AI.PREC: '0'
AI.SCAN: I/O Intr
AO: 0.0
MINUTESRUN: 1
WAVEFORMOUT: [0, 0, 0, 0]
Expand Down
65 changes: 39 additions & 26 deletions softioc/autosave.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ def _ndarray_representer(dumper, array):


def configure(
directory, name, save_period=DEFAULT_SAVE_PERIOD, backup=True, enabled=True
directory,
name,
save_period=DEFAULT_SAVE_PERIOD,
timestamped_backups=True,
enabled=True
):
"""This should be called before initialising the IOC. Configures the
autosave thread for periodic backing up of PV values.
Expand All @@ -34,13 +38,14 @@ def configure(
is usually the same as the device prefix.
save_period: time in seconds between backups. Backups are only performed
if PV values have changed.
backup: creates a backup of the loaded autosave file on load,
timestamped with the time of backup.
timestamped_backups: boolean which determines if backups of existing
autosave files are timestamped on IOC restart. True by default, if
False then backups get overwritten on each IOC restart.
enabled: boolean which enables or disables autosave, set to True by
default, or False if configure not called.
"""
Autosave.directory = Path(directory)
Autosave.backup_on_load = backup
Autosave.timestamped_backups = timestamped_backups
Autosave.save_period = save_period
Autosave.enabled = enabled
Autosave.device_name = name
Expand Down Expand Up @@ -103,7 +108,7 @@ class Autosave:
device_name = None
directory = None
enabled = False
backup_on_load = False
timestamped_backups = True

def __init__(self):
if not self.enabled:
Expand All @@ -129,39 +134,48 @@ def __init__(self):
self._last_saved_time = datetime.now()

@classmethod
def _backup_sav_file(cls):
def __backup_sav_file(cls):
if not cls.directory and cls.directory.is_dir():
print(
f"Could not back up autosave as {cls.directory} is"
" not a valid directory",
file=sys.stderr,
)
return
sav_path = cls._get_current_sav_path()
sav_path = cls.__get_current_sav_path()
if cls.timestamped_backups:
backup_path = cls.__get_timestamped_backup_sav_path()
else:
backup_path = cls.__get_backup_sav_path()
if sav_path.is_file():
copy2(sav_path, cls._get_timestamped_backup_sav_path())
copy2(sav_path, backup_path)
else:
print(
f"Could not back up autosave, {sav_path} is not a file",
file=sys.stderr,
)

@classmethod
def _get_timestamped_backup_sav_path(cls):
sav_path = cls._get_current_sav_path()
def __get_timestamped_backup_sav_path(cls):
sav_path = cls.__get_current_sav_path()
return sav_path.parent / (
sav_path.name + cls._last_saved_time.strftime("_%y%m%d-%H%M%S")
)

@classmethod
def _get_backup_sav_path(cls):
def __get_backup_sav_path(cls):
sav_path = cls.__get_current_sav_path()
return sav_path.parent / (sav_path.name + ".bu")

@classmethod
def __get_tmp_sav_path(cls):
return cls.directory / f"{cls.device_name}.{SAVB_SUFFIX}"

@classmethod
def _get_current_sav_path(cls):
def __get_current_sav_path(cls):
return cls.directory / f"{cls.device_name}.{SAV_SUFFIX}"

def _get_state(self):
def __get_state(self):
state = {}
for pv_field, pv in self._pvs.items():
try:
Expand All @@ -172,7 +186,7 @@ def _get_state(self):
return state

@classmethod
def _set_pvs_from_saved_state(cls):
def __set_pvs_from_saved_state(cls):
for pv_field, value in cls._last_saved_state.items():
try:
pv = cls._pvs[pv_field]
Expand All @@ -184,33 +198,32 @@ def _set_pvs_from_saved_state(cls):
)
traceback.print_exc()

def _state_changed(self, state):
def __state_changed(self, state):
return self._last_saved_state.keys() != state.keys() or any(
# checks equality for builtins and numpy arrays
not numpy.array_equal(state[key], self._last_saved_state[key])
for key in state
)

def _save(self):
state = self._get_state()
if self._state_changed(state):
sav_path = self._get_current_sav_path()
backup_path = self._get_backup_sav_path()
# write to backup file first then use atomic os.rename
state = self.__get_state()
if self.__state_changed(state):
sav_path = self.__get_current_sav_path()
tmp_path = self.__get_tmp_sav_path()
# write to temporary file first then use atomic os.rename
# to safely update stored state
with open(backup_path, "w") as backup:
with open(tmp_path, "w") as backup:
yaml.dump(state, backup, indent=4)
rename(backup_path, sav_path)
rename(tmp_path, sav_path)
self._last_saved_state = state
self._last_saved_time = datetime.now()

@classmethod
def _load(cls, path=None):
if not cls.enabled or not cls._pvs:
return
if cls.backup_on_load:
cls._backup_sav_file()
sav_path = path or cls._get_current_sav_path()
cls.__backup_sav_file()
sav_path = path or cls.__get_current_sav_path()
if not sav_path or not sav_path.is_file():
print(
f"Could not load autosave values from file {sav_path}",
Expand All @@ -219,7 +232,7 @@ def _load(cls, path=None):
return
with open(sav_path, "r") as f:
cls._last_saved_state = yaml.full_load(f)
cls._set_pvs_from_saved_state()
cls.__set_pvs_from_saved_state()

def stop(self):
self._stop_event.set()
Expand Down
37 changes: 24 additions & 13 deletions tests/test_autosave.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def reset_autosave_setup_teardown():
default_device_name = autosave.Autosave.device_name
default_directory = autosave.Autosave.directory
default_enabled = autosave.Autosave.enabled
default_bol = autosave.Autosave.backup_on_load
default_tb = autosave.Autosave.timestamped_backups
yield
autosave.Autosave._pvs = default_pvs
autosave.Autosave._last_saved_state = default_state
Expand All @@ -28,7 +28,7 @@ def reset_autosave_setup_teardown():
autosave.Autosave.device_name = default_device_name
autosave.Autosave.directory = default_directory
autosave.Autosave.enabled = default_enabled
autosave.Autosave.backup_on_load = default_bol
autosave.Autosave.timestamped_backups = default_tb
if builder.GetRecordNames().prefix: # reset device name to empty if set
builder.SetDeviceName("")

Expand Down Expand Up @@ -58,6 +58,8 @@ def existing_autosave_dir(tmp_path):
}
with open(tmp_path / f"{DEVICE_NAME}.softsav", "w") as f:
yaml.dump(state, f, indent=4)
with open(tmp_path / f"{DEVICE_NAME}.softsav.bu", "w") as f:
yaml.dump({"OUT-OF-DATE-KEY": "out of date value"}, f, indent=4)
return tmp_path


Expand All @@ -78,7 +80,7 @@ def test_autosave_defaults():
assert autosave.Autosave.device_name is None
assert autosave.Autosave.directory is None
assert autosave.Autosave.enabled is False
assert autosave.Autosave.backup_on_load is False
assert autosave.Autosave.timestamped_backups is True


def test_configure_dir_doesnt_exist(tmp_path):
Expand Down Expand Up @@ -177,19 +179,28 @@ def test_stop_event(tmp_path):
worker.join(timeout=1)


def test_backup_on_load(existing_autosave_dir):
autosave.configure(existing_autosave_dir, DEVICE_NAME, backup=True)
@pytest.mark.parametrize(
"timestamped,regex",
[(False, r"^" + DEVICE_NAME + r"\.softsav_[0-9]{6}-[0-9]{6}$"),
(True, r"^" + DEVICE_NAME + r"\.softsav\.bu$")]
)
def test_backup_on_load(existing_autosave_dir, timestamped, regex):
autosave.configure(existing_autosave_dir,
DEVICE_NAME,
timestamped_backups=timestamped
)
# backup only performed if there are any pvs to save
builder.aOut("SAVED-AO", autosave=True)
autosave.load_autosave()
backup_files = list(existing_autosave_dir.glob("*.softsav_*"))
assert len(backup_files) == 1
# assert backup file is named <name>.softsave_yymmdd-HHMMSS
for file in backup_files:
assert re.match(
r"^" + DEVICE_NAME + r"\.softsav_[0-9]{6}-[0-9]{6}$", file.name
)

# assert backup is <name>.softsav_yymmdd-HHMMSS or <name>.softsav.bu
any(re.match(regex, file.name) for file in backup_files)
if not timestamped:
# test that existing .bu file gets overwritten
with open(existing_autosave_dir / f"{DEVICE_NAME}.softsav.bu") as f:
state = yaml.full_load(f)
assert "OUT-OF-DATE-KEY" not in state
assert "SAVED-AO" in state

def test_autosave_key_names(tmp_path):
builder.aOut("DEFAULTNAME", autosave=True)
Expand All @@ -206,7 +217,7 @@ def test_autosave_key_names(tmp_path):

def check_all_record_types_load_properly(device_name, autosave_dir, conn):
builder.SetDeviceName(device_name)
autosave.configure(autosave_dir, device_name, backup=False)
autosave.configure(autosave_dir, device_name)
pv_aOut = builder.aOut("SAVED-AO", autosave=True)
pv_aIn = builder.aIn("SAVED-AI", autosave=True)
pv_boolOut = builder.boolOut("SAVED-BO", autosave=True)
Expand Down

0 comments on commit 9b800b0

Please sign in to comment.