Repository: cobbler/cobbler

Issue:
Lazy start is useless when there are a large number of systems
### Describe the bug

Lazy start postpones the slow start problem until the first use of the system. When the system is loaded into memory, all items of the collection are searched and they are also recursively loaded into memory. With a large number of systems, the first command takes a very long time to execute.

<!--- HINT: You can paste gist.github.com links for long logs or larger files -->

### Steps to reproduce

1. lazy_start: true
2. systemctl start cobblerd
3. cobbler system add/edit ..

### Expected behavior

It is necessary to speed up the execution of the first command after the start.

### Cobbler version

<!--- Paste output from `cobbler version` -->
````paste below
````

### Operating system

<!--- On which operating system do you use Cobbler? -->

### Cobbler log

<!--- Paste (partial) output from `/var/log/cobbler/cobbler.log` -->
````paste below
Cobbler 3.4.0
````

### Screenshots

<!--- If applicable, add screenshots to help explain your problem. -->

### Additional information

<!--- Add any other context about the problem here. -->



Discussion:

Resolving patch:
diff --git a/cobbler/cobblerd.py b/cobbler/cobblerd.py
--- a/cobbler/cobblerd.py
+++ b/cobbler/cobblerd.py
@@ -87,6 +87,8 @@ def do_xmlrpc_rw(cobbler_api: CobblerAPI, port: int) -> None:
     while True:
         try:
             logger.info("Cobbler startup completed %s", start_time)
+            # Start background load_items task
+            xinterface.proxied.background_load_items()
             server.serve_forever()
         except IOError:
             # interrupted? try to serve again
diff --git a/cobbler/remote.py b/cobbler/remote.py
--- a/cobbler/remote.py
+++ b/cobbler/remote.py
@@ -128,6 +128,7 @@
 import random
 import re
 import stat
+import threading
 import time
 import xmlrpc.server
 from socketserver import ThreadingMixIn
@@ -195,6 +196,9 @@ def __init__(self, api: "CobblerAPI"):
         random.seed(time.time())
         self.tftpgen = tftpgen.TFTPGen(api)
         self.autoinstall_mgr = autoinstall_manager.AutoInstallationManager(api)
+        # Semaphore that suspends the execution of the background_load_items when the execution
+        # of any command or any other task begins until it finishes.
+        self.load_items_lock = threading.Semaphore()
 
     def check(self, token: str) -> List[str]:
         """
@@ -219,8 +223,6 @@ def background_buildiso(self, options: Dict[str, Any], token: str) -> str:
         webdir = self.api.settings().webdir
 
         def runner(self: CobblerThread):
-            if not isinstance(self.options, dict):
-                raise ValueError("Options need to be dict for background_buildiso!")
             self.remote.api.build_iso(
                 self.options.get("iso", webdir + "/pub/generated.iso"),
                 self.options.get("profiles", None),
@@ -235,8 +237,6 @@ def runner(self: CobblerThread):
             )
 
         def on_done(self: CobblerThread):
-            if not isinstance(self.options, dict):
-                raise ValueError("Options need to be dict for background_buildiso!")
             if self.options.get("iso", "") == webdir + "/pub/generated.iso":
                 msg = 'ISO now available for <A HREF="/cobbler/pub/generated.iso">download</A>'
                 self.remote._new_event(msg)
@@ -255,10 +255,6 @@ def background_aclsetup(self, options: Dict[str, Any], token: str) -> str:
         """
 
         def runner(self: CobblerThread):
-            if not isinstance(self.options, dict):
-                raise ValueError(
-                    "self.options needs to be dict for background_aclsetup!"
-                )
             self.remote.api.acl_config(
                 self.options.get("adduser", None),
                 self.options.get("addgroup", None),
@@ -519,6 +515,47 @@ def runner(self: "CobblerThread"):
             runner, token, "mkloaders", "Create bootable bootloader images", options
         )
 
