Repository: mlflow/mlflow

Issue:
[BUG] Send `Cache-Control` headers from static files routes
### Issues Policy acknowledgement

- [X] I have read and agree to submit bug reports in accordance with the [issues policy](https://www.github.com/mlflow/mlflow/blob/master/ISSUE_POLICY.md)

### Willingness to contribute

Yes. I would be willing to contribute a fix for this bug with guidance from the MLflow community.

### MLflow version

- Client: 2.1.1 & master
- Tracking server: 2.1.1 & master


### System information

- **OS Platform and Distribution (e.g., Linux Ubuntu 16.04)**: all
- **Python version**: 3.10
- **yarn version, if running the dev UI**: 3.3.0


### Describe the problem

I was doing some performance profiling on the mlflow ui, as users were reporting slow load times both on initial page load and when navigating. The react app is quite a large bundle that's fairly split out via webpack.

I noticed the entirety of the js files are being re-fetched on every navigation. Since these files contain a hash, they are essentially immutable files and could be cached almost indefinitely by the browser. (I assume this is a content hash from webpack, but would need to confirm).

Seems the browser is re-fetching/re-validating the files due to flask setting a [`Cache-Control: no-cache` ](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#directives) header for static files. This can also cause upstream proxies (nginx etc) not to cache the content.

It seems to makes sense to add a `max_age` parameter to the [`send_from_directory`](https://github.com/mlflow/mlflow/blob/68e11fbc39a7e1c0d42ab6a38a6243dbc71e031f/mlflow/server/__init__.py#L77-L88) calls in `mlflow.server.__init__.py`. The [`max_age `](https://flask.palletsprojects.com/en/2.2.x/api/?highlight=send_file#flask.send_file) parameter would cause a `Cache-Control: public max_age=2419200` header to be sent. Browsers would then cache the static files which would improve performance on the front and and reduce server load.

This issue explains more about why the default is not to send cache headers: https://github.com/pallets/werkzeug/issues/1882. It seems the mlflow static files would want the inverse of the default behavior.

Ideally, something like a `Cache-Control: max-age=2419200, stale-while-revalidate=2419200` header could be sent. But it isn't possible via `send_from_directory` directly. One could edit the `Response`'s cache control policy manually for more fine grained control.

Example easy mode patch:

```patch
dex bbb5110e7..6264f5391 100644
--- a/mlflow/server/__init__.py
+++ b/mlflow/server/__init__.py
@@ -76,16 +76,17 @@ def serve_get_metric_history_bulk():

 # We expect the react app to be built assuming it is hosted at /static-files, so that requests for
 # CSS/JS resources will be made to e.g. /static-files/main.css and we can handle them here.
+# cache_timeout could be really high actually
 @app.route(_add_static_prefix("/static-files/<path:path>"))
 def serve_static_file(path):
-    return send_from_directory(STATIC_DIR, path)
+    return send_from_directory(STATIC_DIR, path, max_age=2419200)


 # Serve the index.html for the React App for all other routes.
 @app.route(_add_static_prefix("/"))
 def serve():
     if os.path.exists(os.path.join(STATIC_DIR, "index.html")):
-        return send_from_directory(STATIC_DIR, "index.html")
+        return send_from_directory(STATIC_DIR, "index.html", max_age=2419200)

     text = textwrap.dedent(
         """
```

Thoughts?

### Tracking information

<!-- PLEASE KEEP BACKTICKS AND CHECK PREVIEW -->
Developer tools in browser.

### Code to reproduce issue

`Cache-Control: no-cache`

### Stack trace

n/a

### Other info / logs

n/a

### What component(s) does this bug affect?

- [ ] `area/artifacts`: Artifact stores and artifact logging
- [ ] `area/build`: Build and test infrastructure for MLflow
- [ ] `area/docs`: MLflow documentation pages
- [ ] `area/examples`: Example code
- [ ] `area/model-registry`: Model Registry service, APIs, and the fluent client calls for Model Registry
- [ ] `area/models`: MLmodel format, model serialization/deserialization, flavors
- [ ] `area/recipes`: Recipes, Recipe APIs, Recipe configs, Recipe Templates
- [ ] `area/projects`: MLproject format, project running backends
- [ ] `area/scoring`: MLflow Model server, model deployment tools, Spark UDFs
- [X] `area/server-infra`: MLflow Tracking server backend
- [ ] `area/tracking`: Tracking Service, tracking client APIs, autologging

### What interface(s) does this bug affect?

- [X] `area/uiux`: Front-end, user experience, plotting, JavaScript, JavaScript dev server
- [ ] `area/docker`: Docker use across MLflow's components, such as MLflow Projects and MLflow Models
- [ ] `area/sqlalchemy`: Use of SQLAlchemy in the Tracking Service or Model Registry
- [ ] `area/windows`: Windows support

### What language(s) does this bug affect?

- [ ] `language/r`: R APIs and clients
- [ ] `language/java`: Java APIs and clients
- [ ] `language/new`: Proposals for new client languages

### What integration(s) does this bug affect?

- [ ] `integrations/azure`: Azure and Azure ML integrations
- [ ] `integrations/sagemaker`: SageMaker integrations
- [ ] `integrations/databricks`: Databricks integrations


Discussion:
@harupy @sunishsheth2009 @hubertzub-db would the three of you mind taking a look at the proposal here and weigh in? Thank you!
Thanks @jmahlik for filling in the issue! Thoughts:
- Actually, we might want to retain `no-cache` but only for the index.html. It's the only file that does not include a hash in the filename, but its contents contain potentially changing references to other assets. This way we should still be safe from a whole class of stale cache problems. Ofc this means additional latency for fetching this particular file - but on the other hand, it's a pretty small one.
- Still, I'm not sure if there wasn't any deliberate decision in the past to use caching configured in this way. @sunishsheth2009 @harupy do you know something about it, or do you have an idea of who can we ask about it?
I agree, we should surely cache the assets rather than busting them every single time. if its the same hash since same assets are served, we can return the result from the cache. 
not sure if there were any reasons not to cache it. I can't think of any reason that it would break anything. 
cc @smurching and @dbczumar if you know any legacy reasons. 
<!-- assign-maintainer -->
@BenWilson2 @dbczumar @harupy @WeichenXu123 Please assign a maintainer and start triaging this issue.
If we don't hear from @smurching nor @dbczumar , what about enabling `max_age` for hashed assets? i.e. this part

```patch
 @app.route(_add_static_prefix("/static-files/<path:path>"))
 def serve_static_file(path):
-    return send_from_directory(STATIC_DIR, path)
+    return send_from_directory(STATIC_DIR, path, max_age=2419200)

```

Good catch on the `index.html` @hubertzub-db. 

Seems like the above should shave a solid few seconds off the page load for a real use case (k8's deployment behind nginx and a load balancer). I'd imagine other mlflow deployments in the wild are somewhat similarly configured.

Quick pic of the network tab below. All the green would be time saved for both the client and server. 

![image](https://user-images.githubusercontent.com/38999128/224111180-5817385a-0c13-482c-8aa5-a3541fa514b8.png)

The only thing I can think of that might cause cache issues is if the hashes don't update when the source does. The way create react app is configured does hash based on source (see details), so doesn't seem to be a concern.


<details>

Make a small change and rebuild. Results in different hashes for `main.js`.

```patch
diff --git a/mlflow/server/js/src/experiment-tracking/components/HomePage.js b/mlflow/server/js/src/experiment-tracking/components/HomePage.js
index 89b4ad4f1..e59ae017b 100644
--- a/mlflow/server/js/src/experiment-tracking/components/HomePage.js
+++ b/mlflow/server/js/src/experiment-tracking/components/HomePage.js
@@ -32,6 +32,9 @@ export class HomePageImpl extends Component {
   }

   render() {
+    const hello = {}
+    console.log(hello);
+
     const homeView = (
       <HomeView
         history={this.props.history}
```

```diff
*** asset-manifest-pre.json     2023-03-09 12:03:21.098424300 -0600
--- ./build/asset-manifest.json 2023-03-09 12:06:39.005817500 -0600
***************
*** 1,7 ****
  {
    "files": {
      "main.css": "/static-files/static/css/main.cf5f55dc.css",
!     "main.js": "/static-files/static/js/main.7eabccff.js",
      "static/js/113.ade46c2e.chunk.js": "/static-files/static/js/113.ade46c2e.chunk.js",
      "static/js/49.7e6414e5.chunk.js": "/static-files/static/js/49.7e6414e5.chunk.js",
      "static/js/243.8158ea45.chunk.js": "/static-files/static/js/243.8158ea45.chunk.js",
--- 1,7 ----
  {
    "files": {
      "main.css": "/static-files/static/css/main.cf5f55dc.css",
!     "main.js": "/static-files/static/js/main.ad4f2ebb.js",
      "static/js/113.ade46c2e.chunk.js": "/static-files/static/js/113.ade46c2e.chunk.js",
      "static/js/49.7e6414e5.chunk.js": "/static-files/static/js/49.7e6414e5.chunk.js",
      "static/js/243.8158ea45.chunk.js": "/static-files/static/js/243.8158ea45.chunk.js",
***************
*** 46,52 ****
      "static/media/job.svg": "/static-files/static/media/job.6a7e5f3695068e7fa63e60ffcc34641c.svg",
      "static/media/chart-line.svg": "/static-files/static/media/chart-line.0adaa2036bb4eb5956db6d0c7e925a3d.svg",
      "main.cf5f55dc.css.map": "/static-files/static/css/main.cf5f55dc.css.map",
!     "main.7eabccff.js.map": "/static-files/static/js/main.7eabccff.js.map",
      "113.ade46c2e.chunk.js.map": "/static-files/static/js/113.ade46c2e.chunk.js.map",
      "49.7e6414e5.chunk.js.map": "/static-files/static/js/49.7e6414e5.chunk.js.map",
      "243.8158ea45.chunk.js.map": "/static-files/static/js/243.8158ea45.chunk.js.map",
--- 46,52 ----
      "static/media/job.svg": "/static-files/static/media/job.6a7e5f3695068e7fa63e60ffcc34641c.svg",
      "static/media/chart-line.svg": "/static-files/static/media/chart-line.0adaa2036bb4eb5956db6d0c7e925a3d.svg",
      "main.cf5f55dc.css.map": "/static-files/static/css/main.cf5f55dc.css.map",
!     "main.ad4f2ebb.js.map": "/static-files/static/js/main.ad4f2ebb.js.map",
      "113.ade46c2e.chunk.js.map": "/static-files/static/js/113.ade46c2e.chunk.js.map",
      "49.7e6414e5.chunk.js.map": "/static-files/static/js/49.7e6414e5.chunk.js.map",
      "243.8158ea45.chunk.js.map": "/static-files/static/js/243.8158ea45.chunk.js.map",
***************
*** 67,72 ****
    },
    "entrypoints": [
      "static/css/main.cf5f55dc.css",
!     "static/js/main.7eabccff.js"
    ]
  }
\ No newline at end of file
--- 67,72 ----
    },
    "entrypoints": [
      "static/css/main.cf5f55dc.css",
!     "static/js/main.ad4f2ebb.js"
    ]
  }
```

</details>
> The only thing I can think of that might cause cache issues is if the hashes don't update when the source does. 

Yeah, I think that we can leave the responsibility of correctly recalculating hashes to CRA.
I think it's ok to open a PR with the discussed changes (caching assets, leaving `no-cache` on index.html) - let us know if you're willing to do so 🙂 

Resolving patch:
diff --git a/mlflow/server/__init__.py b/mlflow/server/__init__.py
--- a/mlflow/server/__init__.py
+++ b/mlflow/server/__init__.py
@@ -78,9 +78,10 @@ def serve_get_metric_history_bulk():
 
 # We expect the react app to be built assuming it is hosted at /static-files, so that requests for
 # CSS/JS resources will be made to e.g. /static-files/main.css and we can handle them here.
+# The files are hashed based on source code, so ok to send Cache-Control headers via max_age.
 @app.route(_add_static_prefix("/static-files/<path:path>"))
 def serve_static_file(path):
-    return send_from_directory(STATIC_DIR, path)
+    return send_from_directory(STATIC_DIR, path, max_age=2419200)
 
 
 # Serve the index.html for the React App for all other routes.


Test patch: