Repository: voxel51/fiftyone
Pull request: Lazily connect to database when needed
Closes #182
Closes #1964
Closes #1804 
Closes #3189 
## What changes are proposed in this pull request?

Lazily connect to database so you can import `fiftyone` without a database connection.
Most of the work was testing.

## How is this patch tested? If it is not, please explain why.

### Lazy connection testing
A sampling of things users might want to be able to do without connecting to a database
#### Version
BEFORE
```shell
$ FIFTYONE_DATABASE_URI=invalid.com:27017 fiftyone --version
...
Traceback (most recent call last):
  File "/Users/stuart/dev/fiftyone/fresh_venv/bin/fiftyone", line 5, in <module>
    from fiftyone.core.cli import main
  File "/Users/stuart/dev/fiftyone/fiftyone/__init__.py", line 25, in <module>
    from fiftyone.__public__ import *
  File "/Users/stuart/dev/fiftyone/fiftyone/__public__.py", line 17, in <module>
    _foo.establish_db_conn(config)
  File "/Users/stuart/dev/fiftyone/fiftyone/core/odm/database.py", line 216, in establish_db_conn
    _validate_db_version(config, _client)
  File "/Users/stuart/dev/fiftyone/fiftyone/core/odm/database.py", line 301, in _validate_db_version
    raise ConnectionError("Could not connect to `mongod`") from e
ConnectionError: Could not connect to `mongod`
```
AFTER
```shell
$FIFTYONE_DATABASE_URI=invalid.com:27017 fiftyone --version
FiftyOne v0.24.0, Voxel51, Inc.
```
#### Config
BEFORE
```shell
$FIFTYONE_DATABASE_URI=invalid.com:27017 fiftyone config
...
ConnectionError: Could not connect to `mongod`
```
AFTER
```shell
$FIFTYONE_DATABASE_URI=invalid.com:27017 fiftyone config
FiftyOne v0.24.0, Voxel51, Inc.
```
#### Zoo
BEFORE
```shell
$FIFTYONE_DATABASE_URI=invalid.com:27017 fiftyone zoo datasets download quickstart
...
ConnectionError: Could not connect to `mongod`
```

AFTER
```shell
$FIFTYONE_DATABASE_URI=invalid.com:27017 fiftyone zoo datasets download quickstart
Dataset already downloaded
```
#### Data model
BEFORE
```python
import fiftyone as fo

...
ConnectionError: Could not connect to `mongod`
```
AFTER
```python
import fiftyone as fo

test_sample = fo.Sample(
    "/path/to/sample.png",
    ground_truth=fo.Detections(
        detections=[
            fo.Detection(label="thing", bounding_box=[0.5, 0.5, 0.5, 0.5])
        ]
    ),
)
assert test_sample.ground_truth.detections[0].label == "thing"
```

#### Actually requires DB stuff
BOTH
```python
import fiftyone as fo

ds = fo.Dataset()

...
ConnectionError: Could not connect to `mongod`
```

### Correctness Testing
Made sure things still work for normal operation.
This is where I'll likely need some input / ideas.

Works with both database_uri set and mongodb spun up for me.
1. Unit tests pass
2. `fiftyone datasets list`
3. `fiftyone app launch` and launch app from shell
   1. Load dataset
   2. Manipulate view via session
   3. Save view
   4. Filter via sidebar
   5. Patches
   6. Compute/view embeddings+viz
   7. simple dataset-using operator (example_target_view)
4. Debug mode: run `server/main.py` and `yarn dev` separately
5. Make sure migration gets checked/run on first DB-usage also
6. mongoengine documents can be saved before other operations where connection established

Other
1. Spins up 1 mongodb which is shared by other fo's (that are using DB). And it's not killed until last user is done.

## Release Notes

### Is this a user-facing change that should be mentioned in the release notes?

-   [ ] No. You can skip the rest of this section.
-   [x] Yes. Give a description of this change to be included in the release
        notes for FiftyOne users.

