Skip to content

Commit f0bdfb8

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 f0bdfb8

File tree

12 files changed

+229
-44
lines changed

12 files changed

+229
-44
lines changed

tests/unit/profiles/test_merger.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,23 @@
11
import unittest
22
from tuned.profiles.merger import Merger
33
from tuned.profiles.profile import Profile
4+
from tuned.profiles.variables import Variables
45
from collections import OrderedDict
56

67
class MergerTestCase(unittest.TestCase):
78
def test_merge_without_replace(self):
89
merger = Merger()
10+
variables = Variables()
911
config1 = OrderedDict([
1012
("main", {"test_option" : "test_value1"}),
1113
("net", { "devices": "em0", "custom": "custom_value"}),
1214
])
13-
profile1 = Profile('test_profile1',config1)
15+
profile1 = Profile('test_profile1',config1,variables)
1416
config2 = OrderedDict([
1517
('main', {'test_option' : 'test_value2'}),
1618
('net', { 'devices': 'em1' }),
1719
])
18-
profile2 = Profile("test_profile2",config2)
20+
profile2 = Profile("test_profile2",config2,variables)
1921

2022
merged_profile = merger.merge([profile1, profile2])
2123

@@ -27,16 +29,17 @@ def test_merge_without_replace(self):
2729

2830
def test_merge_with_replace(self):
2931
merger = Merger()
32+
variables = Variables()
3033
config1 = OrderedDict([
3134
("main", {"test_option" : "test_value1"}),
3235
("net", { "devices": "em0", "custom": "option"}),
3336
])
34-
profile1 = Profile('test_profile1',config1)
37+
profile1 = Profile('test_profile1',config1,variables)
3538
config2 = OrderedDict([
3639
("main", {"test_option" : "test_value2"}),
3740
("net", { "devices": "em1", "replace": True }),
3841
])
39-
profile2 = Profile('test_profile2',config2)
42+
profile2 = Profile('test_profile2',config2,variables)
4043
merged_profile = merger.merge([profile1, profile2])
4144

4245
self.assertEqual(merged_profile.options["test_option"],"test_value2")
@@ -46,15 +49,16 @@ def test_merge_with_replace(self):
4649

4750
def test_merge_multiple_order(self):
4851
merger = Merger()
52+
variables = Variables()
4953
config1 = OrderedDict([ ("main", {"test_option" : "test_value1"}),\
5054
("net", { "devices": "em0" }) ])
51-
profile1 = Profile('test_profile1',config1)
55+
profile1 = Profile('test_profile1',config1,variables)
5256
config2 = OrderedDict([ ("main", {"test_option" : "test_value2"}),\
5357
("net", { "devices": "em1" }) ])
54-
profile2 = Profile('test_profile2',config2)
58+
profile2 = Profile('test_profile2',config2,variables)
5559
config3 = OrderedDict([ ("main", {"test_option" : "test_value3"}),\
5660
("net", { "devices": "em2" }) ])
57-
profile3 = Profile('test_profile3',config3)
61+
profile3 = Profile('test_profile3',config3,variables)
5862
merged_profile = merger.merge([profile1, profile2, profile3])
5963

6064
self.assertEqual(merged_profile.options["test_option"],"test_value3")

tests/unit/profiles/test_profile.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,33 +9,33 @@ def _create_unit(self, name, config):
99
class ProfileTestCase(unittest.TestCase):
1010

1111
def test_init(self):
12-
MockProfile("test", {})
12+
MockProfile("test", {}, None)
1313

1414
def test_create_units(self):
1515
profile = MockProfile("test", {
1616
"main": { "anything": 10 },
1717
"network" : { "type": "net", "devices": "*" },
1818
"storage" : { "type": "disk" },
19-
})
19+
}, None)
2020

2121
self.assertIs(type(profile.units), collections.OrderedDict)
2222
self.assertEqual(len(profile.units), 2)
2323
self.assertListEqual(sorted([name_config for name_config in profile.units]), sorted(["network", "storage"]))
2424

