@@ -115,18 +115,14 @@ def __init__(
115115 self .local_memory : list [dict [str , Any ]] = []
116116
117117 self ._background_tasks : set [asyncio .Task [Any ]] = set ()
118- # Backpressure for fire-and-forget auto-trigger tasks. Without a
119- # cap, FACT_EXTRACTION_EVERY_N=1 + a burst of pushes spawns one
120- # extract pipeline per push and slams AI Foundry. Configurable via
121- # MEMORY_AUTO_TRIGGER_CONCURRENCY (default 4).
122118 try :
123119 _max = int (os .environ .get ("MEMORY_AUTO_TRIGGER_CONCURRENCY" , "4" ))
124120 except ValueError :
125121 _max = 4
126122 self ._auto_trigger_semaphore : asyncio .Semaphore = asyncio .Semaphore (max (1 , _max ))
127- # One-shot WARN guard: skip-due-to-MEMORY_PROCESSOR_OWNER=durable
128- # would otherwise log on every push.
129123 self ._warned_owner_skip : bool = False
124+ self ._warned_counter_unreachable : bool = False
125+ self ._pipeline_init_error : Exception | None = None
130126
131127 # Store kwargs directly
132128 self ._cosmos_endpoint = cosmos_endpoint
@@ -249,16 +245,23 @@ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
249245 async def close (self ) -> None :
250246 """Close all underlying async clients.
251247
252- Drains any in-flight fire-and-forget auto-trigger tasks first so
253- they don't continue running against torn-down Cosmos/embeddings
254- handles.
248+ Cancels any in-flight fire-and-forget auto-trigger tasks first so
249+ ``close()`` / ``__aexit__`` doesn't block for tens of seconds while
250+ a mid-LLM extract runs to completion. Auto-trigger work is
251+ recoverable on the next push, so cancellation is safe.
255252 """
256- # Drain fire-and-forget auto-trigger tasks before tearing down the
257- # underlying clients, otherwise an in-flight extract+dedup will
258- # crash with CosmosOperationError mid-shutdown.
253+ # Cancel + bounded-wait. Without the cancel, an in-flight extract+dedup
254+ # can hold close() for tens of seconds. With cancel + 5s wait we
255+ # guarantee bounded shutdown latency; remaining tasks raise
256+ # CancelledError and will be retried on the next push_to_cosmos.
259257 pending = list (self ._background_tasks )
260258 if pending :
261- await asyncio .gather (* pending , return_exceptions = True )
259+ for task in pending :
260+ task .cancel ()
261+ try :
262+ await asyncio .wait (pending , timeout = 5.0 )
263+ except Exception :
264+ pass
262265 if self ._cosmos_client is not None :
263266 await self ._cosmos_client .close ()
264267 self ._cosmos_client = None
@@ -273,6 +276,16 @@ async def close(self) -> None:
273276 pass
274277 self ._sync_cosmos_client = None
275278 await self ._embeddings_client .close ()
279+ # Async client's ChatClient is shared with the sync pipeline; close
280+ # its sync httpx pool too so we don't leak across __aexit__.
281+ try :
282+ self ._chat_client .close_sync ()
283+ except Exception :
284+ pass
285+ try :
286+ await self ._chat_client .close ()
287+ except Exception :
288+ pass
276289 if self ._owns_cosmos_credential and self ._cosmos_credential is not None :
277290 close = getattr (self ._cosmos_credential , "close" , None )
278291 if close is not None :
@@ -1213,7 +1226,14 @@ def _init_pipeline(self) -> None:
12131226 sync_db = sync_client .get_database_client (self ._cosmos_database )
12141227 sync_container = sync_db .get_container_client (self ._cosmos_container )
12151228 self ._sync_cosmos_client = sync_client
1229+ self ._pipeline_init_error = None
12161230 except Exception as exc :
1231+ # Capture the real failure so _require_pipeline can surface it
1232+ # later — the alternative is the user calls connect_cosmos()
1233+ # successfully, then sees CosmosNotConnectedError("call
1234+ # connect_cosmos() first") on the first extract, with the real
1235+ # cause buried in an old WARN.
1236+ self ._pipeline_init_error = exc
12171237 logger .warning ("Failed to create sync Cosmos client for pipeline: %s" , exc )
12181238 sync_container = None
12191239
@@ -1268,6 +1288,14 @@ def _warn_on_embedding_dim_mismatch(self, sync_container: Any) -> None:
12681288 def _require_pipeline (self ) -> None :
12691289 """Raise if the processing pipeline is not available."""
12701290 if self ._pipeline is None :
1291+ if self ._pipeline_init_error is not None :
1292+ raise CosmosNotConnectedError (
1293+ "Processing pipeline failed to initialize "
1294+ f"({ type (self ._pipeline_init_error ).__name__ } : "
1295+ f"{ self ._pipeline_init_error } ). The async client connected "
1296+ "but the sync Cosmos client used by the pipeline could "
1297+ "not be built. Check credentials and endpoint."
1298+ ) from self ._pipeline_init_error
12711299 raise CosmosNotConnectedError (
12721300 "Processing pipeline requires Cosmos DB connection. "
12731301 "Call connect_cosmos() or create_memory_store() first."
@@ -1281,7 +1309,11 @@ def _get_processor(self) -> AsyncMemoryProcessor:
12811309 return self ._processor
12821310
12831311 def _get_counter_container (self ) -> Any :
1284- """Lazy handle to the counter container (best-effort, returns None on failure)."""
1312+ """Lazy handle to the counter container (best-effort, returns None on failure).
1313+
1314+ Logs a one-shot WARN on first failure so operators don't silently
1315+ lose all memory processing because of a missing counter container.
1316+ """
12851317 if self ._counter_container_client is not None :
12861318 return self ._counter_container_client
12871319 if self ._cosmos_client is None :
@@ -1291,12 +1323,18 @@ def _get_counter_container(self) -> Any:
12911323 self ._counter_container_client = db .get_container_client (self ._cosmos_counter_container )
12921324 return self ._counter_container_client
12931325 except Exception as exc : # pragma: no cover - defensive
1294- logger .warning (
1295- "Counter container %s/%s unreachable; auto-trigger disabled: %s" ,
1296- self ._cosmos_database ,
1297- self ._cosmos_counter_container ,
1298- exc ,
1299- )
1326+ if not self ._warned_counter_unreachable :
1327+ self ._warned_counter_unreachable = True
1328+ logger .warning (
1329+ "Counter container %s/%s unreachable (%s: %s); "
1330+ "auto-trigger DISABLED for the lifetime of this client. "
1331+ "Provision the container or set MEMORY_PROCESSOR_OWNER=durable "
1332+ "if processing runs in the Function App." ,
1333+ self ._cosmos_database ,
1334+ self ._cosmos_counter_container ,
1335+ type (exc ).__name__ ,
1336+ exc ,
1337+ )
13001338 return None
13011339
13021340 async def _maybe_auto_trigger (self , turn_counts : dict [tuple [str , str ], int ]) -> None :
@@ -1421,6 +1459,14 @@ async def _run_auto_trigger_steps(
14211459 fire_summary = n_summary > 0 and crosses_threshold (old_count , new_count , n_summary )
14221460 fire_dedup = n_dedup_turns > 0 and crosses_threshold (old_count , new_count , n_dedup_turns )
14231461
1462+ # Order matters: extract → dedup → summary.
1463+ # Each step gates on its OWN threshold (independent crossings),
1464+ # but when multiple thresholds cross in the same batch we still
1465+ # want the data flow to be: write fresh facts, deduplicate them,
1466+ # then fold the deduplicated set into the summary. Reordering
1467+ # would risk a summary that includes since-removed duplicates
1468+ # or omits just-extracted facts. Keep in sync with the sync
1469+ # client and function_app/orchestrators/.
14241470 if fire_extract :
14251471 try :
14261472 await processor .process_extract_memories (user_id = user_id , thread_id = thread_id )
0 commit comments