<pre>
Importing ``fiftyone`` doesn't attempt a database connection, rather it's deferred to when actually required.
</pre>

### What areas of FiftyOne does this PR affect?

-   [ ] App: FiftyOne application changes
-   [ ] Build: Build and test infrastructure changes
-   [x] Core: Core `fiftyone` Python library changes
-   [ ] Documentation: FiftyOne documentation changes
-   [ ] Other


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit


- **Refactor**
	- Improved database migration process based on environment variable configuration.
	- Enhanced database connection management in the repository factory module.

- **Tests**
	- Optimized unit tests for plugin secrets management with streamlined code.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Problem statement:
Lazily start DB service to reduce import time?
Currently, running `fiftyone config`, which simply loads and prints one's FO config, takes ~2 seconds to execute on my machine, because `import fiftyone` currently triggers a DB service to be started, among other things.

Can we adopt a lazy initialization strategy where the DB service is only started when absolutely necessary?

There are many valid use cases, like the CLI, or utilizing many of the utility methods in `fiftyone.utils`, where no DB connection is needed.


[FR] Reduce FiftyOne import time
### System information

-   **OS Platform and Distribution (e.g., Linux Ubuntu 16.04)**: Macos 12.0.1
-   **FiftyOne installed from (pip or source)**: pip
-   **FiftyOne version (run `fiftyone --version`)**: v0.15.1
-   **Python version**: 3.10.4

### Commands to reproduce

`fiftyone`
or
`fiftyone --version`
or
`python -c "import fiftyone"`

### Describe the problem

Importing fiftyone in general is VERY slow often 4-6 seconds.
`import fiftyone`

This is not desirable if fiftyone is needed for command line applications.

The main culprits seem to be:
- submodules in fiftyone reference back to the public api which means importing submodules imports everything. Eg. `fiftyone.core.dataset` contains `import fiftyone`
- In `fiftyone/__public__.py` the following files seem to be the culprits:
    + **core.config**: 548.17ms
    + **core.odm**: 1019.60ms
    + **core.plots**: 620.60ms
    + core.session: 54.95ms
    + load_config(): 214.52ms
    + **establish_db_conn()**: 3761.12ms

### Code to reproduce issue

`import fiftyone`

`from fiftyone.core.dataset import Dataset`
- Should be noted that this seems to have back references to `fiftyone.__public__`. This should bot be the case across the library. Imports in submodules should make direct references to the components they need to speed up importing. Importing this should be expected to be faster than importing the entire public interface.

### Other info / logs


Output of running: `python3 -c "import fiftyone"` (With modifications given below)

Custom logs for each import from `fiftyone/__public__.py`
```bash
N/A: 0.00ms
core.config: 548.17ms
core.odm: 1019.60ms
load_config(): 214.52ms
load_annotation_config(): 0.19ms
load_app_config(): 0.07ms
N/A: 0.00ms
core.config: 278.28ms
core.odm: 582.77ms
load_config(): 58.93ms
load_annotation_config(): 0.11ms
load_app_config(): 0.04ms
establish_db_conn(): 0.01ms
core.aggregations: 2.35ms
core.config: 0.00ms
core.dataset: 122.92ms
core.expressions: 0.01ms
core.fields: 0.01ms
core.frame: 0.00ms
core.labels: 0.01ms
core.metadata: 0.00ms
core.models: 0.00ms
core.odm: 0.00ms
core.plots: 620.60ms
core.sample: 0.00ms
core.stages: 2.01ms
core.session: 54.95ms
core.utils: 0.01ms
core.view: 0.00ms
utils.eval.classification: 12.39ms
utils.eval.detection: 0.00ms
utils.eval.segmentation: 0.00ms
utils.eval.quickstart: 25.54ms
establish_db_conn(): 3761.12ms
core.aggregations: 2.75ms
core.config: 0.01ms
core.dataset: 68.54ms
core.expressions: 0.01ms
core.fields: 0.01ms
core.frame: 0.00ms
core.labels: 0.01ms
core.metadata: 0.00ms
core.models: 0.00ms
core.odm: 0.01ms
core.plots: 344.27ms
core.sample: 0.00ms
core.stages: 1.26ms
core.session: 31.29ms
core.utils: 0.01ms
core.view: 0.00ms
utils.eval.classification: 6.16ms
utils.eval.detection: 0.00ms
utils.eval.segmentation: 0.00ms
utils.eval.quickstart: 13.43ms
```

