55"""
66
77import warnings
8+ import weakref
89from collections .abc import Callable , Iterator
910from itertools import chain , groupby
1011from pathlib import Path
1112from typing import Any , Literal
13+ from uuid import uuid4
1214
1315import bson
1416import mongomock_ng as mongomock
@@ -505,23 +507,126 @@ def connect(self, force_reset: bool = False):
505507
506508class MemoryStore (MongoStore ):
507509 """
508- An in-memory Store that functions similarly
509- to a MongoStore.
510+ An in-memory Store that functions similarly to a MongoStore.
511+
512+ If a MongoDB server is reachable (by default on ``localhost:27017``), the
513+ data is stored in a real, ephemeral MongoDB database for full MongoDB
514+ compatibility and performance. That database is namespaced uniquely per
515+ Store instance and is dropped automatically when the Store is garbage
516+ collected or the interpreter exits, so the user never has to create or
517+ clean up a database manually. If no server is reachable, the Store
518+ transparently falls back to an in-process ``mongomock`` database, so it
519+ works even with no MongoDB installed.
510520 """
511521
512- def __init__ (self , collection_name : str = "memory_db" , ** kwargs ):
522+ #: Set to True by connect() when a real MongoDB backend is in use. Defined
523+ #: at class level so close() is safe on subclasses (e.g. MontyStore) that
524+ #: do not call MemoryStore.__init__.
525+ _using_real_mongo : bool = False
526+ _finalizer = None
527+
528+ def __init__ (
529+ self ,
530+ collection_name : str = "memory_db" ,
531+ host : str = "localhost" ,
532+ port : int = 27017 ,
533+ mongoclient_kwargs : dict | None = None ,
534+ server_selection_timeout_ms : int = 500 ,
535+ ** kwargs ,
536+ ):
513537 """
514538 Initializes the Memory Store.
515539
516540 Args:
517541 collection_name: name for the collection in memory.
542+ host: hostname to probe for a running MongoDB server to back the
543+ Store with. The data is written to an ephemeral database that
544+ is dropped when the Store is closed.
545+ port: TCP port to probe for a running MongoDB server.
546+ mongoclient_kwargs: Dict of extra kwargs to pass to MongoClient when
547+ a real MongoDB backend is used.
548+ server_selection_timeout_ms: how long, in milliseconds, to wait when
549+ probing ``host:port`` for a MongoDB server before falling back
550+ to an in-process ``mongomock`` database. Set to 0 (or less) to
551+ skip the probe entirely and always use ``mongomock``.
518552 """
519553 self .collection_name = collection_name
554+ self .host = host
555+ self .port = port
556+ self .mongoclient_kwargs = mongoclient_kwargs or {}
557+ self .server_selection_timeout_ms = server_selection_timeout_ms
558+ # unique, ephemeral database name so that multiple MemoryStore instances
559+ # backed by the same real MongoDB server do not clobber one another
560+ self ._database = f"maggma_memory_{ uuid4 ().hex } "
520561 self .default_sort = None
521562 self ._coll = None
522563 self .kwargs = kwargs
523564 super (MongoStore , self ).__init__ (** kwargs )
524565
566+ def _get_memory_client (self ) -> MongoClient :
567+ """
568+ Return a client backing the in-memory Store.
569+
570+ Attempts to connect to a real MongoDB server at ``host:port`` for full
571+ MongoDB compatibility and performance. If none is reachable within
572+ ``server_selection_timeout_ms``, falls back to an in-process
573+ ``mongomock`` client so the Store works without a MongoDB server.
574+ """
575+ if self .server_selection_timeout_ms > 0 :
576+ mongoclient_kwargs = dict (self .mongoclient_kwargs )
577+ mongoclient_kwargs .setdefault ("serverSelectionTimeoutMS" , self .server_selection_timeout_ms )
578+ try :
579+ client = MongoClient (host = self .host , port = self .port , ** mongoclient_kwargs )
580+ # force server selection to confirm a server is actually reachable
581+ client .admin .command ("ping" )
582+ self ._using_real_mongo = True
583+ # ensure the ephemeral database is dropped when this Store is
584+ # garbage collected or the interpreter exits, so nothing is left
585+ # behind on the server
586+ if self ._finalizer is None :
587+ self ._finalizer = weakref .finalize (
588+ self ,
589+ self ._drop_ephemeral_database ,
590+ self .host ,
591+ self .port ,
592+ self ._database ,
593+ dict (mongoclient_kwargs ),
594+ )
595+ self .logger .debug (f"{ self .name } using real MongoDB backend at { self .host } :{ self .port } " )
596+ return client
597+ except Exception :
598+ self .logger .debug (f"{ self .name } : no MongoDB server reachable, falling back to mongomock" )
599+
600+ self ._using_real_mongo = False
601+ return mongomock .MongoClient () # type: ignore
602+
603+ @staticmethod
604+ def _drop_ephemeral_database (host : str , port : int , database : str , mongoclient_kwargs : dict ):
605+ """Drop an ephemeral MongoDB database. Used as a weakref finalizer, so it
606+ must not hold a reference to the Store."""
607+ mongoclient_kwargs .setdefault ("serverSelectionTimeoutMS" , 500 )
608+ try :
609+ client = MongoClient (host = host , port = port , ** mongoclient_kwargs )
610+ client .drop_database (database )
611+ client .close ()
612+ except Exception :
613+ pass
614+
615+ def _connect_collection (self , force_reset : bool = False ):
616+ """Establish (or re-establish) the underlying in-memory collection."""
617+ if force_reset and self ._coll is not None :
618+ old_client = self ._coll .database .client
619+ # on a forced reset, discard the previous contents (matching the
620+ # historical mongomock behavior of starting from a fresh client)
621+ if getattr (self , "_using_real_mongo" , False ):
622+ try :
623+ old_client .drop_database (self ._database )
624+ except Exception :
625+ pass
626+ old_client .close ()
627+ client = self ._get_memory_client ()
628+ self ._coll = client [self ._database ][self .collection_name ] # type: ignore
629+
525630 def connect (self , force_reset : bool = False ):
526631 """
527632 Connect to the source data.
@@ -531,11 +636,20 @@ def connect(self, force_reset: bool = False):
531636 already connected.
532637 """
533638 if self ._coll is None or force_reset :
534- self ._coll = mongomock . MongoClient (). db [ self . name ] # type: ignore
639+ self ._connect_collection ( force_reset = force_reset )
535640
536641 def close (self ):
537- """Close up all collections."""
538- self ._coll .database .client .close ()
642+ """Close up all collections.
643+
644+ For an in-memory Store this is intentionally a no-op that leaves the
645+ Store usable, matching the historical ``mongomock`` behavior (its
646+ ``close()`` did nothing) that callers such as the builders rely on when
647+ they query a Store after it has been closed. Resources are released and
648+ any ephemeral MongoDB database backing the Store is dropped when the
649+ Store is garbage collected or at interpreter exit (see
650+ ``_drop_ephemeral_database``). Use ``force_reset=True`` on ``connect()``
651+ to explicitly discard the contents and start fresh.
652+ """
539653
540654 @property
541655 def name (self ):
@@ -682,7 +796,7 @@ def connect(self, force_reset: bool = False):
682796 on systems with slow storage when multiple connect / disconnects are performed.
683797 """
684798 if self ._coll is None or force_reset :
685- self ._coll = mongomock . MongoClient (). db [ self . name ] # type: ignore
799+ self ._connect_collection ( force_reset = force_reset )
686800
687801 # create the .json file if it does not exist
688802 if not self .read_only and not Path (self .paths [0 ]).exists ():
@@ -877,6 +991,11 @@ def connect(self, force_reset: bool = False):
877991 client = MontyClient (self .database_path , ** self .client_kwargs )
878992 self ._coll = client [self .database_name ][self .collection_name ]
879993
994+ def close (self ):
995+ """Close up the MontyDB client."""
996+ if self ._coll is not None :
997+ self ._coll .database .client .close ()
998+
880999 @property
8811000 def name (self ) -> str :
8821001 """Return a string representing this data source."""
0 commit comments