Skip to content

Commit 9131940

Browse files
committed
feat: persistence of dynamic tuning changes
Currently, any changes made to the tuning via the `instance_*` dbus calls are lost when tuning is stopped by the service, or when the TuneD service itself is stopped/restarted, or when the service crashes. This commit: * implements a sync of Plugin Instances and Profile Units that is currently missing. This way, dynamic instances and device assignments are persistent across stop/start dbus calls to TuneD. * calculates a hash of the current profile after loading it from disk (after processing all includes, so we have a "flat" representation) * creates snapshots of the current profile whenever instances or assigned devices change. the snapshot includes the hash of the profile as it was initially loaded. for each instance it stores the devices that are currently attached. * restores a snapshot found at startup, if the hashes match (i.e. there have been no profile switches and no changes to the profile or any of its includes on disk) snapshots are restored in case of - daemon restarts (systemctl restart/stop/start) - daemon crashes snapshots are NOT restored in case of - reboots (snapshots are stored in /var/run) - profile changes (snapshots are explicitly deleted when switching profiles, even when "switching" to the same/current profile) Signed-off-by: Adriaan Schmidt <[email protected]>
1 parent a3940f9 commit 9131940

File tree

10 files changed

+206
-28
lines changed

10 files changed

+206
-28
lines changed

tuned/consts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
DEFAULT_STORAGE_FILE = "/run/tuned/save.pickle"
2020
USER_PROFILES_DIR = "/etc/tuned/profiles"
2121
SYSTEM_PROFILES_DIR = "/usr/lib/tuned/profiles"
22+
PROFILE_SNAPSHOT_FILE = "/run/tuned/profile-snapshot.conf"
2223
PERSISTENT_STORAGE_DIR = "/var/lib/tuned"
2324
PLUGIN_MAIN_UNIT_NAME = "main"
2425
# Magic section header because ConfigParser does not support "headerless" config

tuned/daemon/controller.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,7 @@ def instance_acquire_devices(self, devices, instance_name, caller = None):
407407
rets = "Ignoring devices not handled by any instance '%s'." % str(devs)
408408
log.info(rets)
409409
return (False, rets)
410+
self._daemon.sync_instances()
410411
return (True, "OK")
411412

