Repository: PrefectHQ/prefect

Issue:
Handle serialization of State results
We need to put together a new serializer spec for how to handle state results.  An edge case that comes to mind is `Failed` states --> should we be storing the caught exceptions in the DB, or not?  We have discussed that you can fail a task and _still_ pass data around (which is 💯 ) and makes me think we should avoid ever storing the `.result` attribute


Discussion:
Per offline discussion we want to put hooks into the client so that, if a user requests for result caching (e.g., provides `cache_for` kwarg to task), the client can call a customizable serialization class which will place the result in some user-specified location and store the URI in the database.  Similarly with input caching (which will probably be trickier).
Things left:

- [x] handle cached_inputs and cached_states
- [ ] handle expiration
- [ ] add default
- [ ] serialize these / package them up with the Flow for deployment

Resolving patch:
diff --git a/src/prefect/client/client.py b/src/prefect/client/client.py
--- a/src/prefect/client/client.py
+++ b/src/prefect/client/client.py
@@ -2,11 +2,11 @@
 
 import datetime
 import json
+import logging
 import os
 from typing import TYPE_CHECKING, Optional, Union
 
 import prefect
-from prefect.client.result_handlers import ResultHandler
 from prefect.utilities.graphql import (
     EnumValue,
     parse_graphql,
@@ -18,6 +18,7 @@
 if TYPE_CHECKING:
     import requests
     from prefect.core import Flow
+    from prefect.client.result_handlers import ResultHandler
 
 
 BuiltIn = Union[bool, dict, list, str, set, tuple]
@@ -42,7 +43,16 @@ class Client:
             to; if not provided, will be pulled from `cloud.graphql` config var
     """
 
+    def _initialize_logger(self) -> None:
+        self.logger = logging.getLogger("Client")
+        handler = logging.StreamHandler()
+        formatter = logging.Formatter(prefect.config.logging.format)
+        handler.setFormatter(formatter)
+        self.logger.addHandler(handler)
+        self.logger.setLevel(prefect.config.logging.level)
+
     def __init__(self, api_server: str = None, graphql_server: str = None) -> None:
+        self._initialize_logger()
         if not api_server:
             api_server = prefect.config.cloud.get("api", None)
             if not api_server:
@@ -52,6 +62,11 @@ def __init__(self, api_server: str = None, graphql_server: str = None) -> None:
         if not graphql_server:
             graphql_server = prefect.config.cloud.get("graphql") or self.api_server
         self.graphql_server = graphql_server
+        self.logger.debug(
+            "Client initialized with api_server='{0}' and graphql_server='{1}'".format(
+                self.api_server, self.graphql_server
+            )
+        )
 
         token = prefect.config.cloud.get("auth_token", None)
 
@@ -60,12 +75,36 @@ def __init__(self, api_server: str = None, graphql_server: str = None) -> None:
             if os.path.exists(token_path):
                 with open(token_path, "r") as f:
                     token = f.read() or None
+            if token is not None:
+                self.logger.debug("Client token set from file {}".format(token_path))
+        else:
+            self.logger.debug("Client token set from $PREFECT__CLOUD__AUTH_TOKEN")
 
         self.token = token
 
     # -------------------------------------------------------------------------
     # Utilities
 
+    def get(self, path: str, server: str = None, **params: BuiltIn) -> dict:
+        """
+        Convenience function for calling the Prefect API with token auth and GET request
+
+        Args:
+            - path (str): the path of the API url. For example, to GET
+                http://prefect-server/v1/auth/login, path would be 'auth/login'.
+            - server (str, optional): the server to send the GET request to;
+                defaults to `self.api_server`
+            - params (dict): GET parameters
+
+        Returns:
+            - dict: Dictionary representation of the request made
+        """
+        response = self._request(method="GET", path=path, params=params, server=server)
+        if response.text:
+            return response.json()
+        else:
+            return {}
+
     def post(self, path: str, server: str = None, **params: BuiltIn) -> dict:
         """
         Convenience function for calling the Prefect API with token auth and POST request
@@ -152,7 +191,7 @@ def _request(
         def request_fn() -> "requests.models.Response":
             headers = {"Authorization": "Bearer {}".format(self.token)}
             if method == "GET":
-                response = requests.get(url, headers=headers, params=params)
+                response = requests.get(url, headers=headers, json=params)
             elif method == "POST":
                 response = requests.post(url, headers=headers, json=params)
             elif method == "DELETE":
@@ -323,7 +362,7 @@ def create_flow_run(
         return res.createFlowRun.flow_run  # type: ignore
 
     def get_flow_run_info(
-        self, flow_run_id: str, result_handler: ResultHandler = None
+        self, flow_run_id: str, result_handler: "ResultHandler" = None
     ) -> GraphQLResult:
         """
         Retrieves version and current state information for the given flow run.
@@ -404,7 +443,7 @@ def set_flow_run_state(
         flow_run_id: str,
         version: int,
         state: "prefect.engine.state.State",
-        result_handler: ResultHandler = None,
+        result_handler: "ResultHandler" = None,
     ) -> GraphQLResult:
         """
         Sets new state for a flow run in the database.
@@ -449,7 +488,7 @@ def get_task_run_info(
         flow_run_id: str,
         task_id: str,
         map_index: Optional[int],
-        result_handler: ResultHandler = None,
+        result_handler: "ResultHandler" = None,
     ) -> GraphQLResult:
         """
         Retrieves version and current state information for the given task run. If this task run is not present in the database (which could
@@ -507,7 +546,7 @@ def set_task_run_state(
         version: int,
         state: "prefect.engine.state.State",
         cache_for: datetime.timedelta = None,
-        result_handler: ResultHandler = None,
+        result_handler: "ResultHandler" = None,
     ) -> GraphQLResult:
         """
         Sets new state for a task run in the database.
diff --git a/src/prefect/client/result_handlers.py b/src/prefect/client/result_handlers.py
--- a/src/prefect/client/result_handlers.py
+++ b/src/prefect/client/result_handlers.py
@@ -1,8 +1,17 @@
+"""
+Result Handlers provide the hooks that Prefect uses to store task results in production; a `ResultHandler` can be provided to a `Flow` at creation.
+
+Anytime a task needs its output or inputs stored, a result handler is used to determine where this data should be stored (and how it can be retrieved).
+"""
+import base64
 import cloudpickle
 import tempfile
 
 from typing import Any
 
+from prefect import config
+from prefect.client.client import Client
+
 
 class ResultHandler:
     def deserialize(self, loc: str) -> Any:
@@ -12,6 +21,69 @@ def serialize(self, result: Any) -> str:
         raise NotImplementedError()
 
 
+class CloudResultHandler(ResultHandler):
+    """
+    Hook for storing and retrieving task results from Prefect cloud storage; the default result handler for Flows running on Cloud.
+    """
+
+    def __init__(self) -> None:
+        self.client = None
+        self.result_handler_service = None
+
+    def _initialize_client(self) -> None:
+        """
+        Helper method for ensuring that CloudHandlers which are initialized locally
+        do not attempt to start a Client.  This is important because CloudHandlers are
+        currently attached to `Flow` objects which need to be serialized / deserialized
+        independently of cloud settings.
+
+        This will instantiate a Client upon the first call to (de)serialize.
+        """
+        if self.client is None:
+            self.client = Client()  # type: ignore
+        if self.result_handler_service is None:
+            self.result_handler_service = config.cloud.result_handler
+
+    def deserialize(self, uri: str) -> Any:
+        """
+        Deserialize a result from the given URI location.
+
+        Args:
+            - uri (str): the path to the location of a serialized result
+
+        Returns:
+            - the deserialized result from the provided URI
+        """
+        self._initialize_client()
+        res = self.client.get(  # type: ignore
+            "/", server=self.result_handler_service, **{"uri": uri}
+        )
+
+        try:
+            return_val = cloudpickle.loads(base64.b64decode(res.get("result", "")))
+        except EOFError:
+            return_val = None
+
+        return return_val
+
+    def serialize(self, result: Any) -> str:
+        """
+        Serialize the provided result to Prefect Cloud.
+
+        Args:
+            - result (Any): the result to serialize and store
+
+        Returns:
+            - str: the URI path to the serialized result in Cloud storage
+        """
+        self._initialize_client()
+        binary_data = base64.b64encode(cloudpickle.dumps(result)).decode()
+        res = self.client.post(  # type: ignore
+            "/", server=self.result_handler_service, **{"result": binary_data}
+        )
+        return res.get("uri", "")
+
+
 class LocalResultHandler(ResultHandler):
     """
     Hook for storing and retrieving task results from local file storage. Only intended to be used
diff --git a/src/prefect/core/flow.py b/src/prefect/core/flow.py
--- a/src/prefect/core/flow.py
+++ b/src/prefect/core/flow.py
@@ -26,7 +26,7 @@
 
 import prefect
 import prefect.schedules
-from prefect.client.result_handlers import ResultHandler
+from prefect.client.result_handlers import CloudResultHandler, ResultHandler
 from prefect.core.edge import Edge
 from prefect.core.task import Parameter, Task
 from prefect.environments import Environment
@@ -122,7 +122,7 @@ def my_task():
             in the `edges` argument. Defaults to the value of `eager_edge_validation` in
             your prefect configuration file.
         - result_handler (ResultHandler, optional): the handler to use for
-            retrieving and storing state results during execution
+            retrieving and storing state results during execution; defaults to `CloudResultHandler`
 
     Raises:
         - ValueError: if any throttle values are `<= 0`
@@ -151,7 +151,9 @@ def __init__(
         self.name = name or type(self).__name__
         self.schedule = schedule
         self.environment = environment or prefect.environments.LocalEnvironment()
-        self.result_handler = result_handler
+        self.result_handler = (
+            result_handler if result_handler is not None else CloudResultHandler()
+        )
 
         self.tasks = set()  # type: Set[Task]
         self.edges = set()  # type: Set[Edge]


Test patch:
diff --git a/tests/client/test_result_handlers.py b/tests/client/test_result_handlers.py
--- a/tests/client/test_result_handlers.py
+++ b/tests/client/test_result_handlers.py
@@ -1,8 +1,62 @@
+import json
 import os
 import pytest
 import tempfile
+from unittest.mock import MagicMock
 
-from prefect.client.result_handlers import LocalResultHandler
+from prefect.client import Client
+from prefect.client.result_handlers import CloudResultHandler, LocalResultHandler
+from prefect.utilities.configuration import set_temporary_config
+
+
+def requests_post(*args, result=None, **kwargs):
+    return dict(uri=json.dumps(result))
+
+
+class TestCloudHandler:
+    def test_cloud_handler_initializes_with_no_args(self):
+        handler = CloudResultHandler()
+        assert handler.client is None
+        assert handler.result_handler_service is None
+
+    def test_cloud_handler_pulls_settings_from_config_after_first_method_call(
+        self, monkeypatch
+    ):
+        client = MagicMock(post=requests_post)
+        monkeypatch.setattr(
+            "prefect.client.result_handlers.Client", MagicMock(return_value=client)
+        )
+        with set_temporary_config({"cloud.result_handler": "http://foo.bar:4204"}):
+            handler = CloudResultHandler()
+            handler.serialize("random string")
+        assert handler.result_handler_service == "http://foo.bar:4204"
+
+    @pytest.mark.parametrize("data", [None, "my_string", 42])
+    def test_cloud_handler_sends_jsonable_packages(self, data, monkeypatch):
+        client = MagicMock(post=requests_post)
+        monkeypatch.setattr(
+            "prefect.client.result_handlers.Client", MagicMock(return_value=client)
+        )
+        handler = CloudResultHandler()
+        assert isinstance(handler.serialize(data), str)
+
+    def test_cloud_handler_can_interpret_contents_of_standard_uri(self, monkeypatch):
+        binary_data = "gASVDQAAAAAAAACMCW15IHNlY3JldJQu"
+        client = MagicMock(get=lambda *args, **kwargs: dict(result=binary_data))
+        monkeypatch.setattr(
+            "prefect.client.result_handlers.Client", MagicMock(return_value=client)
+        )
+        handler = CloudResultHandler()
+        assert handler.deserialize(uri="http://look-here") == "my secret"
+
+    def test_cloud_handler_handles_empty_buckets(self, monkeypatch):
+        binary_data = ""
+        client = MagicMock(get=lambda *args, **kwargs: dict(result=binary_data))
+        monkeypatch.setattr(
+            "prefect.client.result_handlers.Client", MagicMock(return_value=client)
+        )
+        handler = CloudResultHandler()
+        assert handler.deserialize(uri="http://look-here") is None
 
 
 class TestLocalHandler:
diff --git a/tests/core/test_flow.py b/tests/core/test_flow.py
--- a/tests/core/test_flow.py
+++ b/tests/core/test_flow.py
@@ -9,6 +9,7 @@
 import pytest
 
 import prefect
+from prefect.client.result_handlers import CloudResultHandler
 from prefect.core.edge import Edge
 from prefect.core.flow import Flow
 from prefect.core.task import Parameter, Task
@@ -82,6 +83,10 @@ def test_flow_prevents_bad_throttle_values(self):
     def test_create_flow_without_state_handler(self):
         assert Flow().state_handlers == []
 
+    def test_create_flow_without_result_handler(self):
+        flow = Flow()
+        assert isinstance(flow.result_handler, CloudResultHandler)
+
     @pytest.mark.parametrize("handlers", [[lambda *a: 1], [lambda *a: 1, lambda *a: 2]])
     def test_create_flow_with_state_handler(self, handlers):
         assert Flow(state_handlers=handlers).state_handlers == handlers