Skip to content

Commit 158a3b7

Browse files
committed
ci-parity round 11: redis WATCH/MULTI atomic admission + project_peer NC in-memory migration + abort_toolonly ci_serial
1 parent de81786 commit 158a3b7

3 files changed

Lines changed: 69 additions & 69 deletions

File tree

lib/runtime_state_store.py

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -288,23 +288,29 @@ def _zcap_k(self, kind: str, count_prefix: str) -> str:
288288
def acquire_slot(self, kind: str, slot_key: str, limit: int, ttl: float,
289289
count_prefix: str) -> bool:
290290
"""Bounded acquire via a Redis SORTED SET (score = expiry deadline) —
291-
the single source of truth for the cap. Uses only ``ZADD`` / ``ZRANK``
292-
/ ``ZREM`` / ``ZREMRANGEBYSCORE`` / ``ZCOUNT`` — all guaranteed by
293-
fakeredis AND managed/cluster Redis (NOT Lua EVAL / SCAN-in-Lua).
294-
295-
Algorithm (lock-free, total-order rank — correct under BOTH sequential
296-
admits AND a concurrent burst):
291+
the single source of truth for the cap. Uses only WATCH/MULTI/EXEC +
292+
``ZADD`` / ``ZSCORE`` / ``ZCOUNT`` / ``ZREMRANGEBYSCORE`` — guaranteed
293+
by fakeredis AND managed/cluster Redis (NO Lua EVAL / SCAN-in-Lua).
294+
295+
Algorithm (optimistic-transaction admission — WATCH on the cap key,
296+
count-check and claim commit ATOMICALLY via MULTI/EXEC; a concurrent
297+
writer aborts the txn with WatchError and we retry against the fresh
298+
count):
297299
1. ``ZREMRANGEBYSCORE cap 0 now`` — evict members whose expiry
298300
deadline passed (crash-reclaim, no per-member TTL needed).
299-
2. ``ZADD cap {slot: now+ttl}`` — insert/refresh our member with its
300-
deadline as score. Re-acquiring an existing member just updates
301-
its score (no double-count).
302-
3. ``ZRANK cap slot`` — our position in the score-ordered set. The
303-
ZSET gives ALL racers the SAME total order, so they independently
304-
agree on the lowest-``limit`` winners. Keep iff ``rank < limit``;
305-
otherwise ``ZREM`` our OWN member and refuse. This admits EXACTLY
306-
``limit`` under a burst (no livelock, no overshoot) and is stable
307-
sequentially (an over-cap claim is rolled back).
301+
2. ``ZSCORE`` hit = a live re-acquire → refresh in one txn (never
302+
a second count).
303+
3. ``ZCOUNT`` of live members ≥ limit → refuse WITHOUT inserting;
304+
else ZADD+EXPIRE in the same txn. Because the check and the
305+
insert commit atomically, two racers can never both observe
306+
``count == limit-1`` and both admit.
307+
308+
Why not ZADD-then-ZRANK (the previous design): the score was captured
309+
from the wall clock BEFORE the insert, so a racer descheduled between
310+
capture and ZADD could land an EARLIER-scored member after another
311+
racer's rank check — both then passed the gate. Measured 11 admits on
312+
a 10-cap (CI 3.12 leg, de81786): a starved box widens the
313+
capture→insert gap until the overshoot is deterministic.
308314
309315
Consistency: ``count`` is ``ZCOUNT`` over the SAME set, so the
310316
admission gate and the reported count can never drift. A crash leaves
@@ -318,16 +324,36 @@ def acquire_slot(self, kind: str, slot_key: str, limit: int, ttl: float,
318324
return True # fail-open
319325
zk = self._zcap_k(kind, count_prefix)
320326
ttl_i = max(1, int(ttl))
321-
now = time.time()
322327
try:
323-
r.zremrangebyscore(zk, 0, now) # 1. evict expired members
324-
r.zadd(zk, {slot_key: now + ttl_i}) # 2. claim/refresh
325-
r.expire(zk, ttl_i * 2) # whole-key idle backstop
326-
rank = r.zrank(zk, slot_key) # 3. total-order position
327-
if rank is not None and rank < limit:
328-
return True
329-
r.zrem(zk, slot_key) # over capacity → self-evict
330-
return False
328+
from redis.exceptions import WatchError
329+
for _attempt in range(64):
330+
now = time.time()
331+
try:
332+
with r.pipeline() as p:
333+
p.watch(zk)
334+
p.zremrangebyscore(zk, 0, now) # evict expired members
335+
if p.zscore(zk, slot_key) is not None:
336+
# Live re-acquire → refresh only, never a count.
337+
p.multi()
338+
p.zadd(zk, {slot_key: now + ttl_i})
339+
p.expire(zk, ttl_i * 2)
340+
p.execute()
341+
return True
342+
if p.zcount(zk, now, '+inf') >= limit:
343+
p.unwatch()
344+
return False
345+
p.multi()
346+
p.zadd(zk, {slot_key: now + ttl_i})
347+
p.expire(zk, ttl_i * 2) # whole-key idle backstop
348+
p.execute()
349+
return True
350+
except WatchError:
351+
continue # a concurrent racer touched the set — re-read
352+
# Far past any real burst; fail-open like a backend error rather
353+
# than refuse a legitimate caller forever.
354+
logger.warning('[RuntimeStateStore] acquire_slot watch retries '
355+
'exhausted for %s — fail-open', slot_key)
356+
return True
331357
except Exception as e:
332358
logger.warning('[RuntimeStateStore] acquire_slot failed (%s) — '
333359
'fail-open', e)

tests/test_abort_toolonly_rounds_persist.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,11 @@
7070

7171
import pytest
7272

73-
pytestmark = pytest.mark.unit
73+
# ci_serial: real create_task + persist_task_result write through the shared
74+
# sqlite pool; under the CI parallel lane's contention the seed write exceeded
75+
# the 30s busy timeout ('database is locked', de81786 3.12 leg) while passing
76+
# in seconds uncontended.
77+
pytestmark = [pytest.mark.unit, pytest.mark.ci_serial]
7478

7579

7680
def _seed_conv(conv_id):

tests/test_project_peer.py

Lines changed: 14 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737

3838
import pytest
3939

40+
from tests._nc_harness import patch_restore as _patch_restore
41+
4042
pytestmark = pytest.mark.unit
4143

4244
HERE = os.path.dirname(os.path.abspath(__file__))
@@ -548,13 +550,12 @@ def test_NC_dispatch_drops_peer_markers(monkeypatch):
548550
"""NC-OBSERVE: no-op the marker-propagation block in dispatch_next_queued →
549551
the persisted turn loses its peer markers → the observability test FAILS
550552
(the arrival becomes indistinguishable from user input)."""
551-
import importlib
552-
553553
captured = {}
554554

555555
def run():
556+
# The harness already swapped the NEUTERED module into sys.modules —
557+
# this import resolves to it (no reload; a reload would un-neuter).
556558
import lib.message_queue as mq
557-
importlib.reload(mq)
558559
monkeypatch.setattr(mq, 'dequeue_next', lambda c: {
559560
'queueId': 'q1', 'config': {},
560561
'payload': {'text': 'hi', '_peerMessage': True, '_fromConv': 'cS'}})
@@ -578,41 +579,24 @@ def run():
578579
" pass # NC-OBSERVE (marker propagation disabled)",
579580
run,
580581
)
581-
import lib.message_queue as mq
582-
importlib.reload(mq)
583582

584583

585584
# ════════════════════════════════════════════════════════════════════
586-
# Source-level NEGATIVE CONTROLS (byte-reverting)
585+
# Source-level NEGATIVE CONTROLS (in-memory harness: the neutered module is
586+
# compiled into a throwaway sys.modules entry — the shipped file is READ-ONLY)
587587
# ════════════════════════════════════════════════════════════════════
588588

589-
def _patch_restore(path, old, new, run):
590-
with open(path, encoding='utf-8') as f:
591-
original = f.read()
592-
assert old in original, f'anchor not found in {path}'
593-
try:
594-
with open(path, 'w', encoding='utf-8') as f:
595-
f.write(original.replace(old, new, 1))
596-
run()
597-
finally:
598-
with open(path, 'w', encoding='utf-8') as f:
599-
f.write(original)
600-
with open(path, encoding='utf-8') as f:
601-
assert f.read() == original, 'source not restored byte-identical'
602-
603-
604589
def test_NC_storm_guard_noop_breaks_rate_limit(_stub_io):
605590
"""NC-STORM: disable the rate cap in _prune_and_check → the 4th message is
606591
no longer refused → the storm guard test FAILS."""
607-
import importlib
608-
609592
def run():
593+
# The harness already swapped the NEUTERED module into sys.modules —
594+
# this import resolves to it (no reload; a reload would un-neuter).
610595
import lib.conversations.project_peer as pp
611-
importlib.reload(pp)
612-
# Reload re-binds _resolve_target_conv_id to the real (DB-reading) fn;
613-
# re-stub to identity so this DB-free NC uses synthetic ids.
596+
# The neutered module binds the real (DB-reading) resolver; re-stub to
597+
# identity on the swapped module so this DB-free NC uses synthetic ids.
614598
pp._resolve_target_conv_id = lambda t: ((t or '').strip(), '')
615-
# Re-stub via module attrs the reloaded code reads at call time.
599+
# Re-stub via module attrs the neutered code reads at call time.
616600
for i in range(3):
617601
pp.send_peer_message('/p', 'cA', 'cB', f'm{i}')
618602
blocked = pp.send_peer_message('/p', 'cA', 'cB', 'm4')
@@ -628,24 +612,20 @@ def run():
628612
" return True, kept + [now], 0.0 # NC-STORM (rate cap disabled)",
629613
run,
630614
)
631-
import lib.conversations.project_peer as pp
632-
importlib.reload(pp)
633615

634616

635617
def test_NC_audit_gate_noop_allows_unapproved_abort(monkeypatch):
636618
"""NC-GATE: no-op the approval check in _authorize_hard_abort → an
637619
unapproved hard abort now proceeds → the gate test FAILS."""
638-
import importlib
639-
640620
aborted = []
641621
monkeypatch.setattr('lib.tasks_pkg.manager.abort_running_tasks_for_conv',
642622
lambda c, **k: aborted.append(c) or 1)
643623
monkeypatch.setattr('lib.conversations.project_feed.emit_project_event',
644624
lambda *a, **k: None)
645625

646626
def run():
627+
# Harness already swapped the NEUTERED module into sys.modules.
647628
import lib.conversations.project_peer as pp
648-
importlib.reload(pp)
649629
pp._resolve_target_conv_id = lambda t: ((t or '').strip(), '')
650630
monkeypatch.setattr('lib.conversations.project_peer.audit_log',
651631
lambda *a, **k: None)
@@ -662,25 +642,21 @@ def run():
662642
" return True, 'approved' # NC-GATE (approval check disabled)",
663643
run,
664644
)
665-
import lib.conversations.project_peer as pp
666-
importlib.reload(pp)
667645

668646

669647
def test_NC_deny_branch_noop_runs_abort_despite_denial(_stub_io, monkeypatch):
670648
"""NC-DENY: no-op the deny branch (treat a falsy approval as approved) → a
671649
DENIED hard abort now runs the abort anyway → the deny-path test FAILS.
672650
This proves the deny branch is what actually stops an unapproved kill."""
673-
import importlib
674-
675651
aborted = []
676652
monkeypatch.setattr('lib.tasks_pkg.manager.abort_running_tasks_for_conv',
677653
lambda c, **k: aborted.append(c) or 1)
678654
monkeypatch.setattr('lib.conversations.project_feed.emit_project_event',
679655
lambda *a, **k: None)
680656

681657
def run():
658+
# Harness already swapped the NEUTERED module into sys.modules.
682659
import lib.conversations.project_peer as pp
683-
importlib.reload(pp)
684660
pp._resolve_target_conv_id = lambda t: ((t or '').strip(), '')
685661
monkeypatch.setattr('lib.conversations.project_peer.audit_log',
686662
lambda *a, **k: None)
@@ -698,18 +674,14 @@ def run():
698674
" approved_by = str(approver).strip() if approver else 'nc-deny-forced' # NC-DENY",
699675
run,
700676
)
701-
import lib.conversations.project_peer as pp
702-
importlib.reload(pp)
703677

704678

705679
def test_NC_join_exclude_noop_leaks_self():
706680
"""NC-JOIN: no-op the exclude_conv filter → a conversation sees ITSELF in
707681
its own peer list → the self-exclusion test FAILS."""
708-
import importlib
709-
710682
def run():
683+
# Harness already swapped the NEUTERED module into sys.modules.
711684
import lib.conversations.project_peer as pp
712-
importlib.reload(pp)
713685
view = pp._join_peers(_peers(), {}, {}, exclude_conv='cA')
714686
# With the exclusion disabled, cA leaks into its own peer view.
715687
assert any(v['convId'] == 'cA' for v in view), \
@@ -721,5 +693,3 @@ def run():
721693
" if not conv_id:\n continue # NC-JOIN (self-exclude disabled)",
722694
run,
723695
)
724-
import lib.conversations.project_peer as pp
725-
importlib.reload(pp)

0 commit comments

Comments
 (0)