Skip to content

Commit 98478ab

Browse files
committed
fix(databricks): mark refresh sentinel on attempt + widen lock timeout (review #190)
1 parent 1c18392 commit 98478ab

2 files changed

Lines changed: 52 additions & 4 deletions

File tree

src/ucode/databricks.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,13 @@
7070
# refresh token is rotating, so redeeming it twice in quick succession from
7171
# concurrent `ucode opencode` sessions can revoke the token family (#190).
7272
TOKEN_REFRESH_COALESCE_SECONDS = 60
73-
_LOCK_ACQUIRE_TIMEOUT_SECONDS = 15
73+
# Must exceed the `databricks auth token` subprocess timeout (`timeout=15` at
74+
# the `run(...)` call in the `_fetch` closure below), so a live peer that is
75+
# still inside its own (up to 15s) --force-refresh subprocess is guaranteed
76+
# to finish and touch the sentinel before any waiter gives up on the lock and
77+
# proceeds unlocked (#190: an unlocked waiter racing a still-refreshing peer
78+
# can redeem the rotating refresh token twice).
79+
_LOCK_ACQUIRE_TIMEOUT_SECONDS = 30
7480
_LOCK_POLL_INTERVAL_SECONDS = 0.2
7581

7682

@@ -859,6 +865,8 @@ def _refresh_lock(lock_path: Path):
859865
break
860866
except OSError:
861867
time.sleep(_LOCK_POLL_INTERVAL_SECONDS)
868+
if not acquired:
869+
_debug("refresh_lock", "acquire timeout, proceeding without lock")
862870
_debug("refresh_lock", f"acquired={acquired} path={lock_path}")
863871
yield acquired
864872
finally:
@@ -883,7 +891,23 @@ def _perform_force_refresh(
883891
refresh token concurrently. If a peer already force-refreshed within
884892
`TOKEN_REFRESH_COALESCE_SECONDS`, drops `--force-refresh` from `cmd` (in
885893
place, so retries via the same `fetch` closure stay coalesced too) and
886-
just fetches the now-current cached token instead."""
894+
just fetches the now-current cached token instead.
895+
896+
The sentinel is touched on ATTEMPT, not on success: Databricks rotates
897+
the OAuth refresh token when the `--force-refresh` redemption reaches the
898+
server, not when we successfully parse a token out of our subprocess's
899+
stdout. If the redemption subprocess actually rotated the token
900+
server-side but returned a non-zero exit code or unparseable stdout
901+
(both of which `_fetch` swallows into `""`), failing to touch the
902+
sentinel would let a concurrent peer redeem the same (now-stale)
903+
refresh token again and revoke the whole token family (#190). Marking on
904+
attempt instead means we only risk occasionally serving a slightly-stale
905+
cached token to a coalesced peer — strictly safer than a double
906+
redemption — and any transient failure that gets coalesced away here
907+
will simply be retried after the `TOKEN_REFRESH_COALESCE_SECONDS` window
908+
elapses. Only touch it here, in the branch that actually sent
909+
`--force-refresh`; the coalesced branch above reads the cached token and
910+
performs no redemption, so it must never touch the sentinel."""
887911
lock_path, sentinel_path = _refresh_lock_paths(workspace, profile)
888912
with _refresh_lock(lock_path) as locked:
889913
_debug(
@@ -902,8 +926,7 @@ def _perform_force_refresh(
902926

903927
_debug("get_databricks_token", "performing --force-refresh")
904928
token = fetch()
905-
if token:
906-
_touch_sentinel(sentinel_path)
929+
_touch_sentinel(sentinel_path)
907930
return token
908931

909932

tests/test_databricks.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1189,12 +1189,16 @@ def test_coalesces_force_refresh_when_sentinel_is_fresh(self, tmp_path, monkeypa
11891189

11901190
_, sentinel_path = db_mod._refresh_lock_paths(WS, None)
11911191
sentinel_path.touch() # fresh mtime == a peer just force-refreshed
1192+
mtime_before = sentinel_path.stat().st_mtime_ns
11921193

11931194
token = get_databricks_token(WS, force_refresh=True)
11941195

11951196
assert token == "good-token"
11961197
argv = argv_log.read_text().splitlines()
11971198
assert "--force-refresh" not in argv
1199+
# M2: the coalesced branch performs no redemption, so it must never
1200+
# re-touch the sentinel.
1201+
assert sentinel_path.stat().st_mtime_ns == mtime_before
11981202

11991203
def test_performs_force_refresh_and_touches_sentinel_when_stale(self, tmp_path, monkeypatch):
12001204
monkeypatch.setattr(db_mod, "APP_DIR", tmp_path)
@@ -1212,6 +1216,27 @@ def test_performs_force_refresh_and_touches_sentinel_when_stale(self, tmp_path,
12121216
assert "--force-refresh" in argv
12131217
assert sentinel_path.exists()
12141218

1219+
def test_force_refresh_attempt_touches_sentinel_even_when_fetch_returns_empty(
1220+
self, tmp_path, monkeypatch
1221+
):
1222+
"""M2: mark-on-attempt, not mark-on-success. The OAuth server rotates
1223+
the refresh token when a `--force-refresh` redemption reaches it, not
1224+
when we successfully parse a token out of the subprocess's stdout. If
1225+
the subprocess exits non-zero or returns unparseable stdout, `_fetch`
1226+
swallows that into "" — but the sentinel must still be touched so a
1227+
concurrent peer doesn't redeem the same now-rotated refresh token
1228+
again. `_perform_force_refresh` must also still return "" so the
1229+
caller's outer empty-token re-auth + retry path keeps firing."""
1230+
monkeypatch.setattr(db_mod, "APP_DIR", tmp_path)
1231+
_, sentinel_path = db_mod._refresh_lock_paths(WS, None)
1232+
assert not sentinel_path.exists()
1233+
1234+
cmd = ["databricks", "auth", "token", "--host", WS, "--output", "json", "--force-refresh"]
1235+
token = db_mod._perform_force_refresh(WS, None, cmd, fetch=lambda: "")
1236+
1237+
assert token == ""
1238+
assert sentinel_path.exists()
1239+
12151240
def test_performs_force_refresh_when_sentinel_is_stale(self, tmp_path, monkeypatch):
12161241
monkeypatch.setattr(db_mod, "APP_DIR", tmp_path)
12171242
argv_log = tmp_path / "argv"

0 commit comments

Comments
 (0)