<details>
<summary>Code Modification of `__public__.py` Used To Generate the Above</summary>
<br>
<p>
import time

_t = time.time()
def print_elapsed(msg: str = None):
    global _t
    t = time.time() - _t
    print(f'{msg}: {t*1000:.2f}ms' if msg else f'{t*1000:.2f}ms')
    _t = time.time()

print_elapsed('N/A')
import fiftyone.core.config as _foc
print_elapsed('core.config')
import fiftyone.core.odm as _foo
print_elapsed('core.odm')

config = _foc.load_config()
print_elapsed('load_config()')
annotation_config = _foc.load_annotation_config()
print_elapsed('load_annotation_config()')
app_config = _foc.load_app_config()
print_elapsed('load_app_config()')

_foo.establish_db_conn(config)
print_elapsed('establish_db_conn()')

from .core.aggregations import (
    Bounds,
    Count,
    CountValues,
    Distinct,
    HistogramValues,
    Mean,
    Std,
    Sum,
    Values,
)
print_elapsed('core.aggregations')
from .core.config import AppConfig
print_elapsed('core.config')
from .core.dataset import (
    Dataset,
    list_datasets,
    dataset_exists,
    load_dataset,
    delete_dataset,
    delete_datasets,
    delete_non_persistent_datasets,
    get_default_dataset_name,
    make_unique_dataset_name,
    get_default_dataset_dir,
)
print_elapsed('core.dataset')
from .core.expressions import (
    ViewField,
    ViewExpression,
    VALUE,
)
print_elapsed('core.expressions')
from .core.fields import (
    ArrayField,
    BooleanField,
    ClassesField,
    DateField,
    DateTimeField,
    DictField,
    EmbeddedDocumentField,
    EmbeddedDocumentListField,
    Field,
    FrameNumberField,
    FrameSupportField,
    FloatField,
    GeoPointField,
    GeoLineStringField,
    GeoPolygonField,
    GeoMultiPointField,
    GeoMultiLineStringField,
    GeoMultiPolygonField,
    IntField,
    IntDictField,
    KeypointsField,
    ListField,
    ObjectIdField,
    PolylinePointsField,
    StringField,
    TargetsField,
    VectorField,
)
print_elapsed('core.fields')
from .core.frame import Frame
print_elapsed('core.frame')
from .core.labels import (
    Label,
    Attribute,
    BooleanAttribute,
    CategoricalAttribute,
    NumericAttribute,
    ListAttribute,
    Regression,
    Classification,
    Classifications,
    Detection,
    Detections,
    Polyline,
    Polylines,
    Keypoint,
    Keypoints,
    Segmentation,
    Heatmap,
    TemporalDetection,
    TemporalDetections,
    GeoLocation,
    GeoLocations,
)
print_elapsed('core.labels')
from .core.metadata import (
    Metadata,
    ImageMetadata,
    VideoMetadata,
)
print_elapsed('core.metadata')
from .core.models import (
    apply_model,
    compute_embeddings,
    compute_patch_embeddings,
    load_model,
    Model,
    ModelConfig,
    EmbeddingsMixin,
    TorchModelMixin,
    ModelManagerConfig,
    ModelManager,
)
print_elapsed('core.models')
from .core.odm import KeypointSkeleton
print_elapsed('core.odm')
from .core.plots import (
    plot_confusion_matrix,
    plot_pr_curve,
    plot_pr_curves,
    plot_roc_curve,
    lines,
    scatterplot,
    location_scatterplot,
    Plot,
    ResponsivePlot,
    InteractivePlot,
    ViewPlot,
    ViewGrid,
    CategoricalHistogram,
    NumericalHistogram,
)
print_elapsed('core.plots')
from .core.sample import Sample
print_elapsed('core.sample')
from .core.stages import (
    Concat,
    Exclude,
    ExcludeBy,
    ExcludeFields,
    ExcludeFrames,
    ExcludeLabels,
    Exists,
    FilterField,
    FilterLabels,
    FilterKeypoints,
    Limit,
    LimitLabels,
    GeoNear,
    GeoWithin,
    GroupBy,
    MapLabels,
    Match,
    MatchFrames,
    MatchLabels,
    MatchTags,
    Mongo,
    Shuffle,
    Select,
    SelectBy,
    SelectFields,
    SelectFrames,
    SelectLabels,
    SetField,
    Skip,
    SortBy,
    SortBySimilarity,
    Take,
    ToPatches,
    ToEvaluationPatches,
    ToClips,
    ToFrames,
)
print_elapsed('core.stages')
from .core.session import (
    close_app,
    launch_app,
    Session,
)
print_elapsed('core.session')
from .core.utils import (
    pprint,
    pformat,
    ProgressBar,
)
print_elapsed('core.utils')
from .core.view import DatasetView
print_elapsed('core.view')
from .utils.eval.classification import (
    evaluate_classifications,
    ClassificationResults,
    BinaryClassificationResults,
)
print_elapsed('utils.eval.classification')
from .utils.eval.detection import (
    evaluate_detections,
    DetectionResults,
)
print_elapsed('utils.eval.detection')
from .utils.eval.segmentation import (
    evaluate_segmentations,
    SegmentationResults,
)
print_elapsed('utils.eval.segmentation')
from .utils.quickstart import quickstart
print_elapsed('utils.eval.quickstart')
</p>
</details>

