Skip to content

Commit 170d614

Browse files
authored
fix(checkpointer): serialise concurrent manual_checkpoint() flushes (#80) (#96)
Two asyncio tasks calling manual_checkpoint() against the same BaseHeartbeatCheckPointer could both iterate the buffer snapshot and double-write the same shard. On a last-writer-wins backend (Redis GETSET), an older sequence could clobber a newer one. Without the lock the second flusher could also KeyError on a shard the first had popped mid-iteration. Add asyncio.Lock around the manual_checkpoint() body so concurrent callers serialise. Single-task callers see no behaviour change. Document the full concurrency contract on the class so callers know which methods are safe across tasks (only manual_checkpoint is internally synchronised; checkpoint with auto_checkpoint=True, allocate, deallocate, and close all need single-task drivers or external coordination). Hazard 3 from #80 (heartbeat dict-during-iteration) was closed separately by #92 (issue #81 papercut bundle).
1 parent f82e509 commit 170d614

2 files changed

Lines changed: 117 additions & 12 deletions

File tree

kinesis/checkpointers.py

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,38 @@ def is_allocated(self, shard_id):
172172

173173

174174
class BaseHeartbeatCheckPointer(BaseCheckPointer):
175+
"""Heartbeat-driven base for backend checkpointers (Redis, DynamoDB).
176+
177+
Concurrency contract:
178+
179+
* ``manual_checkpoint`` is the only method with explicit internal
180+
serialisation. Concurrent callers from different tasks are guaranteed
181+
not to double-flush the same buffered shard: the second caller waits
182+
on ``_manual_flush_lock``, then iterates whatever the first left in
183+
the buffer (failed shards retry, succeeded shards are gone). The lock
184+
is held across backend writes, so a slow backend stalls concurrent
185+
flushes.
186+
* ``checkpoint`` with ``auto_checkpoint=False`` is a single dict
187+
assignment to ``_manual_checkpoints`` and is safe to call from any
188+
task. With ``auto_checkpoint=True`` it awaits ``_checkpoint`` directly
189+
and offers no per-shard ordering guarantee against another concurrent
190+
``checkpoint`` for the same shard; on a last-writer-wins backend
191+
(Redis ``GETSET``) two concurrent writers can race. The Consumer
192+
drives this from a single task by design; callers building their own
193+
driver must serialise per-shard themselves.
194+
* ``allocate`` / ``deallocate`` perform backend writes and mutate
195+
``_items``. Concurrent calls for the *same* shard are not synchronised
196+
and would race the backend lease/release; calls for *different* shards
197+
are independent. The Consumer's shard manager invokes these from a
198+
single task.
199+
* ``heartbeat`` is owned by this class. The task is started in
200+
``__init__`` and torn down in ``close()``; do not invoke it directly.
201+
* ``close`` should be called from a single task. Running it concurrently
202+
with itself or with ``manual_checkpoint`` is undefined: ``close`` does
203+
not take ``_manual_flush_lock`` and may deallocate a shard that an
204+
in-flight flush is still writing.
205+
"""
206+
175207
def __init__(
176208
self,
177209
name,
@@ -186,6 +218,7 @@ def __init__(
186218
self.heartbeat_frequency = heartbeat_frequency
187219
self.auto_checkpoint = auto_checkpoint
188220
self._manual_checkpoints: Dict[str, str] = {}
221+
self._manual_flush_lock: asyncio.Lock = asyncio.Lock()
189222

190223
self.heartbeat_task = asyncio.Task(self.heartbeat())
191224

@@ -244,6 +277,12 @@ async def manual_checkpoint(self) -> None:
244277
so a concurrent ``checkpoint()`` that buffered a newer sequence for
245278
the same shard mid-flight is not silently dropped.
246279
280+
Concurrent callers serialise via ``_manual_flush_lock``: two tasks
281+
calling ``manual_checkpoint()`` cannot both flush the same buffered
282+
shard, which would otherwise risk an older sequence clobbering a
283+
newer one on a last-writer-wins backend. See the class-level
284+
thread-safety contract for the full picture.
285+
247286
Raises:
248287
CheckpointFlushError: if one or more shards raised. The compound
249288
exception carries the per-shard failures in ``.errors``.
@@ -252,18 +291,19 @@ async def manual_checkpoint(self) -> None:
252291
callers should monitor that metric for alerting rather than
253292
parsing the exception.
254293
"""
255-
errors: List[Tuple[str, BaseException]] = []
256-
for shard_id in list(self._manual_checkpoints):
257-
sequence = self._manual_checkpoints[shard_id]
258-
try:
259-
await self._checkpoint(shard_id, sequence)
260-
except Exception as exc:
261-
errors.append((shard_id, exc))
262-
continue
263-
if self._manual_checkpoints.get(shard_id) == sequence:
264-
self._manual_checkpoints.pop(shard_id, None)
265-
if errors:
266-
raise CheckpointFlushError(errors) from errors[0][1]
294+
async with self._manual_flush_lock:
295+
errors: List[Tuple[str, BaseException]] = []
296+
for shard_id in list(self._manual_checkpoints):
297+
sequence = self._manual_checkpoints[shard_id]
298+
try:
299+
await self._checkpoint(shard_id, sequence)
300+
except Exception as exc:
301+
errors.append((shard_id, exc))
302+
continue
303+
if self._manual_checkpoints.get(shard_id) == sequence:
304+
self._manual_checkpoints.pop(shard_id, None)
305+
if errors:
306+
raise CheckpointFlushError(errors) from errors[0][1]
267307

268308

269309
class MemoryCheckPointer(BaseCheckPointer):

tests/test_checkpoint_base_contract.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,71 @@ async def inject_newer(shard_id: str, sequence: str) -> None:
186186
assert cp.writes == {"shard-0": "seq-1"}
187187

188188

189+
class TestManualCheckpointConcurrency:
190+
"""Issue #80 hazard 2: two tasks calling ``manual_checkpoint()`` must not
191+
double-flush the same buffered shard. The base-class lock around the
192+
flush body serialises them; only the first flusher writes any given
193+
shard's buffered sequence, the second observes a drained buffer."""
194+
195+
@pytest.mark.asyncio
196+
async def test_concurrent_flushes_serialise_per_shard(self, cp):
197+
"""Two ``manual_checkpoint()`` coroutines see each shard written
198+
exactly once across the pair, in a deterministic order pinned by an
199+
``asyncio.Event`` injected mid-flush.
200+
201+
Without the lock both flushers iterate the same buffer snapshot and
202+
each calls ``_checkpoint(shard, seq)`` once, producing two writes
203+
per shard. With the lock the second flusher waits, sees the
204+
shard already popped, and writes nothing.
205+
"""
206+
207+
await cp.checkpoint("shard-0", "seq-1")
208+
await cp.checkpoint("shard-1", "seq-2")
209+
210+
write_log: list = []
211+
gate = asyncio.Event()
212+
first_in_flight = asyncio.Event()
213+
214+
async def gated_checkpoint(shard_id: str, sequence: str) -> None:
215+
write_log.append((shard_id, sequence))
216+
cp.writes[shard_id] = sequence
217+
cp._items[shard_id] = sequence
218+
if not first_in_flight.is_set():
219+
first_in_flight.set()
220+
await gate.wait()
221+
cp._emit_checkpoint_success(shard_id)
222+
223+
cp._checkpoint = gated_checkpoint # type: ignore[method-assign]
224+
225+
flush_a = asyncio.create_task(cp.manual_checkpoint())
226+
await first_in_flight.wait()
227+
flush_b = asyncio.create_task(cp.manual_checkpoint())
228+
# Yield enough times for flush_b to reach the lock and block on it.
229+
for _ in range(5):
230+
await asyncio.sleep(0)
231+
gate.set()
232+
await asyncio.gather(flush_a, flush_b)
233+
234+
assert sorted(write_log) == [("shard-0", "seq-1"), ("shard-1", "seq-2")]
235+
assert cp._manual_checkpoints == {}
236+
assert cp.writes == {"shard-0": "seq-1", "shard-1": "seq-2"}
237+
238+
@pytest.mark.asyncio
239+
async def test_second_flusher_sees_drained_buffer(self, cp):
240+
"""A second ``manual_checkpoint()`` arriving after the first drains
241+
the buffer must return cleanly: empty buffer is a no-op, not an
242+
error."""
243+
244+
await cp.checkpoint("shard-0", "seq-1")
245+
246+
await cp.manual_checkpoint()
247+
# Second call against an already-drained buffer.
248+
await cp.manual_checkpoint()
249+
250+
assert cp.writes == {"shard-0": "seq-1"}
251+
assert cp._manual_checkpoints == {}
252+
253+
189254
class TestHeartbeatRobustness:
190255
"""Issue #81: heartbeat iteration and close() must survive concurrent
191256
mutation / task death so shards don't leak."""

0 commit comments

Comments
 (0)