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.
Files changed (75) hide show
  1. mdb_engine/README.md +144 -0
  2. mdb_engine/__init__.py +37 -0
  3. mdb_engine/auth/README.md +631 -0
  4. mdb_engine/auth/__init__.py +128 -0
  5. mdb_engine/auth/casbin_factory.py +199 -0
  6. mdb_engine/auth/casbin_models.py +46 -0
  7. mdb_engine/auth/config_defaults.py +71 -0
  8. mdb_engine/auth/config_helpers.py +213 -0
  9. mdb_engine/auth/cookie_utils.py +158 -0
  10. mdb_engine/auth/decorators.py +350 -0
  11. mdb_engine/auth/dependencies.py +747 -0
  12. mdb_engine/auth/helpers.py +64 -0
  13. mdb_engine/auth/integration.py +578 -0
  14. mdb_engine/auth/jwt.py +225 -0
  15. mdb_engine/auth/middleware.py +241 -0
  16. mdb_engine/auth/oso_factory.py +323 -0
  17. mdb_engine/auth/provider.py +570 -0
  18. mdb_engine/auth/restrictions.py +271 -0
  19. mdb_engine/auth/session_manager.py +477 -0
  20. mdb_engine/auth/token_lifecycle.py +213 -0
  21. mdb_engine/auth/token_store.py +289 -0
  22. mdb_engine/auth/users.py +1516 -0
  23. mdb_engine/auth/utils.py +614 -0
  24. mdb_engine/cli/__init__.py +13 -0
  25. mdb_engine/cli/commands/__init__.py +7 -0
  26. mdb_engine/cli/commands/generate.py +105 -0
  27. mdb_engine/cli/commands/migrate.py +83 -0
  28. mdb_engine/cli/commands/show.py +70 -0
  29. mdb_engine/cli/commands/validate.py +63 -0
  30. mdb_engine/cli/main.py +41 -0
  31. mdb_engine/cli/utils.py +92 -0
  32. mdb_engine/config.py +217 -0
  33. mdb_engine/constants.py +160 -0
  34. mdb_engine/core/README.md +542 -0
  35. mdb_engine/core/__init__.py +42 -0
  36. mdb_engine/core/app_registration.py +392 -0
  37. mdb_engine/core/connection.py +243 -0
  38. mdb_engine/core/engine.py +749 -0
  39. mdb_engine/core/index_management.py +162 -0
  40. mdb_engine/core/manifest.py +2793 -0
  41. mdb_engine/core/seeding.py +179 -0
  42. mdb_engine/core/service_initialization.py +355 -0
  43. mdb_engine/core/types.py +413 -0
  44. mdb_engine/database/README.md +522 -0
  45. mdb_engine/database/__init__.py +31 -0
  46. mdb_engine/database/abstraction.py +635 -0
  47. mdb_engine/database/connection.py +387 -0
  48. mdb_engine/database/scoped_wrapper.py +1721 -0
  49. mdb_engine/embeddings/README.md +184 -0
  50. mdb_engine/embeddings/__init__.py +62 -0
  51. mdb_engine/embeddings/dependencies.py +193 -0
  52. mdb_engine/embeddings/service.py +759 -0
  53. mdb_engine/exceptions.py +167 -0
  54. mdb_engine/indexes/README.md +651 -0
  55. mdb_engine/indexes/__init__.py +21 -0
  56. mdb_engine/indexes/helpers.py +145 -0
  57. mdb_engine/indexes/manager.py +895 -0
  58. mdb_engine/memory/README.md +451 -0
  59. mdb_engine/memory/__init__.py +30 -0
  60. mdb_engine/memory/service.py +1285 -0
  61. mdb_engine/observability/README.md +515 -0
  62. mdb_engine/observability/__init__.py +42 -0
  63. mdb_engine/observability/health.py +296 -0
  64. mdb_engine/observability/logging.py +161 -0
  65. mdb_engine/observability/metrics.py +297 -0
  66. mdb_engine/routing/README.md +462 -0
  67. mdb_engine/routing/__init__.py +73 -0
  68. mdb_engine/routing/websockets.py +813 -0
  69. mdb_engine/utils/__init__.py +7 -0
  70. mdb_engine-0.1.6.dist-info/METADATA +213 -0
  71. mdb_engine-0.1.6.dist-info/RECORD +75 -0
  72. mdb_engine-0.1.6.dist-info/WHEEL +5 -0
  73. mdb_engine-0.1.6.dist-info/entry_points.txt +2 -0
  74. mdb_engine-0.1.6.dist-info/licenses/LICENSE +661 -0
  75. 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