### What areas of FiftyOne does this bug affect?

-   [x] `App`: FiftyOne application issue
-   [x] `Core`: Core `fiftyone` Python library issue
-   [ ] `Server`: Fiftyone server issue

### Willingness to contribute

The FiftyOne Community encourages bug fix contributions. Would you or another
member of your organization be willing to contribute a fix for this bug to the
FiftyOne codebase?

-   [ ] Yes. I can contribute a fix for this bug independently.
-   [ ] Yes. I would be willing to contribute a fix for this bug with guidance
        from the FiftyOne community.
-   [x] No. I cannot contribute a bug fix at this time.


[FR] Make fiftyone --version work even if FiftyOne can't be imported?
Currently, `fiftyone --version` requires `import fiftyone as fo` to be runnable, which in turn requires, among other things, the ability to successfully connect to the database.

This presents a problem in the following situation:
- I install an incompatible (eg old) version of `fiftyone` for my database
- I try to `import fiftyone` and get an error (as expected)
- Unsure what the problem is, I want to create a GitHub issue to get some help
- As instructed by FiftyOne's issue template, I try to run `fiftyone --version` to report my package version
- I get the same error!

Thus the FR: it would be ideal if `fiftyone --version` were runnable even for a greater range of "broken" installations. So at least I can obtain my basic system information.

One way to retrieve this info is:

```py
import pkg_resources

print(pkg_resources.get_distribution("fiftyone").version)
```

But it may be better to continue to access it via `fiftyone.constants.VERSION`, but in such a way that database connection issues don't interfere with the process...

[FR] Do not create a database connection during import
### Proposal Summary

Do not setup a database connection during import. 

### Motivation

Setting up a database connection can be time consuming and error prone. Doing it implicitly during an import is unexpected (for me). It leads to errors and creates global state (that we all hate). This is a bad code smell. 

There is a very good alternative: having a client object that represents the connection. 

