mdb-engine 0.1.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mdb_engine/README.md +144 -0
- mdb_engine/__init__.py +37 -0
- mdb_engine/auth/README.md +631 -0
- mdb_engine/auth/__init__.py +128 -0
- mdb_engine/auth/casbin_factory.py +199 -0
- mdb_engine/auth/casbin_models.py +46 -0
- mdb_engine/auth/config_defaults.py +71 -0
- mdb_engine/auth/config_helpers.py +213 -0
- mdb_engine/auth/cookie_utils.py +158 -0
- mdb_engine/auth/decorators.py +350 -0
- mdb_engine/auth/dependencies.py +747 -0
- mdb_engine/auth/helpers.py +64 -0
- mdb_engine/auth/integration.py +578 -0
- mdb_engine/auth/jwt.py +225 -0
- mdb_engine/auth/middleware.py +241 -0
- mdb_engine/auth/oso_factory.py +323 -0
- mdb_engine/auth/provider.py +570 -0
- mdb_engine/auth/restrictions.py +271 -0
- mdb_engine/auth/session_manager.py +477 -0
- mdb_engine/auth/token_lifecycle.py +213 -0
- mdb_engine/auth/token_store.py +289 -0
- mdb_engine/auth/users.py +1516 -0
- mdb_engine/auth/utils.py +614 -0
- mdb_engine/cli/__init__.py +13 -0
- mdb_engine/cli/commands/__init__.py +7 -0
- mdb_engine/cli/commands/generate.py +105 -0
- mdb_engine/cli/commands/migrate.py +83 -0
- mdb_engine/cli/commands/show.py +70 -0
- mdb_engine/cli/commands/validate.py +63 -0
- mdb_engine/cli/main.py +41 -0
- mdb_engine/cli/utils.py +92 -0
- mdb_engine/config.py +217 -0
- mdb_engine/constants.py +160 -0
- mdb_engine/core/README.md +542 -0
- mdb_engine/core/__init__.py +42 -0
- mdb_engine/core/app_registration.py +392 -0
- mdb_engine/core/connection.py +243 -0
- mdb_engine/core/engine.py +749 -0
- mdb_engine/core/index_management.py +162 -0
- mdb_engine/core/manifest.py +2793 -0
- mdb_engine/core/seeding.py +179 -0
- mdb_engine/core/service_initialization.py +355 -0
- mdb_engine/core/types.py +413 -0
- mdb_engine/database/README.md +522 -0
- mdb_engine/database/__init__.py +31 -0
- mdb_engine/database/abstraction.py +635 -0
- mdb_engine/database/connection.py +387 -0
- mdb_engine/database/scoped_wrapper.py +1721 -0
- mdb_engine/embeddings/README.md +184 -0
- mdb_engine/embeddings/__init__.py +62 -0
- mdb_engine/embeddings/dependencies.py +193 -0
- mdb_engine/embeddings/service.py +759 -0
- mdb_engine/exceptions.py +167 -0
- mdb_engine/indexes/README.md +651 -0
- mdb_engine/indexes/__init__.py +21 -0
- mdb_engine/indexes/helpers.py +145 -0
- mdb_engine/indexes/manager.py +895 -0
- mdb_engine/memory/README.md +451 -0
- mdb_engine/memory/__init__.py +30 -0
- mdb_engine/memory/service.py +1285 -0
- mdb_engine/observability/README.md +515 -0
- mdb_engine/observability/__init__.py +42 -0
- mdb_engine/observability/health.py +296 -0
- mdb_engine/observability/logging.py +161 -0
- mdb_engine/observability/metrics.py +297 -0
- mdb_engine/routing/README.md +462 -0
- mdb_engine/routing/__init__.py +73 -0
- mdb_engine/routing/websockets.py +813 -0
- mdb_engine/utils/__init__.py +7 -0
- mdb_engine-0.1.6.dist-info/METADATA +213 -0
- mdb_engine-0.1.6.dist-info/RECORD +75 -0
- mdb_engine-0.1.6.dist-info/WHEEL +5 -0
- mdb_engine-0.1.6.dist-info/entry_points.txt +2 -0
- mdb_engine-0.1.6.dist-info/licenses/LICENSE +661 -0
- mdb_engine-0.1.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1721 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Asynchronous MongoDB Scoped Wrapper
|
|
3
|
+
|
|
4
|
+
Provides an asynchronous, app-scoped proxy wrapper around Motor's
|
|
5
|
+
`AsyncIOMotorDatabase` and `AsyncIOMotorCollection` objects.
|
|
6
|
+
|
|
7
|
+
This module is part of MDB_ENGINE - MongoDB Engine.
|
|
8
|
+
|
|
9
|
+
Core Features:
|
|
10
|
+
- `ScopedMongoWrapper`: Proxies a database. When a collection is
|
|
11
|
+
accessed (e.g., `db.my_collection`), it returns a `ScopedCollectionWrapper`.
|
|
12
|
+
- `ScopedCollectionWrapper`: Proxies a collection, automatically injecting
|
|
13
|
+
`app_id` filters into all read operations (find, aggregate, count)
|
|
14
|
+
and adding the `app_id` to all write operations (insert).
|
|
15
|
+
- `AsyncAtlasIndexManager`: Provides an async-native interface for managing
|
|
16
|
+
both standard MongoDB indexes and Atlas Search/Vector indexes. This
|
|
17
|
+
manager is available via `collection_wrapper.index_manager` and
|
|
18
|
+
operates on the *unscoped* collection for administrative purposes.
|
|
19
|
+
- `AutoIndexManager`: Automatic index management! Automatically
|
|
20
|
+
creates indexes based on query patterns, making it easy to use collections
|
|
21
|
+
without manual index configuration. Enabled by default for all apps.
|
|
22
|
+
|
|
23
|
+
This design ensures data isolation between apps while providing
|
|
24
|
+
a familiar (Motor-like) developer experience with automatic index optimization.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import logging
|
|
29
|
+
import time
|
|
30
|
+
from typing import (Any, ClassVar, Coroutine, Dict, List, Mapping, Optional,
|
|
31
|
+
Tuple, Union)
|
|
32
|
+
|
|
33
|
+
from motor.motor_asyncio import (AsyncIOMotorCollection, AsyncIOMotorCursor,
|
|
34
|
+
AsyncIOMotorDatabase)
|
|
35
|
+
from pymongo import ASCENDING, DESCENDING, TEXT
|
|
36
|
+
from pymongo.errors import (AutoReconnect, CollectionInvalid,
|
|
37
|
+
ConnectionFailure, InvalidOperation,
|
|
38
|
+
OperationFailure, ServerSelectionTimeoutError)
|
|
39
|
+
from pymongo.operations import SearchIndexModel
|
|
40
|
+
from pymongo.results import (DeleteResult, InsertManyResult, InsertOneResult,
|
|
41
|
+
UpdateResult)
|
|
42
|
+
|
|
43
|
+
# Import constants
|
|
44
|
+
from ..constants import (AUTO_INDEX_HINT_THRESHOLD, DEFAULT_DROP_TIMEOUT,
|
|
45
|
+
DEFAULT_POLL_INTERVAL, DEFAULT_SEARCH_TIMEOUT,
|
|
46
|
+
MAX_INDEX_FIELDS)
|
|
47
|
+
from ..exceptions import MongoDBEngineError
|
|
48
|
+
# Import observability
|
|
49
|
+
from ..observability import record_operation
|
|
50
|
+
|
|
51
|
+
# --- FIX: Configure logger *before* first use ---
|
|
52
|
+
logger = logging.getLogger(__name__)
|
|
53
|
+
# --- END FIX ---
|
|
54
|
+
|
|
55
|
+
# --- PyMongo 4.x Compatibility ---
|
|
56
|
+
# PyMongo 4.x removed the GEO2DSPHERE constant.
|
|
57
|
+
# Use the string "2dsphere" directly (this is what PyMongo 4.x expects).
|
|
58
|
+
GEO2DSPHERE = "2dsphere"
|
|
59
|
+
# --- END FIX ---
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --- HELPER FUNCTION FOR MANAGED TASK CREATION ---
|
|
63
|
+
def _create_managed_task(
|
|
64
|
+
coro: Coroutine[Any, Any, Any], task_name: Optional[str] = None
|
|
65
|
+
) -> None:
|
|
66
|
+
"""
|
|
67
|
+
Creates a background task using asyncio.create_task().
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
coro: Coroutine to run as a background task
|
|
71
|
+
task_name: Optional name for the task (for monitoring/debugging, currently unused)
|
|
72
|
+
|
|
73
|
+
Note:
|
|
74
|
+
If no event loop is running, the task creation is skipped silently.
|
|
75
|
+
This allows the code to work in both async and sync contexts.
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
asyncio.get_running_loop()
|
|
79
|
+
asyncio.create_task(coro)
|
|
80
|
+
except RuntimeError:
|
|
81
|
+
# No event loop running - skip task creation
|
|
82
|
+
# This can happen in synchronous contexts (e.g., tests, sync code)
|
|
83
|
+
logger.debug(f"Skipping background task '{task_name}' - no event loop running")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# --- END HELPER FUNCTION ---
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ##########################################################################
|
|
90
|
+
# ASYNCHRONOUS ATLAS INDEX MANAGER
|
|
91
|
+
# ##########################################################################
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class AsyncAtlasIndexManager:
|
|
95
|
+
"""
|
|
96
|
+
Manages MongoDB Atlas Search indexes (Vector & Lucene) and standard
|
|
97
|
+
database indexes with an asynchronous (Motor-native) interface.
|
|
98
|
+
|
|
99
|
+
This class provides a robust, high-level API for index operations,
|
|
100
|
+
including 'wait_for_ready' polling logic to handle the asynchronous
|
|
101
|
+
nature of Atlas index builds.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
# Use __slots__ for minor performance gain (faster attribute access)
|
|
105
|
+
__slots__ = ("_collection",)
|
|
106
|
+
|
|
107
|
+
# --- Class-level constants for polling and timeouts ---
|
|
108
|
+
# Use constants from constants module
|
|
109
|
+
DEFAULT_POLL_INTERVAL: ClassVar[int] = DEFAULT_POLL_INTERVAL
|
|
110
|
+
DEFAULT_SEARCH_TIMEOUT: ClassVar[int] = DEFAULT_SEARCH_TIMEOUT
|
|
111
|
+
DEFAULT_DROP_TIMEOUT: ClassVar[int] = DEFAULT_DROP_TIMEOUT
|
|
112
|
+
|
|
113
|
+
def __init__(self, real_collection: AsyncIOMotorCollection):
|
|
114
|
+
"""
|
|
115
|
+
Initializes the manager with a direct reference to a
|
|
116
|
+
motor.motor_asyncio.AsyncIOMotorCollection.
|
|
117
|
+
"""
|
|
118
|
+
if not isinstance(real_collection, AsyncIOMotorCollection):
|
|
119
|
+
raise TypeError(
|
|
120
|
+
f"Expected AsyncIOMotorCollection, got {type(real_collection)}"
|
|
121
|
+
)
|
|
122
|
+
self._collection = real_collection
|
|
123
|
+
|
|
124
|
+
async def _ensure_collection_exists(self) -> None:
|
|
125
|
+
"""Ensure the collection exists before creating an index."""
|
|
126
|
+
try:
|
|
127
|
+
coll_name = self._collection.name
|
|
128
|
+
await self._collection.database.create_collection(coll_name)
|
|
129
|
+
logger.debug(f"Ensured collection '{coll_name}' exists.")
|
|
130
|
+
except CollectionInvalid as e:
|
|
131
|
+
if "already exists" in str(e):
|
|
132
|
+
logger.warning(
|
|
133
|
+
f"Prerequisite collection '{coll_name}' already exists. "
|
|
134
|
+
f"Continuing index creation."
|
|
135
|
+
)
|
|
136
|
+
else:
|
|
137
|
+
logger.exception(
|
|
138
|
+
"Failed to ensure collection exists - CollectionInvalid error"
|
|
139
|
+
)
|
|
140
|
+
raise MongoDBEngineError(
|
|
141
|
+
f"Failed to create prerequisite collection '{self._collection.name}'",
|
|
142
|
+
context={"collection_name": self._collection.name},
|
|
143
|
+
) from e
|
|
144
|
+
except (ConnectionFailure, ServerSelectionTimeoutError) as e:
|
|
145
|
+
logger.exception("Failed to ensure collection exists - connection error")
|
|
146
|
+
raise MongoDBEngineError(
|
|
147
|
+
f"Failed to create prerequisite collection "
|
|
148
|
+
f"'{self._collection.name}' - connection failed",
|
|
149
|
+
context={"collection_name": self._collection.name},
|
|
150
|
+
) from e
|
|
151
|
+
except (OperationFailure, InvalidOperation) as e:
|
|
152
|
+
logger.exception("Error ensuring collection exists")
|
|
153
|
+
raise MongoDBEngineError(
|
|
154
|
+
f"Error creating prerequisite collection '{self._collection.name}'",
|
|
155
|
+
context={"collection_name": self._collection.name},
|
|
156
|
+
) from e
|
|
157
|
+
|
|
158
|
+
def _check_definition_changed(
|
|
159
|
+
self,
|
|
160
|
+
definition: Dict[str, Any],
|
|
161
|
+
latest_def: Dict[str, Any],
|
|
162
|
+
index_type: str,
|
|
163
|
+
name: str,
|
|
164
|
+
) -> Tuple[bool, str]:
|
|
165
|
+
"""Check if index definition has changed."""
|
|
166
|
+
definition_changed = False
|
|
167
|
+
change_reason = ""
|
|
168
|
+
if "fields" in definition and index_type.lower() == "vectorsearch":
|
|
169
|
+
existing_fields = latest_def.get("fields")
|
|
170
|
+
if existing_fields != definition["fields"]:
|
|
171
|
+
definition_changed = True
|
|
172
|
+
change_reason = "vector 'fields' definition differs."
|
|
173
|
+
elif "mappings" in definition and index_type.lower() == "search":
|
|
174
|
+
existing_mappings = latest_def.get("mappings")
|
|
175
|
+
if existing_mappings != definition["mappings"]:
|
|
176
|
+
definition_changed = True
|
|
177
|
+
change_reason = "Lucene 'mappings' definition differs."
|
|
178
|
+
else:
|
|
179
|
+
logger.warning(
|
|
180
|
+
f"Index definition '{name}' has keys that don't match "
|
|
181
|
+
f"index_type '{index_type}'. Cannot reliably check for changes."
|
|
182
|
+
)
|
|
183
|
+
return definition_changed, change_reason
|
|
184
|
+
|
|
185
|
+
async def _handle_existing_index(
|
|
186
|
+
self,
|
|
187
|
+
existing_index: Dict[str, Any],
|
|
188
|
+
definition: Dict[str, Any],
|
|
189
|
+
index_type: str,
|
|
190
|
+
name: str,
|
|
191
|
+
) -> bool:
|
|
192
|
+
"""Handle existing index - check for changes and update if needed."""
|
|
193
|
+
logger.info(f"Search index '{name}' already exists.")
|
|
194
|
+
latest_def = existing_index.get("latestDefinition", {})
|
|
195
|
+
definition_changed, change_reason = self._check_definition_changed(
|
|
196
|
+
definition, latest_def, index_type, name
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
if definition_changed:
|
|
200
|
+
logger.warning(
|
|
201
|
+
f"Search index '{name}' definition has changed "
|
|
202
|
+
f"({change_reason}). Triggering update..."
|
|
203
|
+
)
|
|
204
|
+
await self.update_search_index(
|
|
205
|
+
name=name,
|
|
206
|
+
definition=definition,
|
|
207
|
+
wait_for_ready=False,
|
|
208
|
+
)
|
|
209
|
+
return False # Will wait below
|
|
210
|
+
elif existing_index.get("queryable"):
|
|
211
|
+
logger.info(
|
|
212
|
+
f"Search index '{name}' is already queryable and definition is up-to-date."
|
|
213
|
+
)
|
|
214
|
+
return True
|
|
215
|
+
elif existing_index.get("status") == "FAILED":
|
|
216
|
+
logger.error(
|
|
217
|
+
f"Search index '{name}' exists but is in a FAILED state. "
|
|
218
|
+
f"Manual intervention in Atlas UI may be required."
|
|
219
|
+
)
|
|
220
|
+
return False
|
|
221
|
+
else:
|
|
222
|
+
logger.info(
|
|
223
|
+
f"Search index '{name}' exists and is up-to-date, "
|
|
224
|
+
f"but not queryable (Status: {existing_index.get('status')}). Waiting..."
|
|
225
|
+
)
|
|
226
|
+
return False # Will wait below
|
|
227
|
+
|
|
228
|
+
async def _create_new_search_index(
|
|
229
|
+
self, name: str, definition: Dict[str, Any], index_type: str
|
|
230
|
+
) -> None:
|
|
231
|
+
"""Create a new search index."""
|
|
232
|
+
try:
|
|
233
|
+
logger.info(f"Creating new search index '{name}' of type '{index_type}'...")
|
|
234
|
+
search_index_model = SearchIndexModel(
|
|
235
|
+
definition=definition, name=name, type=index_type
|
|
236
|
+
)
|
|
237
|
+
await self._collection.create_search_index(model=search_index_model)
|
|
238
|
+
logger.info(f"Search index '{name}' build has been submitted.")
|
|
239
|
+
except OperationFailure as e:
|
|
240
|
+
if "IndexAlreadyExists" in str(e) or "DuplicateIndexName" in str(e):
|
|
241
|
+
logger.warning(
|
|
242
|
+
f"Race condition: Index '{name}' was created by another process."
|
|
243
|
+
)
|
|
244
|
+
else:
|
|
245
|
+
logger.error(
|
|
246
|
+
f"OperationFailure during search index creation "
|
|
247
|
+
f"for '{name}': {e.details}"
|
|
248
|
+
)
|
|
249
|
+
raise e
|
|
250
|
+
|
|
251
|
+
async def create_search_index(
|
|
252
|
+
self,
|
|
253
|
+
name: str,
|
|
254
|
+
definition: Dict[str, Any],
|
|
255
|
+
index_type: str = "search",
|
|
256
|
+
wait_for_ready: bool = True,
|
|
257
|
+
timeout: int = DEFAULT_SEARCH_TIMEOUT,
|
|
258
|
+
) -> bool:
|
|
259
|
+
"""
|
|
260
|
+
Creates or updates an Atlas Search index.
|
|
261
|
+
|
|
262
|
+
This method is idempotent. It checks if an index with the same name
|
|
263
|
+
and definition already exists and is queryable. If it exists but the
|
|
264
|
+
definition has changed, it triggers an update. If it's building,
|
|
265
|
+
it waits. If it doesn't exist, it creates it.
|
|
266
|
+
"""
|
|
267
|
+
await self._ensure_collection_exists()
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
existing_index = await self.get_search_index(name)
|
|
271
|
+
|
|
272
|
+
if existing_index:
|
|
273
|
+
is_ready = await self._handle_existing_index(
|
|
274
|
+
existing_index, definition, index_type, name
|
|
275
|
+
)
|
|
276
|
+
if is_ready:
|
|
277
|
+
return True
|
|
278
|
+
else:
|
|
279
|
+
await self._create_new_search_index(name, definition, index_type)
|
|
280
|
+
|
|
281
|
+
if wait_for_ready:
|
|
282
|
+
return await self._wait_for_search_index_ready(name, timeout)
|
|
283
|
+
return True
|
|
284
|
+
|
|
285
|
+
except OperationFailure as e:
|
|
286
|
+
logger.exception(
|
|
287
|
+
f"OperationFailure during search index creation/check for '{name}'"
|
|
288
|
+
)
|
|
289
|
+
raise MongoDBEngineError(
|
|
290
|
+
f"Failed to create/check search index '{name}'",
|
|
291
|
+
context={"index_name": name, "operation": "create_search_index"},
|
|
292
|
+
) from e
|
|
293
|
+
except (ConnectionFailure, ServerSelectionTimeoutError) as e:
|
|
294
|
+
logger.exception(
|
|
295
|
+
f"Connection error during search index creation/check for '{name}'"
|
|
296
|
+
)
|
|
297
|
+
raise MongoDBEngineError(
|
|
298
|
+
f"Connection failed while creating/checking search index '{name}'",
|
|
299
|
+
context={"index_name": name, "operation": "create_search_index"},
|
|
300
|
+
) from e
|
|
301
|
+
except (OperationFailure, InvalidOperation) as e:
|
|
302
|
+
logger.exception(f"Error during search index creation/check for '{name}'")
|
|
303
|
+
raise MongoDBEngineError(
|
|
304
|
+
f"Error creating/checking search index '{name}'",
|
|
305
|
+
context={"index_name": name, "operation": "create_search_index"},
|
|
306
|
+
) from e
|
|
307
|
+
|
|
308
|
+
async def get_search_index(self, name: str) -> Optional[Dict[str, Any]]:
|
|
309
|
+
"""
|
|
310
|
+
Retrieves the definition and status of a single search index by name
|
|
311
|
+
using the $listSearchIndexes aggregation stage.
|
|
312
|
+
"""
|
|
313
|
+
try:
|
|
314
|
+
pipeline = [{"$listSearchIndexes": {"name": name}}]
|
|
315
|
+
async for index_info in self._collection.aggregate(pipeline):
|
|
316
|
+
# We expect only one or zero results
|
|
317
|
+
return index_info
|
|
318
|
+
return None
|
|
319
|
+
except OperationFailure:
|
|
320
|
+
logger.exception(f"OperationFailure retrieving search index '{name}'")
|
|
321
|
+
return None
|
|
322
|
+
except (ConnectionFailure, ServerSelectionTimeoutError):
|
|
323
|
+
logger.exception(f"Connection error retrieving search index '{name}'")
|
|
324
|
+
return None
|
|
325
|
+
except (OperationFailure, InvalidOperation) as e:
|
|
326
|
+
logger.exception(f"Error retrieving search index '{name}'")
|
|
327
|
+
raise MongoDBEngineError(
|
|
328
|
+
f"Error retrieving search index '{name}'",
|
|
329
|
+
context={"index_name": name, "operation": "get_search_index"},
|
|
330
|
+
) from e
|
|
331
|
+
|
|
332
|
+
async def list_search_indexes(self) -> List[Dict[str, Any]]:
|
|
333
|
+
"""Lists all Atlas Search indexes for the collection."""
|
|
334
|
+
try:
|
|
335
|
+
return await self._collection.list_search_indexes().to_list(None)
|
|
336
|
+
except (OperationFailure, ConnectionFailure, ServerSelectionTimeoutError):
|
|
337
|
+
logger.exception("Database error listing search indexes")
|
|
338
|
+
return []
|
|
339
|
+
except InvalidOperation:
|
|
340
|
+
# Client closed - return empty list
|
|
341
|
+
logger.debug("Cannot list search indexes: MongoDB client is closed")
|
|
342
|
+
return []
|
|
343
|
+
|
|
344
|
+
async def drop_search_index(
|
|
345
|
+
self, name: str, wait_for_drop: bool = True, timeout: int = DEFAULT_DROP_TIMEOUT
|
|
346
|
+
) -> bool:
|
|
347
|
+
"""
|
|
348
|
+
Drops an Atlas Search index by name.
|
|
349
|
+
"""
|
|
350
|
+
try:
|
|
351
|
+
# Check if index exists before trying to drop
|
|
352
|
+
if not await self.get_search_index(name):
|
|
353
|
+
logger.info(f"Search index '{name}' does not exist. Nothing to drop.")
|
|
354
|
+
return True
|
|
355
|
+
|
|
356
|
+
await self._collection.drop_search_index(name=name)
|
|
357
|
+
logger.info(f"Submitted request to drop search index '{name}'.")
|
|
358
|
+
|
|
359
|
+
if wait_for_drop:
|
|
360
|
+
return await self._wait_for_search_index_drop(name, timeout)
|
|
361
|
+
return True
|
|
362
|
+
except OperationFailure as e:
|
|
363
|
+
# Handle race condition where index was already dropped
|
|
364
|
+
if "IndexNotFound" in str(e):
|
|
365
|
+
logger.info(
|
|
366
|
+
f"Search index '{name}' was already deleted (race condition)."
|
|
367
|
+
)
|
|
368
|
+
return True
|
|
369
|
+
logger.exception(f"OperationFailure dropping search index '{name}'")
|
|
370
|
+
raise MongoDBEngineError(
|
|
371
|
+
f"Failed to drop search index '{name}'",
|
|
372
|
+
context={"index_name": name, "operation": "drop_search_index"},
|
|
373
|
+
) from e
|
|
374
|
+
except (ConnectionFailure, ServerSelectionTimeoutError) as e:
|
|
375
|
+
logger.exception(f"Connection error dropping search index '{name}'")
|
|
376
|
+
raise MongoDBEngineError(
|
|
377
|
+
f"Connection failed while dropping search index '{name}'",
|
|
378
|
+
context={"index_name": name, "operation": "drop_search_index"},
|
|
379
|
+
) from e
|
|
380
|
+
except (OperationFailure, InvalidOperation) as e:
|
|
381
|
+
logger.exception(f"Error dropping search index '{name}'")
|
|
382
|
+
raise MongoDBEngineError(
|
|
383
|
+
f"Error dropping search index '{name}'",
|
|
384
|
+
context={"index_name": name, "operation": "drop_search_index"},
|
|
385
|
+
) from e
|
|
386
|
+
|
|
387
|
+
async def update_search_index(
|
|
388
|
+
self,
|
|
389
|
+
name: str,
|
|
390
|
+
definition: Dict[str, Any],
|
|
391
|
+
wait_for_ready: bool = True,
|
|
392
|
+
timeout: int = DEFAULT_SEARCH_TIMEOUT,
|
|
393
|
+
) -> bool:
|
|
394
|
+
"""
|
|
395
|
+
Updates the definition of an existing Atlas Search index.
|
|
396
|
+
This will trigger a rebuild of the index.
|
|
397
|
+
"""
|
|
398
|
+
try:
|
|
399
|
+
logger.info(f"Updating search index '{name}'...")
|
|
400
|
+
await self._collection.update_search_index(name=name, definition=definition)
|
|
401
|
+
logger.info(f"Search index '{name}' update submitted. Rebuild initiated.")
|
|
402
|
+
if wait_for_ready:
|
|
403
|
+
return await self._wait_for_search_index_ready(name, timeout)
|
|
404
|
+
return True
|
|
405
|
+
except OperationFailure as e:
|
|
406
|
+
logger.exception(f"OperationFailure updating search index '{name}'")
|
|
407
|
+
raise MongoDBEngineError(
|
|
408
|
+
f"Failed to update search index '{name}'",
|
|
409
|
+
context={"index_name": name, "operation": "update_search_index"},
|
|
410
|
+
) from e
|
|
411
|
+
except (ConnectionFailure, ServerSelectionTimeoutError) as e:
|
|
412
|
+
logger.exception(f"Connection error updating search index '{name}'")
|
|
413
|
+
raise MongoDBEngineError(
|
|
414
|
+
f"Connection failed while updating search index '{name}'",
|
|
415
|
+
context={"index_name": name, "operation": "update_search_index"},
|
|
416
|
+
) from e
|
|
417
|
+
except (OperationFailure, InvalidOperation) as e:
|
|
418
|
+
logger.exception(f"Error updating search index '{name}'")
|
|
419
|
+
raise MongoDBEngineError(
|
|
420
|
+
f"Error updating search index '{name}'",
|
|
421
|
+
context={"index_name": name, "operation": "update_search_index"},
|
|
422
|
+
) from e
|
|
423
|
+
|
|
424
|
+
async def _wait_for_search_index_ready(self, name: str, timeout: int) -> bool:
|
|
425
|
+
"""
|
|
426
|
+
Private helper to poll the index status until it becomes
|
|
427
|
+
queryable or fails.
|
|
428
|
+
"""
|
|
429
|
+
start_time = time.time()
|
|
430
|
+
logger.info(
|
|
431
|
+
f"Waiting up to {timeout}s for search index '{name}' to become queryable..."
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
while True:
|
|
435
|
+
elapsed = time.time() - start_time
|
|
436
|
+
if elapsed > timeout:
|
|
437
|
+
logger.error(
|
|
438
|
+
f"Timeout: Index '{name}' did not become queryable within {timeout}s."
|
|
439
|
+
)
|
|
440
|
+
raise TimeoutError(
|
|
441
|
+
f"Index '{name}' did not become queryable within {timeout}s."
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
index_info = None
|
|
445
|
+
try:
|
|
446
|
+
# Poll for the index status
|
|
447
|
+
index_info = await self.get_search_index(name)
|
|
448
|
+
except (
|
|
449
|
+
OperationFailure,
|
|
450
|
+
AutoReconnect,
|
|
451
|
+
ConnectionFailure,
|
|
452
|
+
ServerSelectionTimeoutError,
|
|
453
|
+
) as e:
|
|
454
|
+
# Handle transient network/DB errors during polling
|
|
455
|
+
logger.warning(
|
|
456
|
+
f"DB Error during polling for index '{name}': "
|
|
457
|
+
f"{getattr(e, 'details', e)}. Retrying..."
|
|
458
|
+
)
|
|
459
|
+
# Continue polling for transient errors
|
|
460
|
+
|
|
461
|
+
if index_info:
|
|
462
|
+
status = index_info.get("status")
|
|
463
|
+
if status == "FAILED":
|
|
464
|
+
# The build failed permanently
|
|
465
|
+
logger.error(
|
|
466
|
+
f"Search index '{name}' failed to build "
|
|
467
|
+
f"(Status: FAILED). Check Atlas UI for details."
|
|
468
|
+
)
|
|
469
|
+
raise Exception(f"Index build failed for '{name}'.")
|
|
470
|
+
|
|
471
|
+
queryable = index_info.get("queryable")
|
|
472
|
+
if queryable:
|
|
473
|
+
# Success!
|
|
474
|
+
logger.info(
|
|
475
|
+
f"Search index '{name}' is queryable (Status: {status})."
|
|
476
|
+
)
|
|
477
|
+
return True
|
|
478
|
+
|
|
479
|
+
# Not ready yet, log and wait
|
|
480
|
+
logger.info(
|
|
481
|
+
f"Polling for '{name}'. Status: {status}. "
|
|
482
|
+
f"Queryable: {queryable}. Elapsed: {elapsed:.0f}s"
|
|
483
|
+
)
|
|
484
|
+
else:
|
|
485
|
+
# Index not found yet (can happen right after creation command)
|
|
486
|
+
logger.info(
|
|
487
|
+
f"Polling for '{name}'. Index not found yet "
|
|
488
|
+
f"(normal during creation). Elapsed: {elapsed:.0f}s"
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
await asyncio.sleep(self.DEFAULT_POLL_INTERVAL)
|
|
492
|
+
|
|
493
|
+
async def _wait_for_search_index_drop(self, name: str, timeout: int) -> bool:
|
|
494
|
+
"""
|
|
495
|
+
Private helper to poll until an index is successfully dropped.
|
|
496
|
+
"""
|
|
497
|
+
start_time = time.time()
|
|
498
|
+
logger.info(
|
|
499
|
+
f"Waiting up to {timeout}s for search index '{name}' to be dropped..."
|
|
500
|
+
)
|
|
501
|
+
while True:
|
|
502
|
+
if time.time() - start_time > timeout:
|
|
503
|
+
logger.error(
|
|
504
|
+
f"Timeout: Index '{name}' was not dropped within {timeout}s."
|
|
505
|
+
)
|
|
506
|
+
raise TimeoutError(f"Index '{name}' was not dropped within {timeout}s.")
|
|
507
|
+
|
|
508
|
+
index_info = await self.get_search_index(name)
|
|
509
|
+
if not index_info:
|
|
510
|
+
# Success! Index is gone.
|
|
511
|
+
logger.info(f"Search index '{name}' has been successfully dropped.")
|
|
512
|
+
return True
|
|
513
|
+
|
|
514
|
+
logger.debug(
|
|
515
|
+
f"Polling for '{name}' drop. Still present. "
|
|
516
|
+
f"Elapsed: {time.time() - start_time:.0f}s"
|
|
517
|
+
)
|
|
518
|
+
await asyncio.sleep(self.DEFAULT_POLL_INTERVAL)
|
|
519
|
+
|
|
520
|
+
# --- Regular Database Index Methods ---
|
|
521
|
+
# These methods wrap the standard Motor index commands for a
|
|
522
|
+
# consistent async API with the search index methods.
|
|
523
|
+
|
|
524
|
+
async def create_index( # noqa: C901
|
|
525
|
+
self, keys: Union[str, List[Tuple[str, Union[int, str]]]], **kwargs: Any
|
|
526
|
+
) -> str:
|
|
527
|
+
"""
|
|
528
|
+
Creates a standard (non-search) database index.
|
|
529
|
+
Idempotent: checks if the index already exists first.
|
|
530
|
+
"""
|
|
531
|
+
if isinstance(keys, str):
|
|
532
|
+
keys = [(keys, ASCENDING)]
|
|
533
|
+
|
|
534
|
+
# Attempt to auto-generate the index name if not provided
|
|
535
|
+
index_name = kwargs.get("name")
|
|
536
|
+
if not index_name:
|
|
537
|
+
# PyMongo 4.x: Generate index name from keys
|
|
538
|
+
# Use a simple fallback that works across all PyMongo versions
|
|
539
|
+
# Format: field1_1_field2_-1 (1 for ASCENDING, -1 for DESCENDING, "2dsphere" for geo)
|
|
540
|
+
name_parts = []
|
|
541
|
+
for key, direction in keys:
|
|
542
|
+
if isinstance(direction, str):
|
|
543
|
+
# Handle string directions like "2dsphere", "text", etc.
|
|
544
|
+
name_parts.append(f"{key}_{direction}")
|
|
545
|
+
elif direction == ASCENDING:
|
|
546
|
+
name_parts.append(f"{key}_1")
|
|
547
|
+
elif direction == DESCENDING:
|
|
548
|
+
name_parts.append(f"{key}_-1")
|
|
549
|
+
else:
|
|
550
|
+
name_parts.append(f"{key}_{direction}")
|
|
551
|
+
index_name = "_".join(name_parts)
|
|
552
|
+
|
|
553
|
+
try:
|
|
554
|
+
# Check if index already exists
|
|
555
|
+
try:
|
|
556
|
+
existing_indexes = await self.list_indexes()
|
|
557
|
+
except InvalidOperation:
|
|
558
|
+
# Client is closed (e.g., during shutdown/teardown)
|
|
559
|
+
logger.debug(
|
|
560
|
+
"Skipping index existence check: MongoDB client is closed. "
|
|
561
|
+
"Proceeding with creation."
|
|
562
|
+
)
|
|
563
|
+
existing_indexes = []
|
|
564
|
+
|
|
565
|
+
for index in existing_indexes:
|
|
566
|
+
if index.get("name") == index_name:
|
|
567
|
+
logger.info(f"Regular index '{index_name}' already exists.")
|
|
568
|
+
return index_name
|
|
569
|
+
|
|
570
|
+
# Extract wait_for_ready from kwargs if present
|
|
571
|
+
wait_for_ready = kwargs.pop("wait_for_ready", True)
|
|
572
|
+
|
|
573
|
+
# Create the index
|
|
574
|
+
try:
|
|
575
|
+
name = await self._collection.create_index(keys, **kwargs)
|
|
576
|
+
logger.info(f"Successfully created regular index '{name}'.")
|
|
577
|
+
except InvalidOperation as e:
|
|
578
|
+
# Client is closed (e.g., during shutdown/teardown)
|
|
579
|
+
logger.debug(
|
|
580
|
+
f"Cannot create index '{index_name}': MongoDB client is closed "
|
|
581
|
+
f"(likely during shutdown)"
|
|
582
|
+
)
|
|
583
|
+
raise MongoDBEngineError(
|
|
584
|
+
f"Cannot create index '{index_name}': MongoDB client is closed",
|
|
585
|
+
context={"index_name": index_name, "operation": "create_index"},
|
|
586
|
+
) from e
|
|
587
|
+
|
|
588
|
+
# Wait for index to be ready (MongoDB indexes are usually immediate, but we verify)
|
|
589
|
+
if wait_for_ready:
|
|
590
|
+
try:
|
|
591
|
+
is_ready = await self._wait_for_regular_index_ready(
|
|
592
|
+
name, timeout=30
|
|
593
|
+
)
|
|
594
|
+
if not is_ready:
|
|
595
|
+
logger.warning(
|
|
596
|
+
f"Regular index '{name}' may not be fully ready yet, "
|
|
597
|
+
f"but creation was initiated successfully."
|
|
598
|
+
)
|
|
599
|
+
except InvalidOperation:
|
|
600
|
+
# Client closed during wait - index was already created, so this is fine
|
|
601
|
+
logger.debug(
|
|
602
|
+
f"Could not verify index ready: MongoDB client is closed. "
|
|
603
|
+
f"Index '{name}' was created."
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
return name
|
|
607
|
+
except OperationFailure as e:
|
|
608
|
+
# Handle index build aborted (e.g., database being dropped during teardown)
|
|
609
|
+
if (
|
|
610
|
+
e.code == 276
|
|
611
|
+
or "IndexBuildAborted" in str(e)
|
|
612
|
+
or "dropDatabase" in str(e)
|
|
613
|
+
):
|
|
614
|
+
logger.debug(
|
|
615
|
+
f"Skipping regular index creation '{index_name}': "
|
|
616
|
+
f"index build aborted (likely during database drop/teardown): {e}"
|
|
617
|
+
)
|
|
618
|
+
# Return the index name anyway since this is a non-critical error during teardown
|
|
619
|
+
return index_name
|
|
620
|
+
logger.exception(f"OperationFailure creating regular index '{index_name}'")
|
|
621
|
+
raise MongoDBEngineError(
|
|
622
|
+
f"Failed to create regular index '{index_name}'",
|
|
623
|
+
context={"index_name": index_name, "operation": "create_index"},
|
|
624
|
+
) from e
|
|
625
|
+
except (ConnectionFailure, ServerSelectionTimeoutError) as e:
|
|
626
|
+
logger.exception(f"Connection error creating regular index '{index_name}'")
|
|
627
|
+
raise MongoDBEngineError(
|
|
628
|
+
f"Connection failed while creating regular index '{index_name}'",
|
|
629
|
+
context={"index_name": index_name, "operation": "create_index"},
|
|
630
|
+
) from e
|
|
631
|
+
except (InvalidOperation, TypeError, ValueError) as e:
|
|
632
|
+
logger.exception(f"Error creating regular index '{index_name}'")
|
|
633
|
+
raise MongoDBEngineError(
|
|
634
|
+
f"Error creating regular index '{index_name}'",
|
|
635
|
+
context={"index_name": index_name, "operation": "create_index"},
|
|
636
|
+
) from e
|
|
637
|
+
|
|
638
|
+
async def create_text_index(
|
|
639
|
+
self,
|
|
640
|
+
fields: List[str],
|
|
641
|
+
weights: Optional[Dict[str, int]] = None,
|
|
642
|
+
name: str = "text_index",
|
|
643
|
+
**kwargs: Any,
|
|
644
|
+
) -> str:
|
|
645
|
+
"""Helper to create a standard text index."""
|
|
646
|
+
keys = [(field, TEXT) for field in fields]
|
|
647
|
+
if weights:
|
|
648
|
+
kwargs["weights"] = weights
|
|
649
|
+
if name:
|
|
650
|
+
kwargs["name"] = name
|
|
651
|
+
return await self.create_index(keys, **kwargs)
|
|
652
|
+
|
|
653
|
+
async def create_geo_index(
|
|
654
|
+
self, field: str, name: Optional[str] = None, **kwargs: Any
|
|
655
|
+
) -> str:
|
|
656
|
+
"""Helper to create a standard 2dsphere index."""
|
|
657
|
+
keys = [(field, GEO2DSPHERE)]
|
|
658
|
+
if name:
|
|
659
|
+
kwargs["name"] = name
|
|
660
|
+
return await self.create_index(keys, **kwargs)
|
|
661
|
+
|
|
662
|
+
async def drop_index(self, name: str):
|
|
663
|
+
"""Drops a standard (non-search) database index by name."""
|
|
664
|
+
try:
|
|
665
|
+
await self._collection.drop_index(name)
|
|
666
|
+
logger.info(f"Successfully dropped regular index '{name}'.")
|
|
667
|
+
except OperationFailure as e:
|
|
668
|
+
# Handle case where index is already gone
|
|
669
|
+
if "index not found" in str(e).lower():
|
|
670
|
+
logger.info(f"Regular index '{name}' does not exist. Nothing to drop.")
|
|
671
|
+
else:
|
|
672
|
+
logger.exception(f"OperationFailure dropping regular index '{name}'")
|
|
673
|
+
raise MongoDBEngineError(
|
|
674
|
+
f"Failed to drop regular index '{name}'",
|
|
675
|
+
context={"index_name": name, "operation": "drop_index"},
|
|
676
|
+
) from e
|
|
677
|
+
except (ConnectionFailure, ServerSelectionTimeoutError) as e:
|
|
678
|
+
logger.exception(f"Connection error dropping regular index '{name}'")
|
|
679
|
+
raise MongoDBEngineError(
|
|
680
|
+
f"Connection failed while dropping regular index '{name}'",
|
|
681
|
+
context={"index_name": name, "operation": "drop_index"},
|
|
682
|
+
) from e
|
|
683
|
+
except InvalidOperation as e:
|
|
684
|
+
logger.debug(
|
|
685
|
+
f"Cannot drop regular index '{name}': MongoDB client is closed"
|
|
686
|
+
)
|
|
687
|
+
raise MongoDBEngineError(
|
|
688
|
+
f"Cannot drop regular index '{name}': MongoDB client is closed",
|
|
689
|
+
context={"index_name": name, "operation": "drop_index"},
|
|
690
|
+
) from e
|
|
691
|
+
|
|
692
|
+
async def list_indexes(self) -> List[Dict[str, Any]]:
|
|
693
|
+
"""Lists all standard (non-search) indexes on the collection."""
|
|
694
|
+
try:
|
|
695
|
+
return await self._collection.list_indexes().to_list(None)
|
|
696
|
+
except (OperationFailure, ConnectionFailure, ServerSelectionTimeoutError):
|
|
697
|
+
logger.exception("Database error listing regular indexes")
|
|
698
|
+
return []
|
|
699
|
+
except InvalidOperation:
|
|
700
|
+
# Client is closed (e.g., during shutdown/teardown)
|
|
701
|
+
logger.debug(
|
|
702
|
+
"Skipping list_indexes: MongoDB client is closed (likely during shutdown)"
|
|
703
|
+
)
|
|
704
|
+
return []
|
|
705
|
+
|
|
706
|
+
async def get_index(self, name: str) -> Optional[Dict[str, Any]]:
|
|
707
|
+
"""Gets a single standard index by name."""
|
|
708
|
+
indexes = await self.list_indexes()
|
|
709
|
+
return next((index for index in indexes if index.get("name") == name), None)
|
|
710
|
+
|
|
711
|
+
async def _wait_for_regular_index_ready(
|
|
712
|
+
self, name: str, timeout: int = 30, poll_interval: float = 0.5
|
|
713
|
+
) -> bool:
|
|
714
|
+
"""
|
|
715
|
+
Wait for a regular MongoDB index to be ready.
|
|
716
|
+
|
|
717
|
+
Regular indexes are usually created synchronously, but we verify they're
|
|
718
|
+
actually available before returning.
|
|
719
|
+
|
|
720
|
+
Args:
|
|
721
|
+
name: Index name to wait for
|
|
722
|
+
timeout: Maximum time to wait in seconds
|
|
723
|
+
poll_interval: Time between checks in seconds
|
|
724
|
+
|
|
725
|
+
Returns:
|
|
726
|
+
True if index is ready, False if timeout
|
|
727
|
+
"""
|
|
728
|
+
import asyncio
|
|
729
|
+
import time
|
|
730
|
+
|
|
731
|
+
start_time = time.time()
|
|
732
|
+
logger.debug(f"Waiting for regular index '{name}' to be ready...")
|
|
733
|
+
|
|
734
|
+
while time.time() - start_time < timeout:
|
|
735
|
+
index = await self.get_index(name)
|
|
736
|
+
if index:
|
|
737
|
+
logger.debug(f"Regular index '{name}' is ready.")
|
|
738
|
+
return True
|
|
739
|
+
await asyncio.sleep(poll_interval)
|
|
740
|
+
|
|
741
|
+
logger.warning(
|
|
742
|
+
f"Timeout waiting for regular index '{name}' to be ready after {timeout}s. "
|
|
743
|
+
f"Index may still be building."
|
|
744
|
+
)
|
|
745
|
+
return False
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
# ##########################################################################
|
|
749
|
+
# AUTOMATIC INDEX MANAGEMENT
|
|
750
|
+
# ##########################################################################
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
class AutoIndexManager:
|
|
754
|
+
"""
|
|
755
|
+
Magical index manager that automatically creates indexes based on query patterns.
|
|
756
|
+
|
|
757
|
+
This class analyzes query filters and automatically creates appropriate indexes
|
|
758
|
+
for frequently used fields, making it easy for apps to use collections
|
|
759
|
+
without manually defining indexes.
|
|
760
|
+
|
|
761
|
+
Features:
|
|
762
|
+
- Automatically detects query patterns (equality, range, sorting)
|
|
763
|
+
- Creates indexes on-demand based on usage
|
|
764
|
+
- Uses intelligent heuristics to avoid over-indexing
|
|
765
|
+
- Thread-safe with async locks
|
|
766
|
+
"""
|
|
767
|
+
|
|
768
|
+
__slots__ = (
|
|
769
|
+
"_collection",
|
|
770
|
+
"_index_manager",
|
|
771
|
+
"_creation_cache",
|
|
772
|
+
"_lock",
|
|
773
|
+
"_query_counts",
|
|
774
|
+
"_pending_tasks",
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
def __init__(
|
|
778
|
+
self, collection: AsyncIOMotorCollection, index_manager: AsyncAtlasIndexManager
|
|
779
|
+
):
|
|
780
|
+
self._collection = collection
|
|
781
|
+
self._index_manager = index_manager
|
|
782
|
+
# Cache of index creation decisions (index_name -> bool)
|
|
783
|
+
self._creation_cache: Dict[str, bool] = {}
|
|
784
|
+
# Async lock to prevent race conditions during index creation
|
|
785
|
+
self._lock = asyncio.Lock()
|
|
786
|
+
# Track query patterns to determine which indexes to create
|
|
787
|
+
self._query_counts: Dict[str, int] = {}
|
|
788
|
+
# Track in-flight index creation tasks to prevent duplicates
|
|
789
|
+
self._pending_tasks: Dict[str, asyncio.Task] = {}
|
|
790
|
+
|
|
791
|
+
def _extract_index_fields_from_filter(
|
|
792
|
+
self, filter: Optional[Mapping[str, Any]]
|
|
793
|
+
) -> List[Tuple[str, int]]:
|
|
794
|
+
"""
|
|
795
|
+
Extracts potential index fields from a MongoDB query filter.
|
|
796
|
+
|
|
797
|
+
Args:
|
|
798
|
+
filter: MongoDB query filter dictionary
|
|
799
|
+
|
|
800
|
+
Returns:
|
|
801
|
+
List of (field_name, direction) tuples where:
|
|
802
|
+
- direction is 1 for ASCENDING, -1 for DESCENDING
|
|
803
|
+
- Only includes fields that would benefit from indexing
|
|
804
|
+
"""
|
|
805
|
+
if not filter:
|
|
806
|
+
return []
|
|
807
|
+
|
|
808
|
+
index_fields: List[Tuple[str, int]] = []
|
|
809
|
+
|
|
810
|
+
def analyze_value(value: Any, field_name: str) -> None:
|
|
811
|
+
"""Recursively analyze filter values to extract index candidates."""
|
|
812
|
+
if isinstance(value, dict):
|
|
813
|
+
# Handle operators like $gt, $gte, $lt, $lte, $ne, $in, $exists
|
|
814
|
+
if any(
|
|
815
|
+
op in value
|
|
816
|
+
for op in ["$gt", "$gte", "$lt", "$lte", "$ne", "$in", "$exists"]
|
|
817
|
+
):
|
|
818
|
+
# These operators benefit from indexes
|
|
819
|
+
index_fields.append((field_name, ASCENDING))
|
|
820
|
+
# Handle $and and $or - recursively analyze
|
|
821
|
+
if "$and" in value:
|
|
822
|
+
for sub_filter in value["$and"]:
|
|
823
|
+
if isinstance(sub_filter, dict):
|
|
824
|
+
for k, v in sub_filter.items():
|
|
825
|
+
analyze_value(v, k)
|
|
826
|
+
if "$or" in value:
|
|
827
|
+
# For $or, we can't easily determine index fields, skip for now
|
|
828
|
+
pass
|
|
829
|
+
elif value is not None:
|
|
830
|
+
# Direct equality match - very common and benefits from index
|
|
831
|
+
index_fields.append((field_name, ASCENDING))
|
|
832
|
+
|
|
833
|
+
# Analyze top-level fields
|
|
834
|
+
for key, value in filter.items():
|
|
835
|
+
if not key.startswith("$"): # Skip operators at top level
|
|
836
|
+
analyze_value(value, key)
|
|
837
|
+
|
|
838
|
+
return list(set(index_fields)) # Remove duplicates
|
|
839
|
+
|
|
840
|
+
def _extract_sort_fields(
|
|
841
|
+
self, sort: Optional[Union[List[Tuple[str, int]], Dict[str, int]]]
|
|
842
|
+
) -> List[Tuple[str, int]]:
|
|
843
|
+
"""
|
|
844
|
+
Extracts index fields from sort specification.
|
|
845
|
+
|
|
846
|
+
Returns a list of (field_name, direction) tuples.
|
|
847
|
+
"""
|
|
848
|
+
if not sort:
|
|
849
|
+
return []
|
|
850
|
+
|
|
851
|
+
if isinstance(sort, dict):
|
|
852
|
+
return [(field, direction) for field, direction in sort.items()]
|
|
853
|
+
elif isinstance(sort, list):
|
|
854
|
+
return sort
|
|
855
|
+
else:
|
|
856
|
+
return []
|
|
857
|
+
|
|
858
|
+
def _generate_index_name(self, fields: List[Tuple[str, int]]) -> str:
|
|
859
|
+
"""Generate a human-readable index name from field list."""
|
|
860
|
+
if not fields:
|
|
861
|
+
return "auto_idx_empty"
|
|
862
|
+
|
|
863
|
+
parts = []
|
|
864
|
+
for field, direction in fields:
|
|
865
|
+
dir_str = "asc" if direction == ASCENDING else "desc"
|
|
866
|
+
parts.append(f"{field}_{dir_str}")
|
|
867
|
+
|
|
868
|
+
return f"auto_{'_'.join(parts)}"
|
|
869
|
+
|
|
870
|
+
async def _create_index_safely(
|
|
871
|
+
self, index_name: str, all_fields: List[Tuple[str, int]]
|
|
872
|
+
) -> None:
|
|
873
|
+
"""
|
|
874
|
+
Safely create an index, handling errors gracefully.
|
|
875
|
+
|
|
876
|
+
Args:
|
|
877
|
+
index_name: Name of the index to create
|
|
878
|
+
all_fields: List of (field, direction) tuples for the index
|
|
879
|
+
"""
|
|
880
|
+
try:
|
|
881
|
+
# Check if index already exists
|
|
882
|
+
existing_indexes = await self._index_manager.list_indexes()
|
|
883
|
+
for idx in existing_indexes:
|
|
884
|
+
if idx.get("name") == index_name:
|
|
885
|
+
async with self._lock:
|
|
886
|
+
self._creation_cache[index_name] = True
|
|
887
|
+
return # Index already exists
|
|
888
|
+
|
|
889
|
+
# Create the index
|
|
890
|
+
keys = all_fields
|
|
891
|
+
await self._index_manager.create_index(
|
|
892
|
+
keys, name=index_name, background=True
|
|
893
|
+
)
|
|
894
|
+
async with self._lock:
|
|
895
|
+
self._creation_cache[index_name] = True
|
|
896
|
+
logger.info(
|
|
897
|
+
f"✨ Auto-created index '{index_name}' on "
|
|
898
|
+
f"{self._collection.name} for fields: "
|
|
899
|
+
f"{[f[0] for f in all_fields]}"
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
except (
|
|
903
|
+
OperationFailure,
|
|
904
|
+
ConnectionFailure,
|
|
905
|
+
ServerSelectionTimeoutError,
|
|
906
|
+
InvalidOperation,
|
|
907
|
+
) as e:
|
|
908
|
+
# Don't fail the query if index creation fails
|
|
909
|
+
logger.warning(f"Failed to auto-create index '{index_name}': {e}")
|
|
910
|
+
async with self._lock:
|
|
911
|
+
self._creation_cache[index_name] = False
|
|
912
|
+
finally:
|
|
913
|
+
# Clean up pending task
|
|
914
|
+
async with self._lock:
|
|
915
|
+
self._pending_tasks.pop(index_name, None)
|
|
916
|
+
|
|
917
|
+
async def ensure_index_for_query(
|
|
918
|
+
self,
|
|
919
|
+
filter: Optional[Mapping[str, Any]] = None,
|
|
920
|
+
sort: Optional[Union[List[Tuple[str, int]], Dict[str, int]]] = None,
|
|
921
|
+
hint_threshold: int = AUTO_INDEX_HINT_THRESHOLD,
|
|
922
|
+
) -> None:
|
|
923
|
+
"""
|
|
924
|
+
Automatically ensure appropriate indexes exist for a given query.
|
|
925
|
+
|
|
926
|
+
Args:
|
|
927
|
+
filter: The query filter to analyze
|
|
928
|
+
sort: The sort specification to analyze
|
|
929
|
+
hint_threshold: Number of times a query pattern must be seen before creating index
|
|
930
|
+
|
|
931
|
+
This method:
|
|
932
|
+
1. Extracts potential index fields from filter and sort
|
|
933
|
+
2. Combines them into a composite index if needed
|
|
934
|
+
3. Creates the index if it doesn't exist and usage threshold is met
|
|
935
|
+
4. Uses async lock to prevent race conditions
|
|
936
|
+
5. Tracks pending tasks to prevent duplicate index creation
|
|
937
|
+
"""
|
|
938
|
+
# Extract fields from filter and sort
|
|
939
|
+
filter_fields = self._extract_index_fields_from_filter(filter)
|
|
940
|
+
sort_fields = self._extract_sort_fields(sort)
|
|
941
|
+
|
|
942
|
+
# Combine fields intelligently: filter fields first, then sort fields
|
|
943
|
+
all_fields = []
|
|
944
|
+
filter_field_names = {f[0] for f in filter_fields}
|
|
945
|
+
|
|
946
|
+
# Add filter fields first
|
|
947
|
+
for field, direction in filter_fields:
|
|
948
|
+
all_fields.append((field, direction))
|
|
949
|
+
|
|
950
|
+
# Add sort fields that aren't already in filter
|
|
951
|
+
for field, direction in sort_fields:
|
|
952
|
+
if field not in filter_field_names:
|
|
953
|
+
all_fields.append((field, direction))
|
|
954
|
+
|
|
955
|
+
if not all_fields:
|
|
956
|
+
return # No index needed
|
|
957
|
+
|
|
958
|
+
# Limit to MAX_INDEX_FIELDS (MongoDB compound index best practice)
|
|
959
|
+
all_fields = all_fields[:MAX_INDEX_FIELDS]
|
|
960
|
+
|
|
961
|
+
# Generate index name
|
|
962
|
+
index_name = self._generate_index_name(all_fields)
|
|
963
|
+
|
|
964
|
+
# Track query pattern usage
|
|
965
|
+
pattern_key = index_name
|
|
966
|
+
self._query_counts[pattern_key] = self._query_counts.get(pattern_key, 0) + 1
|
|
967
|
+
|
|
968
|
+
# Only create index if usage threshold is met
|
|
969
|
+
if self._query_counts[pattern_key] < hint_threshold:
|
|
970
|
+
return
|
|
971
|
+
|
|
972
|
+
# Check cache and pending tasks to avoid redundant creation attempts
|
|
973
|
+
async with self._lock:
|
|
974
|
+
# Only skip if index was successfully created (cache value is True)
|
|
975
|
+
# If cache value is False (failed attempt), allow retry
|
|
976
|
+
if self._creation_cache.get(index_name) is True:
|
|
977
|
+
return # Already created successfully
|
|
978
|
+
|
|
979
|
+
# Check if task is already in progress
|
|
980
|
+
if index_name in self._pending_tasks:
|
|
981
|
+
task = self._pending_tasks[index_name]
|
|
982
|
+
if not task.done():
|
|
983
|
+
return # Index creation already in progress
|
|
984
|
+
# Task is done, clean it up to allow retry if needed
|
|
985
|
+
self._pending_tasks.pop(index_name, None)
|
|
986
|
+
|
|
987
|
+
# Create task and track it
|
|
988
|
+
# Cleanup happens in _create_index_safely's finally block
|
|
989
|
+
task = asyncio.create_task(
|
|
990
|
+
self._create_index_safely(index_name, all_fields)
|
|
991
|
+
)
|
|
992
|
+
self._pending_tasks[index_name] = task
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
# ##########################################################################
|
|
996
|
+
# SCOPED WRAPPER CLASSES
|
|
997
|
+
# ##########################################################################
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
class ScopedCollectionWrapper:
|
|
1001
|
+
"""
|
|
1002
|
+
Wraps an `AsyncIOMotorCollection` to enforce app data scoping.
|
|
1003
|
+
|
|
1004
|
+
This class intercepts all data access methods (find, insert, update, etc.)
|
|
1005
|
+
to automatically inject `app_id` filters and data.
|
|
1006
|
+
|
|
1007
|
+
- Read operations (`find`, `find_one`, `count_documents`, `aggregate`) are
|
|
1008
|
+
filtered to only include documents matching the `read_scopes`.
|
|
1009
|
+
- Write operations (`insert_one`, `insert_many`) automatically add the
|
|
1010
|
+
`write_scope` as the document's `app_id`.
|
|
1011
|
+
|
|
1012
|
+
Administrative methods (e.g., `drop_index`) are not proxied directly
|
|
1013
|
+
but are available via the `.index_manager` property.
|
|
1014
|
+
|
|
1015
|
+
Magical Auto-Indexing:
|
|
1016
|
+
- Automatically creates indexes based on query patterns
|
|
1017
|
+
- Analyzes filter and sort specifications to determine needed indexes
|
|
1018
|
+
- Creates indexes in the background without blocking queries
|
|
1019
|
+
- Enables apps to use collections without manual index configuration
|
|
1020
|
+
- Can be disabled by setting `auto_index=False` in constructor
|
|
1021
|
+
"""
|
|
1022
|
+
|
|
1023
|
+
# Use __slots__ for memory and speed optimization
|
|
1024
|
+
__slots__ = (
|
|
1025
|
+
"_collection",
|
|
1026
|
+
"_read_scopes",
|
|
1027
|
+
"_write_scope",
|
|
1028
|
+
"_index_manager",
|
|
1029
|
+
"_auto_index_manager",
|
|
1030
|
+
"_auto_index_enabled",
|
|
1031
|
+
)
|
|
1032
|
+
|
|
1033
|
+
def __init__(
|
|
1034
|
+
self,
|
|
1035
|
+
real_collection: AsyncIOMotorCollection,
|
|
1036
|
+
read_scopes: List[str],
|
|
1037
|
+
write_scope: str,
|
|
1038
|
+
auto_index: bool = True,
|
|
1039
|
+
):
|
|
1040
|
+
self._collection = real_collection
|
|
1041
|
+
self._read_scopes = read_scopes
|
|
1042
|
+
self._write_scope = write_scope
|
|
1043
|
+
self._auto_index_enabled = auto_index
|
|
1044
|
+
# Lazily instantiated and cached
|
|
1045
|
+
self._index_manager: Optional[AsyncAtlasIndexManager] = None
|
|
1046
|
+
self._auto_index_manager: Optional[AutoIndexManager] = None
|
|
1047
|
+
|
|
1048
|
+
@property
|
|
1049
|
+
def index_manager(self) -> AsyncAtlasIndexManager:
|
|
1050
|
+
"""
|
|
1051
|
+
Gets the AsyncAtlasIndexManager for this collection.
|
|
1052
|
+
|
|
1053
|
+
It is lazily-instantiated and cached on first access.
|
|
1054
|
+
|
|
1055
|
+
Note: Index operations are administrative and are NOT
|
|
1056
|
+
scoped by 'app_id'. They apply to the
|
|
1057
|
+
entire underlying collection.
|
|
1058
|
+
"""
|
|
1059
|
+
if self._index_manager is None:
|
|
1060
|
+
# Create and cache it.
|
|
1061
|
+
# Pass the *real* collection, not 'self', as indexes
|
|
1062
|
+
# are not scoped by app_id.
|
|
1063
|
+
self._index_manager = AsyncAtlasIndexManager(self._collection)
|
|
1064
|
+
return self._index_manager
|
|
1065
|
+
|
|
1066
|
+
@property
|
|
1067
|
+
def auto_index_manager(self) -> Optional[AutoIndexManager]:
|
|
1068
|
+
"""
|
|
1069
|
+
Gets the AutoIndexManager for magical automatic index creation.
|
|
1070
|
+
|
|
1071
|
+
Returns None if auto-indexing is disabled.
|
|
1072
|
+
"""
|
|
1073
|
+
if not self._auto_index_enabled:
|
|
1074
|
+
return None
|
|
1075
|
+
|
|
1076
|
+
if self._auto_index_manager is None:
|
|
1077
|
+
# Lazily instantiate auto-index manager
|
|
1078
|
+
self._auto_index_manager = AutoIndexManager(
|
|
1079
|
+
self._collection,
|
|
1080
|
+
self.index_manager, # This will create index_manager if needed
|
|
1081
|
+
)
|
|
1082
|
+
return self._auto_index_manager
|
|
1083
|
+
|
|
1084
|
+
def _inject_read_filter(
|
|
1085
|
+
self, filter: Optional[Mapping[str, Any]] = None
|
|
1086
|
+
) -> Dict[str, Any]:
|
|
1087
|
+
"""
|
|
1088
|
+
Combines the user's filter with our mandatory scope filter.
|
|
1089
|
+
|
|
1090
|
+
Optimization: If the user filter is empty, just return the scope filter.
|
|
1091
|
+
Otherwise, combine them robustly with $and.
|
|
1092
|
+
"""
|
|
1093
|
+
scope_filter = {"app_id": {"$in": self._read_scopes}}
|
|
1094
|
+
|
|
1095
|
+
# If filter is None or {}, just return the scope filter
|
|
1096
|
+
if not filter:
|
|
1097
|
+
return scope_filter
|
|
1098
|
+
|
|
1099
|
+
# If filter exists, combine them robustly with $and
|
|
1100
|
+
return {"$and": [filter, scope_filter]}
|
|
1101
|
+
|
|
1102
|
+
async def insert_one(
|
|
1103
|
+
self, document: Mapping[str, Any], *args, **kwargs
|
|
1104
|
+
) -> InsertOneResult:
|
|
1105
|
+
"""
|
|
1106
|
+
Injects the app_id before writing.
|
|
1107
|
+
|
|
1108
|
+
Safety: Creates a copy of the document to avoid mutating the caller's data.
|
|
1109
|
+
"""
|
|
1110
|
+
import time
|
|
1111
|
+
|
|
1112
|
+
start_time = time.time()
|
|
1113
|
+
collection_name = self._collection.name
|
|
1114
|
+
|
|
1115
|
+
try:
|
|
1116
|
+
# Use dictionary spread to create a non-mutating copy
|
|
1117
|
+
doc_to_insert = {**document, "app_id": self._write_scope}
|
|
1118
|
+
result = await self._collection.insert_one(doc_to_insert, *args, **kwargs)
|
|
1119
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
1120
|
+
record_operation(
|
|
1121
|
+
"database.insert_one",
|
|
1122
|
+
duration_ms,
|
|
1123
|
+
success=True,
|
|
1124
|
+
collection=collection_name,
|
|
1125
|
+
app_slug=self._write_scope,
|
|
1126
|
+
)
|
|
1127
|
+
return result
|
|
1128
|
+
except (OperationFailure, AutoReconnect) as e:
|
|
1129
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
1130
|
+
record_operation(
|
|
1131
|
+
"database.insert_one",
|
|
1132
|
+
duration_ms,
|
|
1133
|
+
success=False,
|
|
1134
|
+
collection=collection_name,
|
|
1135
|
+
app_slug=self._write_scope,
|
|
1136
|
+
)
|
|
1137
|
+
logger.exception("Database operation failed in insert_one")
|
|
1138
|
+
raise MongoDBEngineError(
|
|
1139
|
+
"Failed to insert document",
|
|
1140
|
+
context={"operation": "insert_one", "collection": collection_name},
|
|
1141
|
+
) from e
|
|
1142
|
+
except (InvalidOperation, TypeError, ValueError) as e:
|
|
1143
|
+
# Programming errors or client closed
|
|
1144
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
1145
|
+
record_operation(
|
|
1146
|
+
"database.insert_one",
|
|
1147
|
+
duration_ms,
|
|
1148
|
+
success=False,
|
|
1149
|
+
collection=collection_name,
|
|
1150
|
+
app_slug=self._write_scope,
|
|
1151
|
+
)
|
|
1152
|
+
logger.exception("Error in insert_one")
|
|
1153
|
+
raise MongoDBEngineError(
|
|
1154
|
+
"Error inserting document",
|
|
1155
|
+
context={"operation": "insert_one", "collection": collection_name},
|
|
1156
|
+
) from e
|
|
1157
|
+
|
|
1158
|
+
async def insert_many(
|
|
1159
|
+
self, documents: List[Mapping[str, Any]], *args, **kwargs
|
|
1160
|
+
) -> InsertManyResult:
|
|
1161
|
+
"""
|
|
1162
|
+
Injects the app_id into all documents before writing.
|
|
1163
|
+
|
|
1164
|
+
Safety: Uses a list comprehension to create copies of all documents,
|
|
1165
|
+
avoiding in-place mutation of the original list.
|
|
1166
|
+
"""
|
|
1167
|
+
docs_to_insert = [{**doc, "app_id": self._write_scope} for doc in documents]
|
|
1168
|
+
return await self._collection.insert_many(docs_to_insert, *args, **kwargs)
|
|
1169
|
+
|
|
1170
|
+
async def find_one(
|
|
1171
|
+
self, filter: Optional[Mapping[str, Any]] = None, *args, **kwargs
|
|
1172
|
+
) -> Optional[Dict[str, Any]]:
|
|
1173
|
+
"""
|
|
1174
|
+
Applies the read scope to the filter.
|
|
1175
|
+
Automatically ensures appropriate indexes exist for the query.
|
|
1176
|
+
"""
|
|
1177
|
+
import time
|
|
1178
|
+
|
|
1179
|
+
start_time = time.time()
|
|
1180
|
+
collection_name = self._collection.name
|
|
1181
|
+
|
|
1182
|
+
try:
|
|
1183
|
+
# Magical auto-indexing: ensure indexes exist before querying
|
|
1184
|
+
# Note: We analyze the user's filter, not the scoped filter, since
|
|
1185
|
+
# app_id index is always ensured separately
|
|
1186
|
+
if self.auto_index_manager:
|
|
1187
|
+
sort = kwargs.get("sort")
|
|
1188
|
+
await self.auto_index_manager.ensure_index_for_query(
|
|
1189
|
+
filter=filter, sort=sort
|
|
1190
|
+
)
|
|
1191
|
+
|
|
1192
|
+
scoped_filter = self._inject_read_filter(filter)
|
|
1193
|
+
result = await self._collection.find_one(scoped_filter, *args, **kwargs)
|
|
1194
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
1195
|
+
record_operation(
|
|
1196
|
+
"database.find_one",
|
|
1197
|
+
duration_ms,
|
|
1198
|
+
success=True,
|
|
1199
|
+
collection=collection_name,
|
|
1200
|
+
app_slug=self._write_scope,
|
|
1201
|
+
)
|
|
1202
|
+
return result
|
|
1203
|
+
except Exception:
|
|
1204
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
1205
|
+
record_operation(
|
|
1206
|
+
"database.find_one",
|
|
1207
|
+
duration_ms,
|
|
1208
|
+
success=False,
|
|
1209
|
+
collection=collection_name,
|
|
1210
|
+
app_slug=self._write_scope,
|
|
1211
|
+
)
|
|
1212
|
+
raise
|
|
1213
|
+
|
|
1214
|
+
def find(
|
|
1215
|
+
self, filter: Optional[Mapping[str, Any]] = None, *args, **kwargs
|
|
1216
|
+
) -> AsyncIOMotorCursor:
|
|
1217
|
+
"""
|
|
1218
|
+
Applies the read scope to the filter.
|
|
1219
|
+
Returns an async cursor, just like motor.
|
|
1220
|
+
Automatically ensures appropriate indexes exist for the query.
|
|
1221
|
+
"""
|
|
1222
|
+
# Magical auto-indexing: ensure indexes exist before querying
|
|
1223
|
+
# Note: This is fire-and-forget, doesn't block cursor creation
|
|
1224
|
+
if self.auto_index_manager:
|
|
1225
|
+
sort = kwargs.get("sort")
|
|
1226
|
+
|
|
1227
|
+
# Create a task to ensure index (fire and forget, managed to prevent accumulation)
|
|
1228
|
+
async def _safe_index_task():
|
|
1229
|
+
try:
|
|
1230
|
+
await self.auto_index_manager.ensure_index_for_query(
|
|
1231
|
+
filter=filter, sort=sort
|
|
1232
|
+
)
|
|
1233
|
+
except (
|
|
1234
|
+
OperationFailure,
|
|
1235
|
+
ConnectionFailure,
|
|
1236
|
+
ServerSelectionTimeoutError,
|
|
1237
|
+
InvalidOperation,
|
|
1238
|
+
) as e:
|
|
1239
|
+
logger.debug(
|
|
1240
|
+
f"Auto-index creation failed for query (non-critical): {e}"
|
|
1241
|
+
)
|
|
1242
|
+
|
|
1243
|
+
_create_managed_task(_safe_index_task(), task_name="auto_index_check")
|
|
1244
|
+
|
|
1245
|
+
scoped_filter = self._inject_read_filter(filter)
|
|
1246
|
+
return self._collection.find(scoped_filter, *args, **kwargs)
|
|
1247
|
+
|
|
1248
|
+
async def update_one(
|
|
1249
|
+
self, filter: Mapping[str, Any], update: Mapping[str, Any], *args, **kwargs
|
|
1250
|
+
) -> UpdateResult:
|
|
1251
|
+
"""
|
|
1252
|
+
Applies the read scope to the filter.
|
|
1253
|
+
Note: This only scopes the *filter*, not the update operation.
|
|
1254
|
+
"""
|
|
1255
|
+
scoped_filter = self._inject_read_filter(filter)
|
|
1256
|
+
return await self._collection.update_one(scoped_filter, update, *args, **kwargs)
|
|
1257
|
+
|
|
1258
|
+
async def update_many(
|
|
1259
|
+
self, filter: Mapping[str, Any], update: Mapping[str, Any], *args, **kwargs
|
|
1260
|
+
) -> UpdateResult:
|
|
1261
|
+
"""
|
|
1262
|
+
Applies the read scope to the filter.
|
|
1263
|
+
Note: This only scopes the *filter*, not the update operation.
|
|
1264
|
+
"""
|
|
1265
|
+
scoped_filter = self._inject_read_filter(filter)
|
|
1266
|
+
return await self._collection.update_many(
|
|
1267
|
+
scoped_filter, update, *args, **kwargs
|
|
1268
|
+
)
|
|
1269
|
+
|
|
1270
|
+
async def delete_one(
|
|
1271
|
+
self, filter: Mapping[str, Any], *args, **kwargs
|
|
1272
|
+
) -> DeleteResult:
|
|
1273
|
+
"""Applies the read scope to the filter."""
|
|
1274
|
+
scoped_filter = self._inject_read_filter(filter)
|
|
1275
|
+
return await self._collection.delete_one(scoped_filter, *args, **kwargs)
|
|
1276
|
+
|
|
1277
|
+
async def delete_many(
|
|
1278
|
+
self, filter: Mapping[str, Any], *args, **kwargs
|
|
1279
|
+
) -> DeleteResult:
|
|
1280
|
+
"""Applies the read scope to the filter."""
|
|
1281
|
+
scoped_filter = self._inject_read_filter(filter)
|
|
1282
|
+
return await self._collection.delete_many(scoped_filter, *args, **kwargs)
|
|
1283
|
+
|
|
1284
|
+
async def count_documents(
|
|
1285
|
+
self, filter: Optional[Mapping[str, Any]] = None, *args, **kwargs
|
|
1286
|
+
) -> int:
|
|
1287
|
+
"""
|
|
1288
|
+
Applies the read scope to the filter for counting.
|
|
1289
|
+
Automatically ensures appropriate indexes exist for the query.
|
|
1290
|
+
"""
|
|
1291
|
+
# Magical auto-indexing: ensure indexes exist before querying
|
|
1292
|
+
if self.auto_index_manager:
|
|
1293
|
+
await self.auto_index_manager.ensure_index_for_query(filter=filter)
|
|
1294
|
+
|
|
1295
|
+
scoped_filter = self._inject_read_filter(filter)
|
|
1296
|
+
return await self._collection.count_documents(scoped_filter, *args, **kwargs)
|
|
1297
|
+
|
|
1298
|
+
def aggregate(
|
|
1299
|
+
self, pipeline: List[Dict[str, Any]], *args, **kwargs
|
|
1300
|
+
) -> AsyncIOMotorCursor:
|
|
1301
|
+
"""
|
|
1302
|
+
Injects a scope filter into the pipeline. For normal pipelines, we prepend
|
|
1303
|
+
a $match stage. However, if the first stage is $vectorSearch, we embed
|
|
1304
|
+
the read_scope filter into its 'filter' property, because $vectorSearch must
|
|
1305
|
+
remain the very first stage in Atlas.
|
|
1306
|
+
"""
|
|
1307
|
+
if not pipeline:
|
|
1308
|
+
# No stages given, just prepend our $match
|
|
1309
|
+
scope_match_stage = {"$match": {"app_id": {"$in": self._read_scopes}}}
|
|
1310
|
+
pipeline = [scope_match_stage]
|
|
1311
|
+
return self._collection.aggregate(pipeline, *args, **kwargs)
|
|
1312
|
+
|
|
1313
|
+
# Identify the first stage
|
|
1314
|
+
first_stage = pipeline[0]
|
|
1315
|
+
first_stage_op = next(
|
|
1316
|
+
iter(first_stage.keys()), None
|
|
1317
|
+
) # e.g. "$match", "$vectorSearch", etc.
|
|
1318
|
+
|
|
1319
|
+
if first_stage_op == "$vectorSearch":
|
|
1320
|
+
# We must not prepend $match or it breaks the pipeline.
|
|
1321
|
+
# Instead, embed our scope in the 'filter' of $vectorSearch.
|
|
1322
|
+
vs_stage = first_stage["$vectorSearch"]
|
|
1323
|
+
existing_filter = vs_stage.get("filter", {})
|
|
1324
|
+
scope_filter = {"app_id": {"$in": self._read_scopes}}
|
|
1325
|
+
|
|
1326
|
+
if existing_filter:
|
|
1327
|
+
# Combine the user's existing filter with our scope filter via $and
|
|
1328
|
+
new_filter = {"$and": [existing_filter, scope_filter]}
|
|
1329
|
+
else:
|
|
1330
|
+
new_filter = scope_filter
|
|
1331
|
+
|
|
1332
|
+
vs_stage["filter"] = new_filter
|
|
1333
|
+
# Return the pipeline as-is, so that $vectorSearch remains the first stage
|
|
1334
|
+
return self._collection.aggregate(pipeline, *args, **kwargs)
|
|
1335
|
+
else:
|
|
1336
|
+
# Normal case: pipeline doesn't start with $vectorSearch,
|
|
1337
|
+
# so we can safely prepend a $match stage for scoping.
|
|
1338
|
+
scope_match_stage = {"$match": {"app_id": {"$in": self._read_scopes}}}
|
|
1339
|
+
scoped_pipeline = [scope_match_stage] + pipeline
|
|
1340
|
+
return self._collection.aggregate(scoped_pipeline, *args, **kwargs)
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
class ScopedMongoWrapper:
|
|
1344
|
+
"""
|
|
1345
|
+
Wraps an `AsyncIOMotorDatabase` to provide scoped collection access.
|
|
1346
|
+
|
|
1347
|
+
When a collection attribute is accessed (e.g., `db.my_collection`),
|
|
1348
|
+
this class returns a `ScopedCollectionWrapper` instance for that
|
|
1349
|
+
collection, configured with the appropriate read/write scopes.
|
|
1350
|
+
|
|
1351
|
+
It caches these `ScopedCollectionWrapper` instances to avoid
|
|
1352
|
+
re-creating them on every access within the same request context.
|
|
1353
|
+
|
|
1354
|
+
Features:
|
|
1355
|
+
- Automatic index management: indexes are created automatically based
|
|
1356
|
+
on query patterns, making it easy to use collections without manual
|
|
1357
|
+
index configuration. This "magical" feature is enabled by default.
|
|
1358
|
+
"""
|
|
1359
|
+
|
|
1360
|
+
# Class-level cache for collections that have app_id index checked
|
|
1361
|
+
# Key: collection name, Value: boolean (True if index exists, False if check is pending)
|
|
1362
|
+
_app_id_index_cache: ClassVar[Dict[str, bool]] = {}
|
|
1363
|
+
# Lock to prevent race conditions when multiple requests try to create the same index
|
|
1364
|
+
_app_id_index_lock: ClassVar[asyncio.Lock] = asyncio.Lock()
|
|
1365
|
+
|
|
1366
|
+
__slots__ = ("_db", "_read_scopes", "_write_scope", "_wrapper_cache", "_auto_index")
|
|
1367
|
+
|
|
1368
|
+
def __init__(
|
|
1369
|
+
self,
|
|
1370
|
+
real_db: AsyncIOMotorDatabase,
|
|
1371
|
+
read_scopes: List[str],
|
|
1372
|
+
write_scope: str,
|
|
1373
|
+
auto_index: bool = True,
|
|
1374
|
+
):
|
|
1375
|
+
self._db = real_db
|
|
1376
|
+
self._read_scopes = read_scopes
|
|
1377
|
+
self._write_scope = write_scope
|
|
1378
|
+
self._auto_index = auto_index
|
|
1379
|
+
|
|
1380
|
+
# Cache for created collection wrappers.
|
|
1381
|
+
self._wrapper_cache: Dict[str, ScopedCollectionWrapper] = {}
|
|
1382
|
+
|
|
1383
|
+
@property
|
|
1384
|
+
def database(self) -> AsyncIOMotorDatabase:
|
|
1385
|
+
"""
|
|
1386
|
+
Access the underlying AsyncIOMotorDatabase (unscoped).
|
|
1387
|
+
|
|
1388
|
+
This is useful for advanced operations that need direct access to the
|
|
1389
|
+
real database without scoping, such as index management.
|
|
1390
|
+
|
|
1391
|
+
Returns:
|
|
1392
|
+
The underlying AsyncIOMotorDatabase instance
|
|
1393
|
+
|
|
1394
|
+
Example:
|
|
1395
|
+
# Access underlying database for index management
|
|
1396
|
+
real_db = db.raw.database
|
|
1397
|
+
collection = real_db["my_collection"]
|
|
1398
|
+
index_manager = AsyncAtlasIndexManager(collection)
|
|
1399
|
+
"""
|
|
1400
|
+
return self._db
|
|
1401
|
+
|
|
1402
|
+
def __getattr__(self, name: str) -> ScopedCollectionWrapper:
|
|
1403
|
+
"""
|
|
1404
|
+
Proxies attribute access to the underlying database.
|
|
1405
|
+
|
|
1406
|
+
If `name` is a collection, returns a `ScopedCollectionWrapper`.
|
|
1407
|
+
"""
|
|
1408
|
+
|
|
1409
|
+
# Prevent proxying private/special attributes
|
|
1410
|
+
if name.startswith("_"):
|
|
1411
|
+
raise AttributeError(
|
|
1412
|
+
f"'{type(self).__name__}' object has no attribute '{name}'. "
|
|
1413
|
+
"Access to private attributes is blocked."
|
|
1414
|
+
)
|
|
1415
|
+
|
|
1416
|
+
# Construct the prefixed collection name, e.g., "data_imaging_workouts"
|
|
1417
|
+
# `self._write_scope` holds the slug (e.g., "data_imaging")
|
|
1418
|
+
# `name` holds the base name (e.g., "workouts")
|
|
1419
|
+
prefixed_name = f"{self._write_scope}_{name}"
|
|
1420
|
+
|
|
1421
|
+
# Check cache first using the *prefixed_name*
|
|
1422
|
+
if prefixed_name in self._wrapper_cache:
|
|
1423
|
+
return self._wrapper_cache[prefixed_name]
|
|
1424
|
+
|
|
1425
|
+
# Get the real collection from the motor db object using the *prefixed_name*
|
|
1426
|
+
real_collection = getattr(self._db, prefixed_name)
|
|
1427
|
+
# --- END FIX ---
|
|
1428
|
+
|
|
1429
|
+
# Ensure we are actually wrapping a collection object
|
|
1430
|
+
if not isinstance(real_collection, AsyncIOMotorCollection):
|
|
1431
|
+
raise AttributeError(
|
|
1432
|
+
f"'{name}' (prefixed as '{prefixed_name}') is not an AsyncIOMotorCollection. "
|
|
1433
|
+
f"ScopedMongoWrapper can only proxy collections (found {type(real_collection)})."
|
|
1434
|
+
)
|
|
1435
|
+
|
|
1436
|
+
# Create the new wrapper with auto-indexing enabled by default
|
|
1437
|
+
wrapper = ScopedCollectionWrapper(
|
|
1438
|
+
real_collection=real_collection,
|
|
1439
|
+
read_scopes=self._read_scopes,
|
|
1440
|
+
write_scope=self._write_scope,
|
|
1441
|
+
auto_index=self._auto_index,
|
|
1442
|
+
)
|
|
1443
|
+
|
|
1444
|
+
# Magically ensure app_id index exists (it's always used in queries)
|
|
1445
|
+
# This is fire-and-forget, runs in background
|
|
1446
|
+
# Use class-level cache and lock to avoid race conditions
|
|
1447
|
+
if self._auto_index:
|
|
1448
|
+
collection_name = real_collection.name
|
|
1449
|
+
|
|
1450
|
+
# Thread-safe check: use lock to prevent race conditions
|
|
1451
|
+
async def _safe_app_id_index_check():
|
|
1452
|
+
# Check cache inside lock to prevent duplicate tasks
|
|
1453
|
+
async with ScopedMongoWrapper._app_id_index_lock:
|
|
1454
|
+
# Double-check pattern: another coroutine may have already added it
|
|
1455
|
+
if collection_name in ScopedMongoWrapper._app_id_index_cache:
|
|
1456
|
+
return # Already checking or checked
|
|
1457
|
+
|
|
1458
|
+
# Mark as checking to prevent duplicate tasks
|
|
1459
|
+
ScopedMongoWrapper._app_id_index_cache[collection_name] = False
|
|
1460
|
+
|
|
1461
|
+
# Perform index check outside lock (async operation)
|
|
1462
|
+
try:
|
|
1463
|
+
# Check if connection is still alive before attempting index creation
|
|
1464
|
+
try:
|
|
1465
|
+
# Quick ping to verify connection is still valid
|
|
1466
|
+
await real_collection.database.client.admin.command("ping")
|
|
1467
|
+
except (
|
|
1468
|
+
ConnectionFailure,
|
|
1469
|
+
OperationFailure,
|
|
1470
|
+
ServerSelectionTimeoutError,
|
|
1471
|
+
):
|
|
1472
|
+
# Connection is closed, skip index creation
|
|
1473
|
+
# Type 2: Recoverable - skip index creation if connection fails
|
|
1474
|
+
logger.debug(
|
|
1475
|
+
f"Skipping app_id index creation for '{collection_name}': "
|
|
1476
|
+
f"connection is closed (likely during shutdown)"
|
|
1477
|
+
)
|
|
1478
|
+
async with ScopedMongoWrapper._app_id_index_lock:
|
|
1479
|
+
ScopedMongoWrapper._app_id_index_cache.pop(
|
|
1480
|
+
collection_name, None
|
|
1481
|
+
)
|
|
1482
|
+
return
|
|
1483
|
+
|
|
1484
|
+
has_index = await self._ensure_app_id_index(real_collection)
|
|
1485
|
+
# Update cache with result (inside lock for thread-safety)
|
|
1486
|
+
async with ScopedMongoWrapper._app_id_index_lock:
|
|
1487
|
+
ScopedMongoWrapper._app_id_index_cache[collection_name] = (
|
|
1488
|
+
has_index
|
|
1489
|
+
)
|
|
1490
|
+
except (
|
|
1491
|
+
ConnectionFailure,
|
|
1492
|
+
ServerSelectionTimeoutError,
|
|
1493
|
+
InvalidOperation,
|
|
1494
|
+
) as e:
|
|
1495
|
+
# Handle connection errors gracefully (e.g., during shutdown)
|
|
1496
|
+
logger.debug(
|
|
1497
|
+
f"Skipping app_id index creation for '{collection_name}': "
|
|
1498
|
+
f"connection error (likely during shutdown): {e}"
|
|
1499
|
+
)
|
|
1500
|
+
# Remove from cache on error so we can retry later
|
|
1501
|
+
async with ScopedMongoWrapper._app_id_index_lock:
|
|
1502
|
+
ScopedMongoWrapper._app_id_index_cache.pop(
|
|
1503
|
+
collection_name, None
|
|
1504
|
+
)
|
|
1505
|
+
except OperationFailure as e:
|
|
1506
|
+
# Index creation failed for other reasons (non-critical)
|
|
1507
|
+
logger.debug(f"App_id index creation failed (non-critical): {e}")
|
|
1508
|
+
# Remove from cache on error so we can retry later
|
|
1509
|
+
async with ScopedMongoWrapper._app_id_index_lock:
|
|
1510
|
+
ScopedMongoWrapper._app_id_index_cache.pop(
|
|
1511
|
+
collection_name, None
|
|
1512
|
+
)
|
|
1513
|
+
|
|
1514
|
+
# Check cache first (quick check before lock)
|
|
1515
|
+
if collection_name not in ScopedMongoWrapper._app_id_index_cache:
|
|
1516
|
+
# Fire and forget - task will check lock internally
|
|
1517
|
+
# (managed to prevent accumulation)
|
|
1518
|
+
_create_managed_task(
|
|
1519
|
+
_safe_app_id_index_check(), task_name="app_id_index_check"
|
|
1520
|
+
)
|
|
1521
|
+
|
|
1522
|
+
# Store it in the cache for this instance using the *prefixed_name*
|
|
1523
|
+
self._wrapper_cache[prefixed_name] = wrapper
|
|
1524
|
+
return wrapper
|
|
1525
|
+
|
|
1526
|
+
def get_collection(self, name: str) -> ScopedCollectionWrapper:
|
|
1527
|
+
"""
|
|
1528
|
+
Get a collection by name (Motor-like API).
|
|
1529
|
+
|
|
1530
|
+
This method allows accessing collections by their fully prefixed name,
|
|
1531
|
+
which is useful for cross-app access. For same-app access,
|
|
1532
|
+
you can use attribute access (e.g., `db.my_collection`) which automatically
|
|
1533
|
+
prefixes the name.
|
|
1534
|
+
|
|
1535
|
+
Args:
|
|
1536
|
+
name: Collection name - can be base name (will be prefixed) or
|
|
1537
|
+
fully prefixed name (e.g., "click_tracker_clicks")
|
|
1538
|
+
|
|
1539
|
+
Returns:
|
|
1540
|
+
ScopedCollectionWrapper instance
|
|
1541
|
+
|
|
1542
|
+
Example:
|
|
1543
|
+
# Same-app collection (base name)
|
|
1544
|
+
collection = db.get_collection("my_collection")
|
|
1545
|
+
|
|
1546
|
+
# Cross-app collection (fully prefixed)
|
|
1547
|
+
collection = db.get_collection("click_tracker_clicks")
|
|
1548
|
+
"""
|
|
1549
|
+
# Check if name is already fully prefixed (contains underscore and is longer)
|
|
1550
|
+
# We use a heuristic: if name contains underscore and doesn't start with write_scope,
|
|
1551
|
+
# assume it's already fully prefixed
|
|
1552
|
+
if "_" in name and not name.startswith(f"{self._write_scope}_"):
|
|
1553
|
+
# Assume it's already fully prefixed (cross-app access)
|
|
1554
|
+
prefixed_name = name
|
|
1555
|
+
else:
|
|
1556
|
+
# Standard case: prefix with write_scope
|
|
1557
|
+
prefixed_name = f"{self._write_scope}_{name}"
|
|
1558
|
+
|
|
1559
|
+
# Check cache first
|
|
1560
|
+
if prefixed_name in self._wrapper_cache:
|
|
1561
|
+
return self._wrapper_cache[prefixed_name]
|
|
1562
|
+
|
|
1563
|
+
# Get the real collection from the motor db object
|
|
1564
|
+
real_collection = getattr(self._db, prefixed_name)
|
|
1565
|
+
|
|
1566
|
+
# Ensure we are actually wrapping a collection object
|
|
1567
|
+
if not isinstance(real_collection, AsyncIOMotorCollection):
|
|
1568
|
+
raise AttributeError(
|
|
1569
|
+
f"'{name}' (as '{prefixed_name}') is not an AsyncIOMotorCollection. "
|
|
1570
|
+
f"ScopedMongoWrapper can only proxy collections (found {type(real_collection)})."
|
|
1571
|
+
)
|
|
1572
|
+
|
|
1573
|
+
# Create the new wrapper with auto-indexing enabled by default
|
|
1574
|
+
wrapper = ScopedCollectionWrapper(
|
|
1575
|
+
real_collection=real_collection,
|
|
1576
|
+
read_scopes=self._read_scopes,
|
|
1577
|
+
write_scope=self._write_scope,
|
|
1578
|
+
auto_index=self._auto_index,
|
|
1579
|
+
)
|
|
1580
|
+
|
|
1581
|
+
# Magically ensure app_id index exists (background task)
|
|
1582
|
+
# Uses same race-condition-safe approach as __getattr__
|
|
1583
|
+
if self._auto_index:
|
|
1584
|
+
collection_name = real_collection.name
|
|
1585
|
+
|
|
1586
|
+
async def _safe_app_id_index_check():
|
|
1587
|
+
# Check cache inside lock to prevent duplicate tasks
|
|
1588
|
+
async with ScopedMongoWrapper._app_id_index_lock:
|
|
1589
|
+
if collection_name in ScopedMongoWrapper._app_id_index_cache:
|
|
1590
|
+
return # Already checking or checked
|
|
1591
|
+
ScopedMongoWrapper._app_id_index_cache[collection_name] = False
|
|
1592
|
+
|
|
1593
|
+
try:
|
|
1594
|
+
# Check if connection is still alive before attempting index creation
|
|
1595
|
+
try:
|
|
1596
|
+
# Quick ping to verify connection is still valid
|
|
1597
|
+
await real_collection.database.client.admin.command("ping")
|
|
1598
|
+
except (
|
|
1599
|
+
ConnectionFailure,
|
|
1600
|
+
OperationFailure,
|
|
1601
|
+
ServerSelectionTimeoutError,
|
|
1602
|
+
):
|
|
1603
|
+
# Connection is closed, skip index creation
|
|
1604
|
+
# Type 2: Recoverable - skip index creation if connection fails
|
|
1605
|
+
logger.debug(
|
|
1606
|
+
f"Skipping app_id index creation for '{collection_name}': "
|
|
1607
|
+
f"connection is closed (likely during shutdown)"
|
|
1608
|
+
)
|
|
1609
|
+
async with ScopedMongoWrapper._app_id_index_lock:
|
|
1610
|
+
ScopedMongoWrapper._app_id_index_cache.pop(
|
|
1611
|
+
collection_name, None
|
|
1612
|
+
)
|
|
1613
|
+
return
|
|
1614
|
+
|
|
1615
|
+
has_index = await self._ensure_app_id_index(real_collection)
|
|
1616
|
+
async with ScopedMongoWrapper._app_id_index_lock:
|
|
1617
|
+
ScopedMongoWrapper._app_id_index_cache[collection_name] = (
|
|
1618
|
+
has_index
|
|
1619
|
+
)
|
|
1620
|
+
except (
|
|
1621
|
+
ConnectionFailure,
|
|
1622
|
+
ServerSelectionTimeoutError,
|
|
1623
|
+
InvalidOperation,
|
|
1624
|
+
) as e:
|
|
1625
|
+
# Handle connection errors gracefully (e.g., during shutdown)
|
|
1626
|
+
logger.debug(
|
|
1627
|
+
f"Skipping app_id index creation for '{collection_name}': "
|
|
1628
|
+
f"connection error (likely during shutdown): {e}"
|
|
1629
|
+
)
|
|
1630
|
+
async with ScopedMongoWrapper._app_id_index_lock:
|
|
1631
|
+
ScopedMongoWrapper._app_id_index_cache.pop(
|
|
1632
|
+
collection_name, None
|
|
1633
|
+
)
|
|
1634
|
+
except OperationFailure as e:
|
|
1635
|
+
# Index creation failed for other reasons (non-critical)
|
|
1636
|
+
logger.debug(f"App_id index creation failed (non-critical): {e}")
|
|
1637
|
+
async with ScopedMongoWrapper._app_id_index_lock:
|
|
1638
|
+
ScopedMongoWrapper._app_id_index_cache.pop(
|
|
1639
|
+
collection_name, None
|
|
1640
|
+
)
|
|
1641
|
+
|
|
1642
|
+
if collection_name not in ScopedMongoWrapper._app_id_index_cache:
|
|
1643
|
+
# Use managed task creation to prevent accumulation
|
|
1644
|
+
_create_managed_task(
|
|
1645
|
+
_safe_app_id_index_check(), task_name="app_id_index_check"
|
|
1646
|
+
)
|
|
1647
|
+
|
|
1648
|
+
# Store it in the cache
|
|
1649
|
+
self._wrapper_cache[prefixed_name] = wrapper
|
|
1650
|
+
return wrapper
|
|
1651
|
+
|
|
1652
|
+
async def _ensure_app_id_index(self, collection: AsyncIOMotorCollection) -> bool:
|
|
1653
|
+
"""
|
|
1654
|
+
Ensures app_id index exists on collection.
|
|
1655
|
+
This index is always needed since all queries filter by app_id.
|
|
1656
|
+
|
|
1657
|
+
Returns:
|
|
1658
|
+
True if index exists (or was created), False otherwise
|
|
1659
|
+
"""
|
|
1660
|
+
try:
|
|
1661
|
+
index_manager = AsyncAtlasIndexManager(collection)
|
|
1662
|
+
existing_indexes = await index_manager.list_indexes()
|
|
1663
|
+
|
|
1664
|
+
# Check if app_id index already exists
|
|
1665
|
+
app_id_index_exists = False
|
|
1666
|
+
for idx in existing_indexes:
|
|
1667
|
+
keys = idx.get("key", {})
|
|
1668
|
+
# Check if app_id is indexed (could be single field or part of compound)
|
|
1669
|
+
if "app_id" in keys:
|
|
1670
|
+
app_id_index_exists = True
|
|
1671
|
+
break
|
|
1672
|
+
|
|
1673
|
+
if not app_id_index_exists:
|
|
1674
|
+
# Create app_id index
|
|
1675
|
+
try:
|
|
1676
|
+
await index_manager.create_index(
|
|
1677
|
+
[("app_id", ASCENDING)], name="auto_app_id_asc", background=True
|
|
1678
|
+
)
|
|
1679
|
+
logger.info(f"✨ Auto-created app_id index on {collection.name}")
|
|
1680
|
+
return True
|
|
1681
|
+
except OperationFailure as e:
|
|
1682
|
+
# Handle index build aborted (e.g., database being dropped during teardown)
|
|
1683
|
+
if (
|
|
1684
|
+
e.code == 276
|
|
1685
|
+
or "IndexBuildAborted" in str(e)
|
|
1686
|
+
or "dropDatabase" in str(e)
|
|
1687
|
+
):
|
|
1688
|
+
logger.debug(
|
|
1689
|
+
f"Skipping app_id index creation on {collection.name}: "
|
|
1690
|
+
f"index build aborted (likely during database drop/teardown): {e}"
|
|
1691
|
+
)
|
|
1692
|
+
return False
|
|
1693
|
+
raise
|
|
1694
|
+
return True
|
|
1695
|
+
except OperationFailure as e:
|
|
1696
|
+
# Handle index build aborted (e.g., database being dropped during teardown)
|
|
1697
|
+
if (
|
|
1698
|
+
e.code == 276
|
|
1699
|
+
or "IndexBuildAborted" in str(e)
|
|
1700
|
+
or "dropDatabase" in str(e)
|
|
1701
|
+
):
|
|
1702
|
+
logger.debug(
|
|
1703
|
+
f"Skipping app_id index creation on {collection.name}: "
|
|
1704
|
+
f"index build aborted (likely during database drop/teardown): {e}"
|
|
1705
|
+
)
|
|
1706
|
+
return False
|
|
1707
|
+
logger.debug(
|
|
1708
|
+
f"OperationFailure ensuring app_id index on {collection.name}: {e}"
|
|
1709
|
+
)
|
|
1710
|
+
return False
|
|
1711
|
+
except (ConnectionFailure, ServerSelectionTimeoutError, InvalidOperation) as e:
|
|
1712
|
+
# Handle connection errors gracefully (e.g., during shutdown)
|
|
1713
|
+
logger.debug(
|
|
1714
|
+
f"Skipping app_id index creation on {collection.name}: "
|
|
1715
|
+
f"connection error (likely during shutdown): {e}"
|
|
1716
|
+
)
|
|
1717
|
+
return False
|
|
1718
|
+
except OperationFailure as e:
|
|
1719
|
+
# Index creation failed for other reasons (non-critical)
|
|
1720
|
+
logger.debug(f"Could not ensure app_id index on {collection.name}: {e}")
|
|
1721
|
+
return False
|