+    def background_load_items(self) -> str:
+        """
+        Loading items
+        """
+
+        def runner(self: "CobblerThread"):
+            item_types = [
+                "repo",
+                "distro",
+                "menu",
+                "image",
+                "profile",
+                "system",
+            ]
+            lock = self.options.get("load_items_lock", None)
+            if lock is None or not isinstance(lock, threading.Semaphore):
+                return
+
+            for item_type in item_types:
+                count = 0
+                begin_time = time.time()
+                collection = self.api.get_items(item_type)
+                names_copy = collection.get_names()
+                for name in names_copy:
+                    count += 1
+                    with lock:
+                        item_obj = collection.get(name)
+                        if item_obj is not None and not item_obj.inmemory:
+                            item_obj.deserialize()
+                if count > 0:
+                    collection.inmemory = True
+                    collections_types = collection.collection_types()
+                    self.logger.info(
+                        f"{count} {collections_types} loaded in {str(time.time() - begin_time)} seconds"
+                    )
+
+        if self.api.settings().lazy_start:
+            token = self.login("", self.shared_secret)
+            return self.__start_task(runner, token, "load_items", "Loading items", {})
+        return ""
+
     def get_events(self, for_user: str = "") -> Dict[str, List[Union[str, float]]]:
         """
         Returns a dict(key=event id) = [ statetime, name, state, [read_by_who] ]
@@ -599,6 +636,7 @@ def __start_task(
 
         self._log(f"create_task({name}); event_id({new_event.event_id})")
 
+        args["load_items_lock"] = self.load_items_lock
         thr_obj = CobblerThread(
             new_event.event_id, self, args, role_name, self.api, thr_obj_fn, on_done
         )
@@ -2106,7 +2144,7 @@ def xapi_object_edit(
                         object_type, handle, attr_name, attributes.pop(attr_name), token
                     )
             have_interface_keys = False
-            for (key, value) in attributes.items():
+            for key, value in attributes.items():
                 if self.__is_interface_field(key):
                     have_interface_keys = True
                 if object_type != "system" or not self.__is_interface_field(key):
@@ -2124,7 +2162,7 @@ def xapi_object_edit(
                         new_value = details[key]  # type: ignore
                         parsed_input = self.api.input_string_or_dict(value)  # type: ignore
                         if isinstance(parsed_input, dict):
-                            for (input_key, input_value) in parsed_input.items():
+                            for input_key, input_value in parsed_input.items():
                                 if input_key.startswith("~") and len(input_key) > 1:
                                     del new_value[input_key[1:]]  # type: ignore
                                 else:
@@ -3917,7 +3955,7 @@ def _dispatch(self, method: str, params: Any, **rest: Any) -> Any:
         :return: The result of the method.
         """
         # ToDo: Drop rest param
-        if method.startswith("_"):
+        if method.startswith("_") or method == "background_load_items":
             raise CX("forbidden method")
 
         if not hasattr(self.proxied, method):
@@ -3927,7 +3965,11 @@ def _dispatch(self, method: str, params: Any, **rest: Any) -> Any:
 
         # FIXME: see if this works without extra boilerplate
         try:
+            # Shared lock to suspend execution of background_load_items
+            self.proxied.load_items_lock.acquire(blocking=False)
             return method_handle(*params)
         except Exception as exception:
             utils.log_exc()
             raise exception
+        finally:
+            self.proxied.load_items_lock.release()
diff --git a/cobbler/utils/thread.py b/cobbler/utils/thread.py
--- a/cobbler/utils/thread.py
+++ b/cobbler/utils/thread.py
@@ -5,7 +5,7 @@
 import logging
 import pathlib
 from threading import Thread
-from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
+from typing import TYPE_CHECKING, Any, Callable, Dict, Optional
 
 from cobbler import enums, utils
 
@@ -23,7 +23,7 @@ def __init__(
         self,
         event_id: str,
         remote: "CobblerXMLRPCInterface",
-        options: Optional[Union[List[str], Dict[str, Any]]],
+        options: Dict[str, Any],
         task_name: str,
         api: "CobblerAPI",
         run: Callable[["CobblerThread"], None],
@@ -48,8 +48,6 @@ def __init__(
         self.__setup_logger()
         self._run = run
         self.on_done = on_done
-        if options is None:
-            options = []
         self.options = options
         self.task_name = task_name
         self.api = api
@@ -92,6 +90,10 @@ def run(self) -> None:
         :return: The return code of the action. This may a boolean or a Linux return code.
         """
         self.logger.info("start_task(%s); event_id(%s)", self.task_name, self.event_id)
+        lock = "load_items_lock" in self.options and self.task_name != "load_items"
+        if lock:
+            # Shared lock to suspend execution of _background_load_items
+            self.options["load_items_lock"].acquire(blocking=False)
         try:
             if utils.run_triggers(
                 api=self.api,
@@ -118,5 +120,7 @@ def run(self) -> None:
             self._set_task_state(enums.EventStatus.FAILED)
             return
         finally:
+            if lock:
+                self.options["load_items_lock"].release()
             if self.__task_log_handler is not None:
                 self.logger.removeHandler(self.__task_log_handler)


Test patch:
diff --git a/tests/xmlrpcapi/background_test.py b/tests/xmlrpcapi/background_test.py
--- a/tests/xmlrpcapi/background_test.py
+++ b/tests/xmlrpcapi/background_test.py
@@ -68,3 +68,12 @@ def test_background_validate_autoinstall_files(self, remote, token):
 
         # Assert
         assert result
+
+    def test_background_load_items(self, remote):
+        # Arrange
+
+        # Act
+        result = remote.background_load_items()
+
+        # Assert
+        assert result