412413
@exports.export("s", "(bsa(ss))")
@@ -472,6 +473,8 @@ def instance_create(self, plugin_name, instance_name, options, caller = None):
472473
"""
473474
if caller == "":
474475
return (False, "Unauthorized")
476+
plugin_name = str(plugin_name)
477+
instance_name = str(instance_name)
475478
if not self._cmd.is_valid_name(plugin_name):
476479
return (False, "Invalid plugin_name")
477480
if not self._cmd.is_valid_name(instance_name):
@@ -519,6 +522,7 @@ def instance_create(self, plugin_name, instance_name, options, caller = None):
519522
other_instance.name, instance.name))
520523
plugin._remove_devices_nocheck(other_instance, devs_moving)
521524
plugin._add_devices_nocheck(instance, devs_moving)
525+
self._daemon.sync_instances()
522526
return (True, "OK")
523527

524528
@exports.export("s", "(bs)")
@@ -561,4 +565,5 @@ def instance_destroy(self, instance_name, caller = None):
561565
for device in devices:
562566
# _add_device() will find a suitable plugin instance
563567
plugin._add_device(device)
568+
self._daemon.sync_instances()
564569
return (True, "OK")

tuned/daemon/daemon.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,11 @@ def _load_profiles(self, profile_names, manual):
121121
self._notify_profile_changed(profile_names, False, errstr)
122122
raise TunedException(errstr)
123123

124+
# restore profile snapshot (if there is one)
125+
snapshot = self._profile_loader.restore_snapshot(self._profile)
126+
if snapshot is not None:
127+
self._profile = snapshot
128+
124129
def set_profile(self, profile_names, manual):
125130
if self.is_running():
126131
errstr = "Cannot set profile while the daemon is running."
@@ -154,6 +159,40 @@ def set_all_profiles(self, active_profiles, manual, post_loaded_profile,
154159
self._save_active_profile(active_profiles, manual)
155160
self._save_post_loaded_profile(post_loaded_profile)
156161

162+
def sync_instances(self):
163+
# NOTE: currently, Controller creates the new instances, and here in Daemon
164+
# we discover what happened, and update the profile accordingly.
165+
# a potentially better approach would be to move some of the logic
166+
# from Controller to Daemon, and create/destroy the instances here,
167+
# and at the same time update the profile.
168+
169+
# remove all units that don't have an instance
170+
instance_names = [i.name for i in self._unit_manager.instances]
171+
for unit in list(self._profile.units.keys()):
172+
if unit in instance_names:
173+
continue
174+
log.debug("snapshot sync: removing unit '%s'" % unit)
175+
del self._profile.units[unit]
176+
# create units for new instances
177+
for instance in self._unit_manager.instances:
178+
if instance.name in self._profile.units:
179+
continue
180+
log.debug("snapshot sync: creating unit '%s'" % instance.name)
181+
config = {
182+
"priority": instance.priority,
183+
"type": instance._plugin.name,
184+
"enabled": instance.active,
185+
"devices": instance.devices_expression,
186+
"devices_udev_regex": instance.devices_udev_regex,
187+
"script_pre": instance.script_pre,
188+
"script_post": instance.script_post,
189+
}
190+
for k, v in instance.options.items():
191+
config[k] = v
192+
self._profile.units[instance.name] = self._profile._create_unit(instance.name, config)
193+
# create profile snapshot
194+
self._profile_loader.create_snapshot(self._profile, self._unit_manager.instances)
195+
157196
@property
158197
def profile(self):
159198
return self._profile
@@ -200,6 +239,8 @@ def _thread_code(self):
200239
self._save_active_profile(" ".join(self._active_profiles),
201240
self._manual)
202241
self._save_post_loaded_profile(self._post_loaded_profile)
242+
# trigger a profile snapshot
243+
self.sync_instances()
203244
self._unit_manager.start_tuning()
204245
self._profile_applied.set()
205246
log.info("static tuning from profile '%s' applied" % self._profile.name)
@@ -368,6 +409,7 @@ def stop(self, profile_switch = False):
368409
return False
369410
log.info("stopping tuning")
370411
if profile_switch:
412+
self._profile_loader.remove_snapshot()
371413
self._terminate_profile_switch.set()
372414
self._terminate.set()
373415
self._thread.join()

tuned/plugins/base.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,23 +164,35 @@ def _get_matching_devices(self, instance, devices):
164164
udev_devices = self._device_matcher_udev.match_list(instance.devices_udev_regex, udev_devices)
165165
return set([x.sys_name for x in udev_devices])
166166

167+
def restore_devices(self, instance, devices):
168+
if not self._devices_supported:
169+
return
170+
171+
log.debug("Restoring devices of instance %s: %s" % (instance.name, " ".join(devices)))
172+
for device in devices:
173+
if device not in self._free_devices:
174+
continue
175+
self._free_devices.remove(device)
176+
instance.assigned_devices.add(device)
177+
self._assigned_devices.add(device)
178+
167179
def assign_free_devices(self, instance):
168180
if not self._devices_supported:
169181
return
170182

171183
log.debug("assigning devices to instance %s" % instance.name)
172184
to_assign = self._get_matching_devices(instance, self._free_devices)
173-
instance.active = len(to_assign) > 0
174-
if not instance.active:
175-
log.warning("instance %s: no matching devices available" % instance.name)
176-
else:
185+
if len(to_assign) > 0:
177186
name = instance.name
178187
if instance.name != self.name:
179188
name += " (%s)" % self.name
180189
log.info("instance %s: assigning devices %s" % (name, ", ".join(to_assign)))
181190
instance.assigned_devices.update(to_assign) # cannot use |=
182191
self._assigned_devices |= to_assign
183192
self._free_devices -= to_assign
193+
instance.active = len(instance.assigned_devices) > 0
194+
if not instance.active:
195+
log.warning("instance %s: no matching devices available" % instance.name)
184196

185197
def release_devices(self, instance):
186198
if not self._devices_supported:

tuned/profiles/factory.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import tuned.profiles.profile
22

33
class Factory(object):
4-
def create(self, name, config):
5-
return tuned.profiles.profile.Profile(name, config)
4+
def create(self, name, config, variables):
5+
return tuned.profiles.profile.Profile(name, config, variables)

tuned/profiles/loader.py

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,6 @@ def __init__(self, profile_locator, profile_factory, profile_merger, global_conf
2424
self._global_config = global_config
2525
self._variables = variables
2626

27-
def _create_profile(self, profile_name, config):
28-
return tuned.profiles.profile.Profile(profile_name, config)
29-
3027
@classmethod
3128
def safe_name(cls, profile_name):
3229
return re.match(r'^[a-zA-Z0-9_.-]+$', profile_name)
@@ -57,22 +54,40 @@ def load(self, profile_names):
5754
final_profile = profiles[0]
5855

5956
final_profile.name = " ".join(profile_names)
60-
if "variables" in final_profile.units:
61-
self._variables.add_from_cfg(final_profile.units["variables"].options)
62-
del(final_profile.units["variables"])
63-
# FIXME hack, do all variable expansions in one place
64-
self._expand_vars_in_devices(final_profile)
65-
self._expand_vars_in_regexes(final_profile)
57+
final_profile.process_variables()
58+
final_profile.calculate_hash()
6659
return final_profile
6760

68-
def _expand_vars_in_devices(self, profile):
69-
for unit in profile.units:
70-
profile.units[unit].devices = self._variables.expand(profile.units[unit].devices)
71-
72-
def _expand_vars_in_regexes(self, profile):
73-
for unit in profile.units:
74-
profile.units[unit].cpuinfo_regex = self._variables.expand(profile.units[unit].cpuinfo_regex)
75-
profile.units[unit].uname_regex = self._variables.expand(profile.units[unit].uname_regex)
61+
def create_snapshot(self, profile, instances):
62+
snapshot = profile.snapshot(instances)
63+
log.debug("Storing profile snapshot in %s:\n%s" % (consts.PROFILE_SNAPSHOT_FILE, snapshot))
64+
with open(consts.PROFILE_SNAPSHOT_FILE, "w") as f:
65+
f.write(snapshot)
66+
67+
def restore_snapshot(self, profile):
68+
snapshot = None
69+
if os.path.isfile(consts.PROFILE_SNAPSHOT_FILE):
70+
log.debug("Found profile snapshot '%s'" % consts.PROFILE_SNAPSHOT_FILE)
71+
try:
72+
config = self._load_config_data(consts.PROFILE_SNAPSHOT_FILE)
73+
snapshot_hash = config.get("main", {}).get("profile_base_hash", None)
74+
if snapshot_hash == profile._base_hash:
75+
snapshot = self._profile_factory.create("restore", config, self._variables)
76+
snapshot.name = profile.name
77+
snapshot.process_variables()
78+
log.info("Restored profile snapshot: %s" % snapshot.name)
79+
else:
80+
log.debug("Snapshot hash '%s' does not match current base hash '%s'. Not restoring." % (snapshot_hash, profile._base_hash))
81+
os.remove(consts.PROFILE_SNAPSHOT_FILE)
82+
except InvalidProfileException as e:
83+
log.error("Could not process profile snapshot: %s" % e)
84+
return snapshot
85+
86+
def remove_snapshot(self):
87+
try:
88+
os.remove(consts.PROFILE_SNAPSHOT_FILE)
89+
except FileNotFoundError:
90+
pass
7691

7792
def _load_profile(self, profile_names, profiles, processed_files):
7893
for name in profile_names:
@@ -84,9 +99,9 @@ def _load_profile(self, profile_names, profiles, processed_files):
8499
processed_files.append(filename)
85100

86101
config = self._load_config_data(filename)
87-
profile = self._profile_factory.create(name, config)
102+
profile = self._profile_factory.create(name, config, self._variables)
88103
if "include" in profile.options:
89-
include_names = re.split(r"\s*[,;]\s*", self._variables.expand(profile.options.pop("include")))
104+
include_names = re.split(r"\s*[,;]\s*", profile._variables.expand(profile.options.pop("include")))
90105
self._load_profile(include_names, profiles, processed_files)
91106

92107
profiles.append(profile)

tuned/profiles/profile.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,28 @@
11
import tuned.profiles.unit
2+
import tuned.profiles.variables
23
import tuned.consts as consts
34
import collections
5+
import hashlib
6+
import json
47

58
class Profile(object):
69
"""
710
Representation of a tuning profile.
811
"""
912

10-
__slots__ = ["_name", "_options", "_units"]
13+
__slots__ = ["_name", "_options", "_units", "_variables", "_base_hash"]
1114

12-
def __init__(self, name, config):
15+
def __init__(self, name, config, variables):
1316
self._name = name
1417
self._init_options(config)
1518
self._init_units(config)
19+
self._variables = variables
20+
self._base_hash = config.get("main", {}).get("profile_base_hash", None)
1621

1722
def _init_options(self, config):
1823
self._options = {}
1924
if consts.PLUGIN_MAIN_UNIT_NAME in config:
20-
self._options = dict(config[consts.PLUGIN_MAIN_UNIT_NAME])
25+
self._options = collections.OrderedDict(config[consts.PLUGIN_MAIN_UNIT_NAME])
2126

2227
def _init_units(self, config):
2328
self._units = collections.OrderedDict()
@@ -29,6 +34,43 @@ def _init_units(self, config):
2934
def _create_unit(self, name, config):
3035
return tuned.profiles.unit.Unit(name, config)
3136

37+
def process_variables(self):
38+
if "variables" in self.units:
39+
self._variables.add_from_cfg(self.units["variables"].options)
40+
del(self.units["variables"])
41+
# FIXME hack, do all variable expansions in one place
42+
for unit in self.units:
43+
self.units[unit].devices = self._variables.expand(self.units[unit].devices)
44+
self.units[unit].cpuinfo_regex = self._variables.expand(self.units[unit].cpuinfo_regex)
45+
self.units[unit].uname_regex = self._variables.expand(self.units[unit].uname_regex)
46+
47+
def as_ordered_dict(self):
48+
"""generate serializable (with json.dumps()) representation for hashing"""
49+
profile_dict = collections.OrderedDict()
50+
profile_dict["main"] = self.options
51+
profile_dict["variables"] = self._variables.as_ordered_dict()
52+
for name, unit in self._units.items():
53+
profile_dict[name] = unit.as_ordered_dict()
54+
return profile_dict
55+
56+
def calculate_hash(self):
57+
serialized = json.dumps(self.as_ordered_dict())
58+
self._base_hash = hashlib.md5(serialized.encode(), usedforsecurity=False).hexdigest()
59+
60+
def snapshot(self, instances):
61+
"""generate config representation that will re-create the data when read as a profile"""
62+
snapshot = "[main]\n"
63+
snapshot += "active_profile=%s\n" % self.name
64+
snapshot += "profile_base_hash=%s\n" % self._base_hash
65+
snapshot += "\n" + self._variables.snapshot()
66+
for unit in self.units.values():
67+
snapshot += "\n" + unit.snapshot()
68+
for instance in instances:
69+
if instance.name == unit.name:
70+
snapshot += "__devices__=%s\n" % " ".join(instance.assigned_devices | instance.processed_devices)
71+
break
72+
return snapshot
73+
3274
@property
3375
def name(self):
3476
"""

