Repository: AppDaemon/appdaemon

Issue:
Rework AD's internal Service calls
Service calls within AD should no longer wait to return results by default
If results are needed, the user will have 2 options

1. Give the `call_service` api a callback to use
2. Set a flag, that it should wait for the result

This will help significantly speedup the core, as right now only one service call can be ran at the same time 


Discussion:

Resolving patch:
diff --git a/appdaemon/adapi.py b/appdaemon/adapi.py
--- a/appdaemon/adapi.py
+++ b/appdaemon/adapi.py
@@ -1555,6 +1555,8 @@ def register_service(
         Using this function, an App can register a function to be available in the service registry.
         This will automatically make it available to other apps using the `call_service()` API call, as well as publish
         it as a service in the REST API and make it available to the `call_service` command in the event stream.
+        It should be noted that registering services within a plugin's namespace is a bad idea. It could work, but not always reliable
+        It is recommended to make use of this api, within a user definded namespace, or one not tied to a plugin.
 
         Args:
             service: Name of the service, in the format `domain/service`. If the domain does not exist it will be created
@@ -1570,7 +1572,10 @@ def register_service(
             None
 
         Examples:
-            >>> self.register_service("myservices/service1", mycallback)
+            >>> self.register_service("myservices/service1", self.mycallback)
+
+            >>> async def mycallback(self, namespace, domain, service, kwargs):
+            >>>     self.log("Service called")
 
         """
         self._check_service(service)
@@ -1666,6 +1671,10 @@ async def call_service(self, service: str, **kwargs: Optional[dict]) -> Any:
         and the part after is the ``service name`. For instance, `light/turn_on`
         has a domain of `light` and a service name of `turn_on`.
 
+        The default behaviour of the call service api is not to wait for any result, typically
+        known as "fire and forget". If it is required to get the results of the call, keywords
+        "return_result" or "callback" can be added.
+
         Args:
             service (str): The service name.
             **kwargs (optional): Zero or more keyword arguments.
@@ -1681,6 +1690,9 @@ async def call_service(self, service: str, **kwargs: Optional[dict]) -> Any:
                 if no namespace is given, AppDaemon will use the last specified namespace
                 or the default namespace. See the section on `namespaces <APPGUIDE.html#namespaces>`__
                 for a detailed description. In most cases, it is safe to ignore this parameter.
+            return_result(str, option): If `return_result` is provided and set to `True` AD will attempt
+                to wait for the result, and return it after execution
+            callback: The non-async callback to be executed when complete.
 
         Returns:
             Result of the `call_service` function if any
@@ -3003,7 +3015,7 @@ def callback_inner(f):
             except asyncio.CancelledError:
                 pass
 
-        f = asyncio.ensure_future(coro)
+        f = asyncio.create_task(coro)
         if callback is not None:
             self.logger.debug("Adding add_done_callback for future %s for %s", f, self.name)
             f.add_done_callback(callback_inner)
diff --git a/appdaemon/services.py b/appdaemon/services.py
--- a/appdaemon/services.py
+++ b/appdaemon/services.py
@@ -2,7 +2,7 @@
 import traceback
 import asyncio
 from copy import deepcopy
-from typing import Any, Optional, Callable
+from typing import Any, Optional, Callable, Awaitable
 
 from appdaemon.appdaemon import AppDaemon
 from appdaemon.exceptions import NamespaceException
@@ -33,6 +33,9 @@ def register_service(
             if name and namespace not in self.AD.state.state:
                 raise NamespaceException(f"Namespace '{namespace}', doesn't exist")
 
+            elif not callable(callback):
+                raise ValueError(f"The given callback {callback} is not a callable function")
+
             if namespace not in self.services:
                 self.services[namespace] = {}
 
@@ -76,12 +79,12 @@ def deregister_service(self, namespace: str, domain: str, service: str, **kwargs
             raise ValueError("App must be given to deregister service call")
 
         if name not in self.app_registered_services:
-            raise ValueError(f"The given App {name} has no services registered")
+            raise ValueError("The given App %s has no services registered", name)
 
         app_service = f"{namespace}:{domain}:{service}"
 
         if app_service not in self.app_registered_services[name]:
-            raise ValueError(f"The given App {name} doesn't have the given service registered it")
+            raise ValueError("The given App %s doesn't have the given service registered it", name)
 
         # if it gets here, then time to deregister
         with self.services_lock:
@@ -143,18 +146,17 @@ async def call_service(self, namespace: str, domain: str, service: str, data: di
             "call_service: namespace=%s domain=%s service=%s data=%s", namespace, domain, service, data,
         )
         with self.services_lock:
+            name = data.pop("__name", None)
+
             if namespace not in self.services:
-                name = data.get("__name", None)
                 self.logger.warning("Unknown namespace (%s) in call_service from %s", namespace, name)
                 return None
             if domain not in self.services[namespace]:
-                name = data.get("__name", None)
                 self.logger.warning(
                     "Unknown domain (%s/%s) in call_service from %s", namespace, domain, name,
                 )
                 return None
             if service not in self.services[namespace][domain]:
-                name = data.get("__name", None)
                 self.logger.warning(
                     "Unknown service (%s/%s/%s) in call_service from %s", namespace, domain, service, name,
                 )
@@ -164,44 +166,62 @@ async def call_service(self, namespace: str, domain: str, service: str, data: di
             # from the namespace the call itself is executed from. e.g. set_state() is in the AppDaemon namespace but
             # needs to operate on a different namespace, e.g. "default"
 
-            if "__name" in data:
-                del data["__name"]
-
             if "namespace" in data:
                 ns = data["namespace"]
                 del data["namespace"]
             else:
                 ns = namespace
 
-            try:
-                funcref = self.services[namespace][domain][service]["callback"]
+            funcref = self.services[namespace][domain][service]["callback"]
 
-                # Decide whether or not to call this as async
+            # Decide whether or not to call this as async
 
-                # Default to true
-                isasync = True
+            # Default to true
+            isasync = True
 
-                if "__async" in self.services[namespace][domain][service]:
-                    # We have a kwarg to tell us what to do
-                    if self.services[namespace][domain][service]["__async"] == "auto":
-                        # We decide based on introspection
-                        if not asyncio.iscoroutinefunction(funcref):
-                            isasync = False
-                    else:
-                        # We do what the kwarg tells us
-                        isasync = self.services[namespace][domain][service]["__async"]
+            # if to wait for results, default to False
+            return_result = data.pop("return_result", False)
 
-                if isasync is True:
-                    # it's a coroutine just await it.
-                    return await funcref(ns, domain, service, data)
+            # if to return results via callback
+            callback = data.pop("callback", None)
+
+            if "__async" in self.services[namespace][domain][service]:
+                # We have a kwarg to tell us what to do
+                if self.services[namespace][domain][service]["__async"] == "auto":
+                    # We decide based on introspection
+                    if not asyncio.iscoroutinefunction(funcref):
+                        isasync = False
                 else:
-                    # It's not a coroutine, , run it in an executor
-                    return await utils.run_in_executor(self, funcref, ns, domain, service, data)
-
-            except Exception:
-                self.logger.error("-" * 60)
-                self.logger.error("Unexpected error in call_service()")
-                self.logger.error("-" * 60)
-                self.logger.error(traceback.format_exc())
-                self.logger.error("-" * 60)
-                return None
+                    # We do what the kwarg tells us
+                    isasync = self.services[namespace][domain][service]["__async"]
+
+            if isasync is True:
+                # it's a coroutine just await it.
+                coro = funcref(ns, domain, service, data)
+            else:
+                # It's not a coroutine, , run it in an executor
+                coro = utils.run_in_executor(self, funcref, ns, domain, service, data)
+
+            if return_result is True:
+                return await self.run_service(coro)
+
+            elif callback is not None and name is not None:
+                # results expected and it must belong to an app
+                app_object = await self.AD.app_management.get_app(name)
+                app_object.create_task(self.run_service(coro), callback=callback)
+
+            else:
+                asyncio.create_task(self.run_service(coro))
+
+    async def run_service(self, coro: Awaitable) -> Any:
+        """Used to process a service call"""
+        try:
+            return await coro
+
+        except Exception:
+            self.logger.error("-" * 60)
+            self.logger.error("Unexpected error in call_service()")
+            self.logger.error("-" * 60)
+            self.logger.error(traceback.format_exc())
+            self.logger.error("-" * 60)
+            return None


Test patch: