Skip to content

Commit 9c3ac8a

Browse files
committed
test(store): exercise the actual nudge SELECT-vs-UPDATE race (Greptile)
The prior test only validated sequential delivery -- the old WHERE delivered=0 sweep passed it too. New test commits a competing nudge from a separate connection in the window between the SELECT and the UPDATE (via a sync execute wrapper that preserves aiosqlite's await/async-with duality). It fails against the old sweep (n2 marked delivered and lost) and passes with the by-id UPDATE.
1 parent 9f785bb commit 9c3ac8a

1 file changed

Lines changed: 56 additions & 0 deletions

File tree

tests/memory/test_store.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,62 @@ async def test_get_pending_nudges_only_marks_returned(self, tmp_path: Path) -> N
243243
assert await store.get_pending_nudges("a1") == ["third"]
244244
assert await store.get_pending_nudges("a1") == []
245245

246+
@pytest.mark.asyncio
247+
async def test_get_pending_nudges_survives_concurrent_insert(self, tmp_path: Path) -> None:
248+
"""A nudge committed by another connection *between* the SELECT and the
249+
UPDATE must not be marked delivered. This is the actual race the by-id
250+
UPDATE fixes -- the old `WHERE delivered = 0` sweep would have lost it."""
251+
from contextlib import asynccontextmanager
252+
253+
db_path = tmp_path / "state.db"
254+
store = HiveStore(db_path)
255+
await store.initialize()
256+
await store.save_nudge("n1", "a1", "first")
257+
258+
real_connect = store._connect
259+
state = {"injected": False}
260+
261+
@asynccontextmanager
262+
async def connect_with_injection(foreign_keys: bool = False): # type: ignore[no-untyped-def]
263+
async with real_connect(foreign_keys=foreign_keys) as db:
264+
real_execute = db.execute
265+
266+
# Sync wrapper preserves aiosqlite's `await`/`async with` duality.
267+
# Just before the UPDATE runs (rows already SELECTed), a *separate*
268+
# connection commits a new nudge -- the exact race window where the
269+
# old `WHERE delivered = 0` sweep would have marked it delivered.
270+
def execute(*args, **kwargs): # type: ignore[no-untyped-def]
271+
sql = args[0] if args else kwargs.get("sql", "")
272+
if (
273+
not state["injected"]
274+
and sql.lstrip().upper().startswith("UPDATE")
275+
and "nudges" in sql
276+
):
277+
state["injected"] = True
278+
279+
async def _inject_then_update(): # type: ignore[no-untyped-def]
280+
async with aiosqlite.connect(db_path) as other:
281+
await other.execute(
282+
"INSERT INTO nudges (nudge_id, agent_id, message, "
283+
"delivered, created_at) VALUES ('n2','a1','second',0,'t')"
284+
)
285+
await other.commit()
286+
return await real_execute(*args, **kwargs)
287+
288+
return _inject_then_update()
289+
return real_execute(*args, **kwargs)
290+
291+
db.execute = execute # type: ignore[method-assign]
292+
yield db
293+
294+
store._connect = connect_with_injection # type: ignore[assignment,method-assign]
295+
returned = await store.get_pending_nudges("a1")
296+
store._connect = real_connect # type: ignore[method-assign]
297+
298+
assert returned == ["first"]
299+
# n2 raced in mid-call; it must survive as still-pending, not be lost.
300+
assert await store.get_pending_nudges("a1") == ["second"]
301+
246302

247303
class TestCascades:
248304
@pytest.mark.asyncio

0 commit comments

Comments
 (0)