Skip to content

Commit fd7220a

Browse files
authored
Merge pull request #41 from aerospike/dev
feat: [AIE-3] Use atomic operations instead of read-modify-write
2 parents 837eb15 + 3f211a5 commit fd7220a

5 files changed

Lines changed: 239 additions & 208 deletions

File tree

.github/workflows/upgrade-lock.yml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,6 @@ jobs:
5656
add-paths: uv.lock
5757
commit-message: "chore: refresh uv.lock via weekly upgrade"
5858
title: "chore: weekly uv lockfile upgrade"
59-
# `main` requires signed commits (see setup-github-protection.sh).
60-
# `sign-commits: true` makes the action create the commit via the
61-
# GitHub Contents API, which signs it with GitHub's web-flow GPG
62-
# key so it shows as "Verified" and satisfies the rule.
6359
sign-commits: true
6460
body: |
6561
Automated weekly run of `uv lock --upgrade`.

packages/langgraph-checkpoint-aerospike/langgraph/checkpoint/aerospike/saver.py

Lines changed: 80 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
import asyncio
44
import builtins
55
import contextlib
6-
import json
76
from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
87
from datetime import datetime, timezone
98
from typing import Any, cast
109

10+
from aerospike_helpers.operations import map_operations, operations
1111
from langchain_core.runnables import RunnableConfig
1212
from 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"]
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Concurrency tests for ``AerospikeSaver``.
2+
3+
The upstream conformance suite is single-threaded and the integration
4+
tests won't reliably surface contention bugs, so we cover three
5+
properties directly here:
6+
7+
* ``put_writes`` from N concurrent callers against the same checkpoint
8+
produces N distinct pending writes (no lost updates).
9+
* ``put_writes`` retries of the same ``(task_id, idx)`` upsert in place
10+
rather than appending duplicates.
11+
* ``put()`` from N concurrent callers against the same thread/ns
12+
produces N distinct timeline entries.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import threading
18+
from concurrent.futures import ThreadPoolExecutor
19+
20+
from langchain_core.runnables import RunnableConfig
21+
from langgraph.checkpoint.base import empty_checkpoint
22+
23+
24+
def _seed_checkpoint(saver, thread_id: str, checkpoint_ns: str = "") -> RunnableConfig:
25+
"""Write an empty checkpoint so subsequent ``put_writes`` has a target."""
26+
base_config: RunnableConfig = {
27+
"configurable": {
28+
"thread_id": thread_id,
29+
"checkpoint_ns": checkpoint_ns,
30+
}
31+
}
32+
cp = empty_checkpoint()
33+
metadata = {"source": "input", "step": 0, "writes": {}, "parents": {}}
34+
new_config = saver.put(base_config, cp, metadata, {}) # type: ignore[arg-type]
35+
return new_config
36+
37+
38+
def test_put_writes_concurrent_no_lost_updates(saver) -> None:
39+
"""N parallel ``put_writes`` calls must each be visible in pending_writes."""
40+
cp_config = _seed_checkpoint(saver, thread_id="concurrent_writers")
41+
42+
n = 32
43+
barrier = threading.Barrier(n)
44+
45+
def worker(i: int) -> None:
46+
# Synchronize at the barrier to maximize contention.
47+
barrier.wait()
48+
saver.put_writes(
49+
config=cp_config,
50+
writes=[(f"channel_{i}", f"value_{i}")],
51+
task_id=f"task_{i}",
52+
)
53+
54+
with ThreadPoolExecutor(max_workers=n) as ex:
55+
list(ex.map(worker, range(n)))
56+
57+
tpl = saver.get_tuple(cp_config)
58+
assert tpl is not None
59+
assert len(tpl.pending_writes) == n
60+
seen_task_ids = {task_id for (task_id, _channel, _value) in tpl.pending_writes}
61+
assert seen_task_ids == {f"task_{i}" for i in range(n)}
62+
63+
64+
def test_put_writes_retry_overwrites_in_place(saver) -> None:
65+
"""A retry of the same ``(task_id, idx)`` must upsert, not append."""
66+
cp_config = _seed_checkpoint(saver, thread_id="retry_writer")
67+
68+
task_id = "retrying_task"
69+
70+
saver.put_writes(
71+
config=cp_config,
72+
writes=[("ch", "v1")],
73+
task_id=task_id,
74+
)
75+
saver.put_writes(
76+
config=cp_config,
77+
writes=[("ch", "v2")],
78+
task_id=task_id,
79+
)
80+
saver.put_writes(
81+
config=cp_config,
82+
writes=[("ch", "v3")],
83+
task_id=task_id,
84+
)
85+
86+
tpl = saver.get_tuple(cp_config)
87+
assert tpl is not None
88+
89+
matching = [w for w in tpl.pending_writes if w[0] == task_id]
90+
assert len(matching) == 1
91+
assert matching[0][1] == "ch"
92+
assert matching[0][2] == "v3"
93+
94+
95+
def test_put_concurrent_no_lost_timeline_entries(saver) -> None:
96+
"""N parallel ``put()`` calls must each leave a timeline entry."""
97+
base_config: RunnableConfig = {
98+
"configurable": {
99+
"thread_id": "concurrent_timeline",
100+
"checkpoint_ns": "",
101+
}
102+
}
103+
104+
n = 16
105+
barrier = threading.Barrier(n)
106+
107+
def worker(i: int) -> None:
108+
cp = empty_checkpoint()
109+
cp["id"] = f"cp_{i:04d}"
110+
cp["ts"] = f"2026-04-29T17:00:{i:02d}+00:00"
111+
metadata = {"source": "input", "step": i, "writes": {}, "parents": {}}
112+
barrier.wait()
113+
saver.put(base_config, cp, metadata, {}) # type: ignore[arg-type]
114+
115+
with ThreadPoolExecutor(max_workers=n) as ex:
116+
list(ex.map(worker, range(n)))
117+
118+
timeline = list(saver.list(base_config))
119+
assert len(timeline) == n
120+
seen_ids = {tpl.config["configurable"]["checkpoint_id"] for tpl in timeline}
121+
assert seen_ids == {f"cp_{i:04d}" for i in range(n)}

0 commit comments

Comments
 (0)