tuned/profiles/unit.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,46 @@ def __init__(self, name, config):
2626
self._script_post = config.pop("script_post", None)
2727
self._options = collections.OrderedDict(config)
2828

29+
def as_ordered_dict(self):
30+
"""generate serializable (with json.dumps()) representation for hashing"""
31+
ret = collections.OrderedDict()
32+
ret["name"] = self.name
33+
ret["priority"] = self.priority
34+
ret["type"] = self.type
35+
ret["enabled"] = self.enabled
36+
ret["replace"] = self.replace
37+
ret["drop"] = self.drop
38+
ret["devices"] = self.devices
39+
ret["devices_udev_regex"] = self.devices_udev_regex
40+
ret["cpuinfo_regex"] = self.cpuinfo_regex
41+
ret["uname_regex"] = self.uname_regex
42+
ret["script_pre"] = self.script_pre
43+
ret["script_post"] = self.script_post
44+
for k, v in self.options.items():
45+
ret[k] = v
46+
return ret
47+
48+
def snapshot(self):
49+
"""generate config representation that will re-create the data when read as a profile"""
50+
snapshot = "[%s]\n" % self.name
51+
snapshot += "priority=%s\n" % self.priority
52+
snapshot += "type=%s\n" % self.type
53+
snapshot += "enabled=%s\n" % self.enabled
54+
snapshot += "devices=%s\n" % self.devices
55+
if self.devices_udev_regex is not None:
56+
snapshot += "devices_udev_regex=%s\n" % self.devices_udev_regex
57+
if self.cpuinfo_regex is not None:
58+
snapshot += "cpuinfo_regex=%s\n" % self.cpuinfo_regex
59+
if self.uname_regex is not None:
60+
snapshot += "uname_regex=%s\n" % self.uname_regex
61+
if self.script_pre is not None:
62+
snapshot += "script_pre=%s\n" % self.script_pre
63+
if self.script_post is not None:
64+
snapshot += "script_post=%s\n" % self.script_post
65+
for k, v in self.options.items():
66+
snapshot += "%s=%s\n" % (k, v)
67+
return snapshot
68+
2969
@property
3070
def name(self):
3171
return self._name

