33import asyncio
44import builtins
55import contextlib
6- import json
76from collections .abc import AsyncIterator , Iterator , Mapping , Sequence
87from datetime import datetime , timezone
98from typing import Any , cast
109
10+ from aerospike_helpers .operations import map_operations , operations
1111from langchain_core .runnables import RunnableConfig
1212from langgraph .checkpoint .base import (
1313 WRITES_IDX_MAP ,
@@ -114,13 +114,19 @@ def _key_timeline(self, thread_id: str, checkpoint_ns: str):
114114 return (self .ns , self .set_meta , f"{ thread_id } { SEP } { checkpoint_ns } { SEP } __timeline__" )
115115
116116 # ---------- aerospike io ----------
117- def _put (self , key , bins : dict [str , Any ]) -> None :
118- policy : dict [str , Any ] | None = None
117+ def _ttl_policy (self ) -> dict [str , Any ] | None :
118+ """Return ``{"ttl": seconds}`` for the configured TTL, or ``None``.
119+
120+ Passed as ``policy=`` to both ``client.put`` and ``client.operate``.
121+ """
119122 minutes = self ._ttl_minutes
120- if minutes is not None :
121- minutes = int (minutes )
122- if minutes > 0 :
123- policy = {"ttl" : minutes * 60 }
123+ if minutes is None :
124+ return None
125+ seconds = int (minutes ) * 60
126+ return {"ttl" : seconds } if seconds > 0 else None
127+
128+ def _put (self , key , bins : dict [str , Any ]) -> None :
129+ policy = self ._ttl_policy ()
124130 try :
125131 if policy is not None :
126132 self .client .put (key , bins , policy = policy )
@@ -130,39 +136,45 @@ def _put(self, key, bins: dict[str, Any]) -> None:
130136 raise RuntimeError (f"Aerospike put failed for { key } : { e } " ) from e
131137
132138 def _get (self , key ) -> tuple | None :
139+ # `read_touch_ttl_percent=100` refreshes the TTL on every
140+ # successful read, server-side, in the same round-trip.
141+ policy : dict [str , Any ] | None = None
142+ if self ._refresh_on_read and self ._ttl_minutes is not None and self ._ttl_minutes > 0 :
143+ policy = {"read_touch_ttl_percent" : 100 }
144+
133145 try :
134- rec = self .client .get (key )
146+ if policy is not None :
147+ rec = self .client .get (key , policy = policy )
148+ else :
149+ rec = self .client .get (key )
135150 except aerospike .exception .RecordNotFound :
136151 return None
137152 except aerospike .exception .AerospikeError as e :
138153 raise RuntimeError (f"Aerospike get failed for { key } : { e } " ) from e
139154
140- if self ._refresh_on_read and self ._ttl_minutes is not None and self ._ttl_minutes > 0 :
141- with contextlib .suppress (aerospike .exception .AerospikeError ):
142- self .client .touch (key , int (self ._ttl_minutes ) * 60 )
143-
144155 return rec
145156
146157 def _read_timeline_items (self , timeline_key ) -> builtins .list [tuple [str , str ]]:
147- """Return timeline entries as ``(iso_timestamp, checkpoint_id)`` pairs."""
158+ """Return timeline entries as ``(iso_timestamp, checkpoint_id)`` pairs.
159+
160+ On-disk shape is a Map bin (``timeline``) keyed by
161+ ``checkpoint_id`` with ISO-timestamp values. We sort by ``ts``
162+ descending here so callers see reverse-chronological order.
163+ """
148164 rec = self ._get (timeline_key )
149165 if rec is None :
150166 return []
151167 bins = rec [2 ]
152- try :
153- items = json .loads (bins .get ("items" , "[]" ))
154- cleaned : list [tuple [str , str ]] = []
155- for it in items :
156- if (
157- isinstance (it , list )
158- and len (it ) == 2
159- and isinstance (it [0 ], str )
160- and isinstance (it [1 ], str )
161- ):
162- cleaned .append ((it [0 ], it [1 ]))
163- return cleaned
164- except Exception :
168+ timeline = bins .get ("timeline" ) or {}
169+ if not isinstance (timeline , dict ):
165170 return []
171+ pairs : list [tuple [str , str ]] = [
172+ (ts , cid )
173+ for cid , ts in timeline .items ()
174+ if isinstance (ts , str ) and isinstance (cid , str )
175+ ]
176+ pairs .sort (key = lambda p : p [0 ], reverse = True )
177+ return pairs
166178
167179 def _delete (self , key ) -> None :
168180 try :
@@ -220,19 +232,21 @@ def put(
220232 )
221233
222234 timeline_key = self ._key_timeline (thread_id , checkpoint_ns )
223- items = self ._read_timeline_items (timeline_key )
224-
225- items = [(t , cid ) for (t , cid ) in items if cid != checkpoint_id ]
226- items .insert (0 , (ts , checkpoint_id ))
227- if self .timeline_max is not None and len (items ) > self .timeline_max :
228- items = items [: self .timeline_max ]
229- self ._put (
230- timeline_key ,
231- {
232- "thread_id" : thread_id ,
233- "items" : json .dumps (items ),
234- },
235- )
235+ # `map_put` upserts atomically: re-`put()`ing the same
236+ # `checkpoint_id` overwrites in place, and concurrent `put()`s
237+ # against the same thread/ns can't clobber each other's entries.
238+ timeline_ops : list [dict [str , Any ]] = [
239+ operations .write ("thread_id" , thread_id ),
240+ map_operations .map_put ("timeline" , checkpoint_id , ts ),
241+ ]
242+ timeline_policy = self ._ttl_policy ()
243+ try :
244+ if timeline_policy is not None :
245+ self .client .operate (timeline_key , timeline_ops , policy = timeline_policy )
246+ else :
247+ self .client .operate (timeline_key , timeline_ops )
248+ except aerospike .exception .AerospikeError as e :
249+ raise RuntimeError (f"Aerospike operate failed for { timeline_key } : { e } " ) from e
236250
237251 cfg_conf : dict [str , Any ] = {** (config .get ("configurable" ) or {})}
238252 cfg_conf .update (
@@ -252,30 +266,31 @@ def put_writes(
252266 task_id : str ,
253267 task_path : str = "" ,
254268 ) -> None :
269+ """Persist pending writes for a checkpoint.
255270
271+ Each write is stored inside a Map bin (``writes``) keyed by
272+ ``f"{task_id}|{idx}"``, written via a single ``client.operate``
273+ call. ``map_put`` is server-atomic, giving us upsert-on-retry
274+ and tolerating concurrent callers against the same checkpoint.
275+
276+ The ``thread_id`` bin is rewritten on every call so that
277+ ``delete_thread``'s secondary-index query keeps finding the
278+ record; the value is the same every time for a given key.
279+ """
256280 if not writes :
257281 return
258282
259283 thread_id , checkpoint_ns , checkpoint_id = self ._ids_from_config (config )
260284 if not checkpoint_id :
261285 return
262286
263- key = self ._key_writes (
264- thread_id , checkpoint_ns , checkpoint_id
265- ) # Do we need to put task id as key. (Reason: Record limit 8Mb)
266-
267- existing_rec = self ._get (key )
268- existing_items : list [dict [str , Any ]] = []
269- if existing_rec is not None :
270- _ , _ , bins = existing_rec
271- existing_items = bins .get ("writes" ) or []
272-
287+ key = self ._key_writes (thread_id , checkpoint_ns , checkpoint_id )
273288 now_ts = _now_ns ().isoformat ()
274289
290+ ops : list [dict [str , Any ]] = [operations .write ("thread_id" , thread_id )]
275291 for idx , (channel , value ) in enumerate (writes ):
276292 idx_val = WRITES_IDX_MAP .get (channel , idx )
277293 type_ , serialized = self .serde .dumps_typed (value )
278-
279294 new_item = {
280295 "task_id" : task_id ,
281296 "task_path" : task_path ,
@@ -285,24 +300,17 @@ def put_writes(
285300 "value" : serialized ,
286301 "ts" : now_ts ,
287302 }
288- replace_at : int | None = None
289- for i , item in enumerate (existing_items ):
290- if item .get ("task_id" ) == task_id and item .get ("idx" ) == idx_val :
291- replace_at = i
292- break
293-
294- if replace_at is not None :
295- existing_items [replace_at ] = new_item
296- else :
297- existing_items .append (new_item )
303+ map_key = f"{ task_id } { SEP } { idx_val } "
304+ ops .append (map_operations .map_put ("writes" , map_key , new_item ))
298305
299- self ._put (
300- key ,
301- {
302- "thread_id" : thread_id ,
303- "writes" : existing_items ,
304- },
305- )
306+ policy = self ._ttl_policy ()
307+ try :
308+ if policy is not None :
309+ self .client .operate (key , ops , policy = policy )
310+ else :
311+ self .client .operate (key , ops )
312+ except aerospike .exception .AerospikeError as e :
313+ raise RuntimeError (f"Aerospike operate failed for { key } : { e } " ) from e
306314
307315 def get_tuple (
308316 self ,
@@ -346,8 +354,11 @@ def get_tuple(
346354 wrec = self ._get (self ._key_writes (thread_id , checkpoint_ns , checkpoint_id ))
347355 if wrec is not None :
348356 _ , _ , wbins = wrec
349- items = wbins .get ("writes" ) or []
350- for item in items :
357+ # `writes` is a Map bin (see `put_writes`); each value
358+ # carries its own `task_id`, `channel`, and `idx`, so we
359+ # don't depend on map iteration order.
360+ writes_map = wbins .get ("writes" ) or {}
361+ for item in writes_map .values ():
351362 try :
352363 task_id = item .get ("task_id" , "" )
353364 channel = item ["channel" ]
0 commit comments