@@ -272,15 +272,16 @@ def push(self, local_memory: list[dict[str, Any]], batch_size: int = 25) -> None
272272 )
273273
274274 bodies = [self ._prepare_doc (body ) for body in bodies ]
275- for record , body in zip ( batch , bodies ) :
275+ for body in bodies :
276276 memory_type = body .get ("type" )
277277 if memory_type not in _CONTAINER_FOR_TYPE :
278278 raise ValueError (
279279 f"push: record id={ body .get ('id' )!r} has invalid type={ memory_type !r} . "
280280 f"Set 'type' to one of { sorted (_CONTAINER_FOR_TYPE )} on every local "
281281 f"memory before calling push_to_cosmos."
282282 )
283- container = self ._container_for_type (memory_type )
283+ for record , body in zip (batch , bodies ):
284+ container = self ._container_for_type (body .get ("type" ))
284285 try :
285286 container .upsert_item (body = body )
286287 except Exception as exc :
@@ -375,34 +376,55 @@ def update(
375376 metadata : Optional [dict [str , Any ]] = None ,
376377 ) -> None :
377378 """Update a memory document via point read in the container for ``memory_type``."""
378- from azure .cosmos .exceptions import CosmosResourceNotFoundError
379-
380- container = self ._container_for_type (memory_type )
381- try :
382- doc = container .read_item (item = memory_id , partition_key = [user_id , thread_id ])
383- except CosmosResourceNotFoundError as exc :
384- raise MemoryNotFoundError (memory_id = memory_id , user_id = user_id , thread_id = thread_id ) from exc
385- except Exception as exc :
386- raise _wrap_cosmos_exception (exc , message = f"update read failed for { memory_id } : { exc } " ) from exc
387-
388- actual_type = doc .get ("type" )
389- if actual_type != memory_type :
390- raise MemoryTypeMismatchError (memory_id = memory_id , expected = memory_type , actual = actual_type )
379+ import random
380+ import time
391381
392- if content is not None :
393- doc ["content" ] = content
394- if role is not None :
395- doc ["role" ] = role
396- if metadata is not None :
397- doc ["metadata" ] = metadata
398- doc ["updated_at" ] = datetime .now (timezone .utc ).isoformat ()
382+ from azure .core import MatchConditions
383+ from azure .cosmos .exceptions import (
384+ CosmosAccessConditionFailedError ,
385+ CosmosResourceNotFoundError ,
386+ )
399387
400- try :
401- container .replace_item (item = doc ["id" ], body = doc )
402- except Exception as exc :
403- raise _wrap_cosmos_exception (exc , message = f"update replace failed for { memory_id } : { exc } " ) from exc
388+ container = self ._container_for_type (memory_type )
389+ max_attempts = 5
390+ attempts = 0
391+ while True :
392+ try :
393+ doc = container .read_item (item = memory_id , partition_key = [user_id , thread_id ])
394+ except CosmosResourceNotFoundError as exc :
395+ raise MemoryNotFoundError (memory_id = memory_id , user_id = user_id , thread_id = thread_id ) from exc
396+ except Exception as exc :
397+ raise _wrap_cosmos_exception (exc , message = f"update read failed for { memory_id } : { exc } " ) from exc
398+
399+ actual_type = doc .get ("type" )
400+ if actual_type != memory_type :
401+ raise MemoryTypeMismatchError (memory_id = memory_id , expected = memory_type , actual = actual_type )
402+
403+ if content is not None :
404+ doc ["content" ] = content
405+ if role is not None :
406+ doc ["role" ] = role
407+ if metadata is not None :
408+ doc ["metadata" ] = metadata
409+ doc ["updated_at" ] = datetime .now (timezone .utc ).isoformat ()
404410
405- logger .info ("Updated record %s" , memory_id )
411+ kwargs : dict [str , Any ] = {"item" : doc ["id" ], "body" : doc }
412+ if etag := doc .get ("_etag" ):
413+ kwargs .update (match_condition = MatchConditions .IfNotModified , etag = etag )
414+ try :
415+ container .replace_item (** kwargs )
416+ logger .info ("Updated record %s" , memory_id )
417+ return
418+ except CosmosAccessConditionFailedError as exc :
419+ attempts += 1
420+ if attempts >= max_attempts :
421+ raise MemoryConflictError (
422+ f"update conflicted after { max_attempts } attempts for memory_id={ memory_id !r} "
423+ ) from exc
424+ base = 0.02 * (2 ** (attempts - 1 ))
425+ time .sleep (base + random .uniform (0 , base ))
426+ except Exception as exc :
427+ raise _wrap_cosmos_exception (exc , message = f"update replace failed for { memory_id } : { exc } " ) from exc
406428
407429 def delete (
408430 self ,
@@ -414,11 +436,16 @@ def delete(
414436 ) -> None :
415437 """Delete a memory document from the container for ``memory_type``.
416438
417- Reads the doc first to verify its ``type`` matches ``memory_type`` so a
418- wrong routing key cannot silently delete the wrong document (within
419- MEMORIES, fact/episodic/procedural share the same partition key).
439+ Reads the doc first to verify its ``type`` matches ``memory_type`` and
440+ then issues the delete with ``If-Match`` on the read ETag, so a
441+ concurrent type mutation between read and delete is rejected (412)
442+ rather than silently dropping the wrong document.
420443 """
421- from azure .cosmos .exceptions import CosmosResourceNotFoundError
444+ from azure .core import MatchConditions
445+ from azure .cosmos .exceptions import (
446+ CosmosAccessConditionFailedError ,
447+ CosmosResourceNotFoundError ,
448+ )
422449
423450 container = self ._container_for_type (memory_type )
424451 try :
@@ -432,10 +459,17 @@ def delete(
432459 if actual_type != memory_type :
433460 raise MemoryTypeMismatchError (memory_id = memory_id , expected = memory_type , actual = actual_type )
434461
462+ kwargs : dict [str , Any ] = {"item" : memory_id , "partition_key" : [user_id , thread_id ]}
463+ if etag := doc .get ("_etag" ):
464+ kwargs .update (match_condition = MatchConditions .IfNotModified , etag = etag )
435465 try :
436- container .delete_item (item = memory_id , partition_key = [ user_id , thread_id ] )
466+ container .delete_item (** kwargs )
437467 except CosmosResourceNotFoundError as exc :
438468 raise MemoryNotFoundError (memory_id = memory_id , user_id = user_id , thread_id = thread_id ) from exc
469+ except CosmosAccessConditionFailedError as exc :
470+ raise MemoryConflictError (
471+ f"delete conflicted for memory_id={ memory_id !r} — document was modified after the type check"
472+ ) from exc
439473 except Exception as exc :
440474 raise _wrap_cosmos_exception (exc , message = f"delete failed for { memory_id } : { exc } " ) from exc
441475
@@ -680,11 +714,13 @@ def mark_superseded(
680714 )
681715 del exc
682716 return False
683- except CosmosHttpResponseError :
684- logger .exception (
685- "supersede transient failure id=%s superseder=%s" ,
717+ except CosmosHttpResponseError as exc :
718+ logger .warning (
719+ "supersede failed id=%s superseder=%s status=%s: %s" ,
686720 old_doc .get ("id" ),
687721 superseder_id ,
722+ getattr (exc , "status_code" , None ),
723+ exc ,
688724 )
689725 return False
690726
0 commit comments