Skip to content

Commit e4eddb2

Browse files
UN-3479 [FIX] Address review: prefer success over cached failure; guard None group listing
- _cached: a raced _FETCH_FAILED no longer shadows a peer's successful listing (would silently no-op all share replication for the run). - list_groups: 'or {}' guard mirrors list_users so a None response can't raise AttributeError. - Add regression tests for both race directions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bfc87a3 commit e4eddb2

3 files changed

Lines changed: 35 additions & 5 deletions

File tree

src/unstract/clone/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ def list_users(self) -> list[dict[str, Any]]:
123123
def list_groups(self) -> list[dict[str, Any]]:
124124
"""List org groups; no server-side name filter — callers match in memory."""
125125
result = self._request("GET", "groups/")
126-
return result if isinstance(result, list) else result.get("results", [])
126+
return result if isinstance(result, list) else (result or {}).get("results", [])
127127

128128
def create_group(self, payload: dict[str, Any]) -> dict[str, Any]:
129129
"""Create a group; response has no ``id`` — re-list to learn the pk."""

src/unstract/clone/sharing.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,22 @@ def _cached(ctx: CloneContext, key: str, build: Callable[[], Any]) -> Any:
4646
with ctx.share_cache_lock:
4747
if key in ctx.share_cache:
4848
return ctx.share_cache[key]
49-
# Build outside the lock (HTTP call); worst case two threads race and
50-
# one result wins — the listings are read-only so that's harmless.
49+
# Build outside the lock (HTTP call). Two threads may race; the merge
50+
# below keeps a real result over a failure sentinel (listings are
51+
# read-only, so two successes are interchangeable).
5152
try:
5253
value = build()
5354
except Exception as e:
5455
logger.warning("share replication: %s listing failed: %s", key, e)
5556
value = _FETCH_FAILED
5657
with ctx.share_cache_lock:
57-
ctx.share_cache.setdefault(key, value)
58+
# Prefer a real result if we raced: a cached failure must not shadow a
59+
# peer's success, else share replication silently no-ops for the run.
60+
cached = ctx.share_cache.get(key, _FETCH_FAILED)
61+
if key not in ctx.share_cache or (
62+
cached is _FETCH_FAILED and value is not _FETCH_FAILED
63+
):
64+
ctx.share_cache[key] = value
5865
return ctx.share_cache[key]
5966

6067

tests/clone/test_sharing.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from unstract.clone.context import CloneContext, CloneOptions, RemapTable
1414
from unstract.clone.phases.base import build_post_payload
1515
from unstract.clone.report import PhaseResult
16-
from unstract.clone.sharing import apply_share_state
16+
from unstract.clone.sharing import _FETCH_FAILED, _cached, apply_share_state
1717

1818

1919
class FakeClient:
@@ -208,6 +208,29 @@ def test_share_users_listing_caches_across_resources():
208208
assert len(tgt_client.share_posts) == 2
209209

210210

211+
def test_cached_failure_does_not_shadow_a_raced_success():
212+
# A peer thread committing _FETCH_FAILED mid-build must not shadow our
213+
# success, else share replication silently no-ops for the rest of the run.
214+
ctx = _ctx(FakeClient(), FakeClient())
215+
216+
def build_while_peer_fails():
217+
ctx.share_cache["k"] = _FETCH_FAILED # racing thread commits first
218+
return {"ok": 1}
219+
220+
assert _cached(ctx, "k", build_while_peer_fails) == {"ok": 1}
221+
assert ctx.share_cache["k"] == {"ok": 1}
222+
223+
224+
def test_cached_success_is_not_overwritten_by_a_raced_failure():
225+
ctx = _ctx(FakeClient(), FakeClient())
226+
227+
def build_fails_while_peer_succeeds():
228+
ctx.share_cache["k"] = {"ok": 1} # racing thread commits success first
229+
raise RuntimeError("down")
230+
231+
assert _cached(ctx, "k", build_fails_while_peer_succeeds) == {"ok": 1}
232+
233+
211234
def test_share_post_failure_lands_in_errors():
212235
src_client = FakeClient(users=[{"id": "7", "email": "alice@x.com"}])
213236
tgt_client = FakeClient(users=[{"id": "70", "email": "alice@x.com"}])

0 commit comments

Comments
 (0)