2525
def test_create_units_empty(self):
26-
profile = MockProfile("test", {"main":{}})
26+
profile = MockProfile("test", {"main":{}}, None)
2727

2828
self.assertIs(type(profile.units), collections.OrderedDict)
2929
self.assertEqual(len(profile.units), 0)
3030

3131
def test_sets_name(self):
32-
profile1 = MockProfile("test_one", {})
33-
profile2 = MockProfile("test_two", {})
32+
profile1 = MockProfile("test_one", {}, None)
33+
profile2 = MockProfile("test_two", {}, None)
3434
self.assertEqual(profile1.name, "test_one")
3535
self.assertEqual(profile2.name, "test_two")
3636

3737
def test_change_name(self):
38-
profile = MockProfile("oldname", {})
38+
profile = MockProfile("oldname", {}, None)
3939
self.assertEqual(profile.name, "oldname")
4040
profile.name = "newname"
4141
self.assertEqual(profile.name, "newname")
@@ -44,15 +44,15 @@ def test_sets_options(self):
4444
profile = MockProfile("test", {
4545
"main": { "anything": 10 },
4646
"network" : { "type": "net", "devices": "*" },
47-
})
47+
}, None)
4848

49-
self.assertIs(type(profile.options), dict)
49+
self.assertIs(type(profile.options), collections.OrderedDict)
5050
self.assertEqual(profile.options["anything"], 10)
5151

5252
def test_sets_options_empty(self):
5353
profile = MockProfile("test", {
5454
"storage" : { "type": "disk" },
55-
})
55+
}, None)
5656

5757
self.assertIs(type(profile.options), dict)
5858
self.assertEqual(len(profile.options), 0)

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: 37 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,43 @@ 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+
if profile is None:
69+
# When tuning is stopped, we are called with profile==None -> skip
70+
return None
71+
snapshot = None
72+
if os.path.isfile(consts.PROFILE_SNAPSHOT_FILE):
73+
log.debug("Found profile snapshot '%s'" % consts.PROFILE_SNAPSHOT_FILE)
74+
try:
75+
config = self._load_config_data(consts.PROFILE_SNAPSHOT_FILE)
76+
snapshot_hash = config.get("main", {}).get("profile_base_hash", None)
77+
if snapshot_hash == profile._base_hash:
78+
snapshot = self._profile_factory.create("restore", config, self._variables)
79+
snapshot.name = profile.name
80+
snapshot.process_variables()
81+
log.info("Restored profile snapshot: %s" % snapshot.name)
82+
else:
83+
log.debug("Snapshot hash '%s' does not match current base hash '%s'. Not restoring." % (snapshot_hash, profile._base_hash))
84+
os.remove(consts.PROFILE_SNAPSHOT_FILE)
85+
except InvalidProfileException as e:
86+
log.error("Could not process profile snapshot: %s" % e)
87+
return snapshot
88+
89+
def remove_snapshot(self):
90+
try:
91+
os.remove(consts.PROFILE_SNAPSHOT_FILE)
92+
except FileNotFoundError:
93+
pass
7694

7795
def _load_profile(self, profile_names, profiles, processed_files):
7896
for name in profile_names:
@@ -84,9 +102,9 @@ def _load_profile(self, profile_names, profiles, processed_files):
84102
processed_files.append(filename)
85103

86104
config = self._load_config_data(filename)
87-
profile = self._profile_factory.create(name, config)
105+
profile = self._profile_factory.create(name, config, self._variables)
88106
if "include" in profile.options:
89-
include_names = re.split(r"\s*[,;]\s*", self._variables.expand(profile.options.pop("include")))
107+
include_names = re.split(r"\s*[,;]\s*", profile._variables.expand(profile.options.pop("include")))
90108
self._load_profile(include_names, profiles, processed_files)
91109

92110
profiles.append(profile)

0 commit comments

Comments
 (0)