### What areas of FiftyOne does this feature affect?

-   [x] App: FiftyOne application
-   [x] Core: Core `fiftyone` Python library
-   [ ] Server: FiftyOne server

### Details

We including FiftyOne in our multi-gpu training code and it crashed because of it. Multiple processes tried to connect to the same port. This is an unwanted side-effect of doing an import in a file that is not used during the training. 

### Willingness to contribute

The FiftyOne Community welcomes contributions! Would you or another member of your organization be willing to contribute an implementation of this feature?

-   [ ] Yes. I can contribute this feature independently
-   [ ] Yes. I would be willing to contribute this feature with guidance from the FiftyOne community
-   [x] No. I cannot contribute this feature at this time

Hints from issue discussion:
Related: the DB is also spinning up unnecessarily (?) when connecting to a remote session.
@tylerganter do you have any thoughts on this? I don't have a good understanding of what operations should cause the DB to spin up. Another reason (besides import time) why lazily starting the DB would be good is that MongoDB logs an error when its port is already in use, which isn't relevant to tasks that aren't using the database.
Hi @nmichlo, per your benchmarking, most of the import time (~4 seconds) is due to establishing a MongoDB connection, which is mandatory for using FiftyOne (that's where all metadata is stored).

By default, importing FiftyOne spins up a MongoDB instance in a background process. This process is a singleton on the machine, however, so, if you import FiftyOne in multiple processes, imports after the first one will be faster.

One option to speed up imports is to [run MongoDB outside of FiftyOne](https://voxel51.com/docs/fiftyone/user_guide/config.html#configuring-a-mongodb-connection), in which case you'll avoid the 4 seconds of MongoDB-related setup at import.
I am not entirely sure about the architecture of FiftyOne, but some of the APIs that fiftyone provides are extremely useful on their own and I do not see any reason why they should require that MongoDB is initialised to use them?

Could fiftyone be configured to use an alternative more lightweight backend?
- Maybe something like `tinydb`?

With regards to Mongo DB
- Could initialisation be lazily run, only when required?
- Could initialisation be done in a background thread? And calls that actually need mongo access then become blocked?

It seems like there is still potential for import time optimisation:
- Many external libraries are loaded when only some features are used
- Not all the import time seems to be initialising mongo, but it is the majority.
Everything in FiftyOne requires MongoDB, because that's where all `Dataset` object's contents are stored. So deferring MongoDB startup is not an option, nor is swapping it out for another backend, because FiftyOne's API is tightly coupled with MongoDB-specific features.
That is unfortunate.

I hope in future there might be workarounds for this, especially with regards to dataset conversion and uploading to CVAT. Which are useful APIs on their own, without the fiftyone visualisation tools, but are limited by the startup times.

To reproduce

```python
from multiprocessing import Process


def main():
    import fiftyone


if __name__ == "__main__":
    processes = [Process(target=main) for i in range(2)]
    for p in processes:
        p.start()
    for p in processes:
        p.join()
```

Works with 1 process. Does not with 2. 

```
Subprocess ['.../.venv/lib/python3.10/site-packages/fiftyone/db/bin/mongod', '--dbpath', '.../.fiftyone/var/lib/mongo', '--logpath', '.../.fiftyone/var/lib/mongo/log/mongo.log', '--port', '0', '--nounixsocket'] exited with error 100:
{"t":{"$date":"2023-06-12T15:49:34.228Z"},"s":"I",  "c":"CONTROL",  "id":20697,   "ctx":"-","msg":"Renamed existing log file","attr":{"oldLogPath":".../.fiftyone/var/lib/mongo/log/mongo.log","newLogPath":".../.fiftyone/var/lib/mongo/log/mongo.log.2023-06-12T15-49-34"}}
```
Hi @pietermarsman this is something we definitely agree with having! I can't give an estimate of when this might be done though because it's not a quick fix unfortunately.

In your particular case, you could try spinning up your own mongodb instance and [pointing fiftyone to it before importing.](https://docs.voxel51.com/user_guide/config.html#configuring-a-mongodb-connection) I tested this and it worked for me locally with your script above.

e.g.,
```
# No
python pieter.py
# Yes (with mongod running at localhost:27017 of course)
FIFTYONE_DATABASE_URI=mongodb://localhost:27017 python pieter.py
```
As an aside, even without configuring a database URI as @swheaton described, FiftyOne *is* designed to support concurrent usage in multiple separate processes. EG if you import FiftyOne in multiple shells, a single child process will run the database and shut it down only when all processes are finished.

The specific problem that this issue has identified is that FiftyOne does not seem to support `multiprocessing` when no custom database URI is configured.
Actually I think it works with `multiprocessing` but the problem here is that the two processes both try to start a mongod at the exact same time. The toy script works if I add a 4s delay, for example.

```python
from multiprocessing import Process
import time

def main(i):
    time.sleep(i)
    import fiftyone


if __name__ == "__main__":
    processes = [Process(target=main, args=(i*4,)) for i in range(2)]
    for p in processes:
        p.start()
    for p in processes:
        p.join()
```
Yeah that makes sense. The problem only arises when two processes simultaneously decide they need to be the one that spawns the `mongod` subprocess.

Either way, just confirming that using a custom `database_uri` is the most robust approach to multiple connections.
Same problem here. In my case I have a huge pipeline with a yaml config that defines how an experiment should be configured (I would like to configure a mongo connection in this file). The pipeline has some top level imports (one of which contains `import fiftyone` itself). All in all this means that I have to read the yaml and execute some code before importing files which is a huge pain, because I can't have top level imports.
Hi @YuseqYaseq, configuring the connection with the environment variable `FIFTYONE_DATABASE_URI` is what we can recommend right now. Again, cleaning this up is something we are thinking about

Commit message: Revert "reorder"

This reverts commit 3b26be28c9456042be521fa31cb53ab7a0f22bca.

Patch to review:
diff --git a/docs/generate_docs.bash b/docs/generate_docs.bash
index 058c58d14dc..8ec30aedc68 100755
--- a/docs/generate_docs.bash
+++ b/docs/generate_docs.bash
@@ -125,7 +125,7 @@ fi
 
 echo "Building docs"
 # sphinx-build [OPTIONS] SOURCEDIR OUTPUTDIR [FILENAMES...]
-sphinx-build -M html source build $SPHINXOPTS
+_FIFTYONE_FORCE_DB_CONNECT_ON_IMPORT=1 sphinx-build -M html source build $SPHINXOPTS
 
 # Remove symlink to fiftyone-teams
 if [[ -n "${PATH_TO_TEAMS}" ]]; then
diff --git a/fiftyone/__init__.py b/fiftyone/__init__.py
index 5ec4db198bc..fb5b3321eb3 100644
--- a/fiftyone/__init__.py
+++ b/fiftyone/__init__.py
@@ -25,9 +25,15 @@
 from fiftyone.__public__ import *
 
 import fiftyone.core.logging as _fol
-import fiftyone.migrations as _fom
 
-_fol.init_logging()
+# The old way of doing things, migrating database on import. If we
+#   REALLY need to do this, for example doc build, we can.
+if (
+    _os.environ.get("FIFTYONE_DISABLE_SERVICES", "0") != "1"
+    and "_FIFTYONE_FORCE_DB_CONNECT_ON_IMPORT" in _os.environ
+):
+    import fiftyone.migrations as _fom
 
-if _os.environ.get("FIFTYONE_DISABLE_SERVICES", "0") != "1":
     _fom.migrate_database_if_necessary()
+
+_fol.init_logging()
diff --git a/fiftyone/__public__.py b/fiftyone/__public__.py
index 57c91c4b0ac..ff900961f47 100644
--- a/fiftyone/__public__.py
+++ b/fiftyone/__public__.py
@@ -5,16 +5,21 @@
 | `voxel51.com <https://voxel51.com/>`_
 |
 """
+import os as _os
 
 import fiftyone.core.config as _foc
-import fiftyone.core.odm as _foo
 
 config = _foc.load_config()
 annotation_config = _foc.load_annotation_config()
 evaluation_config = _foc.load_evaluation_config()
 app_config = _foc.load_app_config()
 
-_foo.establish_db_conn(config)
+# The old way of doing things, connecting to database on import. If we
+#   REALLY need to do this, for example doc build, we can.
+if "_FIFTYONE_FORCE_DB_CONNECT_ON_IMPORT" in _os.environ:
+    import fiftyone.core.odm as _foo
+
+    _foo.establish_db_conn(config)
 
 from .core.aggregations import (
     Aggregation,
diff --git a/fiftyone/core/odm/database.py b/fiftyone/core/odm/database.py
index c3244c29b25..c498cf15769 100644
--- a/fiftyone/core/odm/database.py
+++ b/fiftyone/core/odm/database.py
@@ -27,6 +27,7 @@
 
 import fiftyone as fo
 import fiftyone.constants as foc
+
 from fiftyone.core.config import FiftyOneConfigError
 import fiftyone.core.fields as fof
 import fiftyone.core.service as fos
@@ -34,6 +35,7 @@
 
 from .document import Document
 
+fom = fou.lazy_import("fiftyone.migrations")
 foa = fou.lazy_import("fiftyone.core.annotation")
 fob = fou.lazy_import("fiftyone.core.brain")
 fod = fou.lazy_import("fiftyone.core.dataset")
@@ -220,26 +222,37 @@ def establish_db_conn(config):
 
     connect(config.database_name, **_connection_kwargs)
 
-    config = get_db_config()
-    if foc.CLIENT_TYPE != config.type:
+    db_config = get_db_config()
+    if foc.CLIENT_TYPE != db_config.type:
         raise ConnectionError(
             "Cannot connect to database type '%s' with client type '%s'"
-            % (config.type, foc.CLIENT_TYPE)
+            % (db_config.type, foc.CLIENT_TYPE)
         )
 
+    # Migrate the database if necessary after establishing connection.
+    #   If we are forcing immediate connection upon import though, migration
+    #   is being performed at that time, and we cannot do it here as well,
+    #   or else it'll be a circular import.
+    if (
+        os.environ.get("FIFTYONE_DISABLE_SERVICES", "0") != "1"
+        and "_FIFTYONE_FORCE_DB_CONNECT_ON_IMPORT" not in os.environ
+    ):
+        fom.migrate_database_if_necessary()
+
 
 def _connect():
     global _client
     if _client is None:
         global _connection_kwargs
 
-        _client = pymongo.MongoClient(
-            **_connection_kwargs, appname=foc.DATABASE_APPNAME
-        )
-        connect(fo.config.database_name, **_connection_kwargs)
+        establish_db_conn(fo.config)
 
 
 def _async_connect(use_global=False):
+    # Regular connect here first, to ensure connection kwargs are established
+    #   for below.
+    _connect()
+
     global _async_client
     if not use_global or _async_client is None:
         global _connection_kwargs
diff --git a/fiftyone/factory/repo_factory.py b/fiftyone/factory/repo_factory.py
index e224b9f1fe2..3badb603227 100644
--- a/fiftyone/factory/repo_factory.py
+++ b/fiftyone/factory/repo_factory.py
@@ -15,11 +15,19 @@
     MongoDelegatedOperationRepo,
 )
 
-db: Database = foo.get_db_conn()
+_db: Database = None
+
+
+def _get_db():
+    global _db
+    if _db is None:
+        _db = foo.get_db_conn()
+    return _db
 
 
 class RepositoryFactory(object):
     repos = {}
+    db = None
 
     @staticmethod
     def delegated_operation_repo() -> DelegatedOperationRepo:
@@ -30,7 +38,9 @@ def delegated_operation_repo() -> DelegatedOperationRepo:
             RepositoryFactory.repos[
                 MongoDelegatedOperationRepo.COLLECTION_NAME
             ] = MongoDelegatedOperationRepo(
-                collection=db[MongoDelegatedOperationRepo.COLLECTION_NAME]
+                collection=_get_db()[
+                    MongoDelegatedOperationRepo.COLLECTION_NAME
+                ]
             )
 
         return RepositoryFactory.repos[


Code review comments:
diff_hunk: @@ -25,9 +25,15 @@
 from fiftyone.__public__ import *
 
 import fiftyone.core.logging as _fol
-import fiftyone.migrations as _fom
 
-_fol.init_logging()
+# The old way of doing things, migrating database on import. If we
+#   REALLY need to do this, for example doc build, we can.
+if (
+    _os.environ.get("FIFTYONE_DISABLE_SERVICES", "0") != "1"
path: fiftyone/__init__.py
text: @user1:
@author can you explain why the docs build is causing us problems here? Would love to avoid needing `_FIFTYONE_FORCE_DB_CONNECT_ON_IMPORT` if at all possible

@author:
Yeah I really hate it also 😫😫

Basically it is a mongoengine problem.

Autodoc tries to go in and touch every member and inherited member of each class. Which includes a field on the mongoengine document called "objects" which has a getter that requires the db connection 🤬

So it goes in, the connection has not been made, the "objects" getter fails, and then doc build fails.

I tried to play with autodoc settings including to ignore inherited members or specifically skip "objects", but didn't come up with anything that worked.

I'll try to think of other solutions, but brainstorming welcome

@user1:
I see. One way to clean up a bit would be to only call `migrate_database_if_necessary()` inside `establish_db_conn()`.

To limit the number of places that `_FIFTYONE_FORCE_DB_CONNECT_ON_IMPORT` needs to exist.

@user1:
you tried added `exclude-members` [to the conf](https://github.com/voxel51/fiftyone/blob/85339fcecf03978435fed6fd94565d1c63f58dd6/docs/source/conf.py#L73
)? 


```py
autodoc_default_options = {
    ...
    "exclude-members": "objects"
}
```


@author:
Ok perhaps I do not understand sphinx directives like I thought I did, but adding to `conf.py` works where directly to the automodule directive did not.
I thought I had tried that too but oh well glad it works!

diff_hunk: @@ -5,16 +5,21 @@
 | `voxel51.com <https://voxel51.com/>`_
 |
 """
+import os as _os
 
 import fiftyone.core.config as _foc
-import fiftyone.core.odm as _foo
 
 config = _foc.load_config()
 annotation_config = _foc.load_annotation_config()
 evaluation_config = _foc.load_evaluation_config()
 app_config = _foc.load_app_config()
 
-_foo.establish_db_conn(config)
+# The old way of doing things, connecting to database on import. If we
+#   REALLY need to do this, for example doc build, we can.
+if "_FIFTYONE_FORCE_DB_CONNECT_ON_IMPORT" in _os.environ:
path: fiftyone/__public__.py
text: @user1:
If the env var needs to stay, you could turn it into more of a feature by adding it to the [FiftyOneConfig](https://github.com/voxel51/fiftyone/blob/85339fcecf03978435fed6fd94565d1c63f58dd6/fiftyone/core/config.py#L63):

```py
        self._lazy_database_connection = self.parse_bool(
            d,
            "_lazy_database_connection",
            env_var="_FIFTYONE_LAZY_DATABASE_CONNECTION",
            default=True,
        )
```

Private parameters are not included in `print(fo.config)` or `$ fiftyone config` so it's both more cleanly organized and hidden by default.

Having said that, perhaps we should just make this a fully-documented public setting. It would have the benefit of allowing users to switch back to immediate db connections in the event that we discover some issue with lazy connections.