tuned/profiles/variables.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import collections
12
import os
23
import re
34
import tuned.logs
@@ -15,6 +16,7 @@ class Variables():
1516

1617
def __init__(self):
1718
self._cmd = commands()
19+
self._raw = collections.OrderedDict()
1820
self._lookup_re = {}
1921
self._lookup_env = {}
2022
self._functions = functions.Repository()
@@ -34,6 +36,7 @@ def add_variable(self, variable, value):
3436
if not self._check_var(variable):
3537
log.error("variable definition '%s' contains unallowed characters" % variable)
3638
return
39+
self._raw[s] = value
3740
v = self.expand(value)
3841
# variables referenced by ${VAR}, $ can be escaped by two $,
3942
# i.e. the following will not expand: $${VAR}
@@ -77,3 +80,14 @@ def expand(self, value):
7780

7881
def get_env(self):
7982
return self._lookup_env
83+
84+
def as_ordered_dict(self):
85+
"""generate serializable (with json.dumps()) representation for hashing"""
86+
return self._raw
87+
88+
def snapshot(self):
89+
"""generate config representation that will re-create the data when read as a profile"""
90+
snapshot = "[variables]\n"
91+
for k, v in self._raw.items():
92+
snapshot += "%s=%s\n" % (k, v)
93+
return snapshot

0 commit comments

Comments
 (0)