Skip to content

Commit 392b5b3

Browse files
committed
fix(databricks): serialize token --force-refresh with cross-process flock (#190)
Concurrent long-lived ucode sessions each ran `databricks auth token --force-refresh` independently, racing on the shared OAuth cache and redeeming (and revoking) the rotating refresh token concurrently. Serialize --force-refresh calls per workspace+profile behind a cross-process flock under APP_DIR, and coalesce redundant refreshes: if a peer force-refreshed within TOKEN_REFRESH_COALESCE_SECONDS (60s), drop --force-refresh and reuse the now-current cached token instead of redeeming the refresh token again. Degrades gracefully to no locking when fcntl is unavailable (Windows) or lock acquisition fails for any reason -- a stuck/missing lock must never block a token refresh. The non-force-refresh (cheap read) path is unchanged and unlocked.
1 parent 446a24a commit 392b5b3

2 files changed

Lines changed: 246 additions & 1 deletion

File tree

src/ucode/databricks.py

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
from __future__ import annotations
55

66
import configparser
7+
import contextlib
78
import functools
9+
import hashlib
10+
import importlib
811
import json
912
import logging
1013
import logging.handlers
@@ -15,6 +18,7 @@
1518
import shutil
1619
import subprocess
1720
import time
21+
from collections.abc import Callable
1822
from concurrent.futures import (
1923
ThreadPoolExecutor,
2024
as_completed,
@@ -23,6 +27,7 @@
2327
TimeoutError as FutureTimeoutError,
2428
)
2529
from pathlib import Path
30+
from types import ModuleType
2631
from typing import Literal, cast, overload
2732
from urllib import error as urllib_error
2833
from urllib import request as urllib_request
@@ -42,6 +47,15 @@
4247
spinner,
4348
)
4449

50+
# `fcntl` (POSIX file locking) doesn't exist on Windows. Load it dynamically
51+
# so we degrade to no cross-process locking there instead of failing to
52+
# import this module at all; see `_refresh_lock`.
53+
fcntl: ModuleType | None
54+
try:
55+
fcntl = importlib.import_module("fcntl")
56+
except ImportError:
57+
fcntl = None
58+
4559
UNIX_DATABRICKS_INSTALL_URL = (
4660
"https://raw.githubusercontent.com/databricks/setup-cli/main/install.sh"
4761
)
@@ -51,6 +65,13 @@
5165
AI_GATEWAY_V2_DOCS_URL = "https://docs.databricks.com/aws/en/ai-gateway/overview-beta"
5266
MIN_DATABRICKS_CLI_VERSION = (0, 298, 0)
5367
TOKEN_REFRESH_INTERVAL_SECONDS = 1800
68+
# A `--force-refresh` within this window of a peer process's successful
69+
# force-refresh (same workspace+profile) is considered redundant: the OAuth
70+
# refresh token is rotating, so redeeming it twice in quick succession from
71+
# concurrent `ucode opencode` sessions can revoke the token family (#190).
72+
TOKEN_REFRESH_COALESCE_SECONDS = 60
73+
_LOCK_ACQUIRE_TIMEOUT_SECONDS = 15
74+
_LOCK_POLL_INTERVAL_SECONDS = 0.2
5475

5576

5677
def _debug_enabled() -> bool:
@@ -776,6 +797,116 @@ def ensure_databricks_auth(workspace: str, profile: str | None = None) -> None:
776797
run_databricks_login(workspace, profile)
777798

778799

800+
def _refresh_lock_paths(workspace: str, profile: str | None) -> tuple[Path, Path]:
801+
"""Per-workspace+profile lock file and sentinel path, both under APP_DIR.
802+
803+
Hashing the workspace+profile pair keeps concurrent refreshes for
804+
*different* workspaces from serializing against each other, while still
805+
giving every process refreshing the *same* workspace+profile a shared,
806+
stable path to flock."""
807+
digest = hashlib.sha256(f"{workspace}|{profile or ''}".encode()).hexdigest()[:16]
808+
lock_path = APP_DIR / f".token-refresh-{digest}.lock"
809+
sentinel_path = Path(f"{lock_path}.ts")
810+
return lock_path, sentinel_path
811+
812+
813+
def _sentinel_is_fresh(sentinel_path: Path) -> bool:
814+
"""Whether `sentinel_path`'s mtime is within TOKEN_REFRESH_COALESCE_SECONDS."""
815+
try:
816+
mtime = sentinel_path.stat().st_mtime
817+
except OSError:
818+
return False
819+
return (time.time() - mtime) < TOKEN_REFRESH_COALESCE_SECONDS
820+
821+
822+
def _touch_sentinel(sentinel_path: Path) -> None:
823+
try:
824+
sentinel_path.touch()
825+
except OSError as exc:
826+
_debug("refresh_lock", f"failed to touch sentinel {sentinel_path}: {exc}")
827+
828+
829+
@contextlib.contextmanager
830+
def _refresh_lock(lock_path: Path):
831+
"""Best-effort cross-process exclusive lock guarding a force-refresh.
832+
833+
Degrades to a no-op (yields ``False`` without locking anything) when
834+
``fcntl`` is unavailable (Windows) or the lock can't be opened or
835+
acquired for any ``OSError`` — a missing or stuck lock must never
836+
prevent a token refresh from proceeding. Acquisition is a bounded,
837+
non-blocking retry loop (flock has no native timeout) capped at
838+
``_LOCK_ACQUIRE_TIMEOUT_SECONDS`` before falling back to proceeding
839+
without the lock."""
840+
if fcntl is None:
841+
yield False
842+
return
843+
844+
try:
845+
APP_DIR.mkdir(parents=True, exist_ok=True)
846+
handle = lock_path.open("a+")
847+
except OSError as exc:
848+
_debug("refresh_lock", f"could not open lock file {lock_path}: {exc}")
849+
yield False
850+
return
851+
852+
acquired = False
853+
try:
854+
deadline = time.monotonic() + _LOCK_ACQUIRE_TIMEOUT_SECONDS
855+
while time.monotonic() < deadline:
856+
try:
857+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
858+
acquired = True
859+
break
860+
except OSError:
861+
time.sleep(_LOCK_POLL_INTERVAL_SECONDS)
862+
_debug("refresh_lock", f"acquired={acquired} path={lock_path}")
863+
yield acquired
864+
finally:
865+
if acquired:
866+
try:
867+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
868+
except OSError:
869+
pass
870+
handle.close()
871+
872+
873+
def _perform_force_refresh(
874+
workspace: str,
875+
profile: str | None,
876+
cmd: list[str],
877+
fetch: Callable[[], str],
878+
) -> str:
879+
"""Serialize `--force-refresh` across processes and coalesce redundant ones.
880+
881+
Holds a cross-process flock (see `_refresh_lock`) for the duration of an
882+
actual force-refresh so two processes can never redeem the rotating
883+
refresh token concurrently. If a peer already force-refreshed within
884+
`TOKEN_REFRESH_COALESCE_SECONDS`, drops `--force-refresh` from `cmd` (in
885+
place, so retries via the same `fetch` closure stay coalesced too) and
886+
just fetches the now-current cached token instead."""
887+
lock_path, sentinel_path = _refresh_lock_paths(workspace, profile)
888+
with _refresh_lock(lock_path) as locked:
889+
_debug(
890+
"get_databricks_token",
891+
f"force-refresh lock acquired={locked} workspace={workspace} profile={profile or '<none>'}",
892+
)
893+
if _sentinel_is_fresh(sentinel_path):
894+
_debug(
895+
"get_databricks_token",
896+
f"coalescing --force-refresh: peer refreshed within "
897+
f"{TOKEN_REFRESH_COALESCE_SECONDS}s",
898+
)
899+
if "--force-refresh" in cmd:
900+
cmd.remove("--force-refresh")
901+
return fetch()
902+
903+
_debug("get_databricks_token", "performing --force-refresh")
904+
token = fetch()
905+
if token:
906+
_touch_sentinel(sentinel_path)
907+
return token
908+
909+
779910
def get_databricks_token(
780911
workspace: str,
781912
profile: str | None = None,
@@ -835,7 +966,10 @@ def _fetch() -> str:
835966
_debug("auth token", f"exception: {type(exc).__name__}: {exc}")
836967
return ""
837968

838-
token = _fetch()
969+
if force_refresh:
970+
token = _perform_force_refresh(workspace, profile, cmd, _fetch)
971+
else:
972+
token = _fetch()
839973
if not token:
840974
# Session may have expired — attempt non-interactive re-auth and retry once.
841975
_debug("auth token", "empty on first fetch; attempting auth login --no-browser")

tests/test_databricks.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import json
66
import os
77
import subprocess
8+
import time
89

910
import pytest
1011

@@ -1163,6 +1164,116 @@ def test_error_suggests_logout_when_matching_profile_exists(self, tmp_path, monk
11631164
assert f"databricks auth login --host {WS} --profile example-profile" in message
11641165

11651166

1167+
class TestForceRefreshLockAndCoalescing:
1168+
"""Axis A of #190: serialize `--force-refresh` across processes and
1169+
coalesce redundant refreshes within TOKEN_REFRESH_COALESCE_SECONDS."""
1170+
1171+
def _fake_databricks(self, tmp_path, script: str) -> dict:
1172+
fake = tmp_path / "databricks"
1173+
fake.write_text(f"#!/bin/sh\n{script}\n")
1174+
fake.chmod(0o755)
1175+
return {**os.environ, "PATH": f"{tmp_path}:{os.environ['PATH']}"}
1176+
1177+
def _argv_recording_env(self, tmp_path, argv_log) -> dict:
1178+
return self._fake_databricks(
1179+
tmp_path,
1180+
f'printf "%s\\n" "$@" >> {argv_log}\n'
1181+
'echo \'{"access_token": "good-token", "token_type": "Bearer"}\'',
1182+
)
1183+
1184+
def test_coalesces_force_refresh_when_sentinel_is_fresh(self, tmp_path, monkeypatch):
1185+
monkeypatch.setattr(db_mod, "APP_DIR", tmp_path)
1186+
argv_log = tmp_path / "argv"
1187+
env = self._argv_recording_env(tmp_path, argv_log)
1188+
monkeypatch.setattr("os.environ", env)
1189+
1190+
_, sentinel_path = db_mod._refresh_lock_paths(WS, None)
1191+
sentinel_path.touch() # fresh mtime == a peer just force-refreshed
1192+
1193+
token = get_databricks_token(WS, force_refresh=True)
1194+
1195+
assert token == "good-token"
1196+
argv = argv_log.read_text().splitlines()
1197+
assert "--force-refresh" not in argv
1198+
1199+
def test_performs_force_refresh_and_touches_sentinel_when_stale(self, tmp_path, monkeypatch):
1200+
monkeypatch.setattr(db_mod, "APP_DIR", tmp_path)
1201+
argv_log = tmp_path / "argv"
1202+
env = self._argv_recording_env(tmp_path, argv_log)
1203+
monkeypatch.setattr("os.environ", env)
1204+
1205+
_, sentinel_path = db_mod._refresh_lock_paths(WS, None)
1206+
assert not sentinel_path.exists()
1207+
1208+
token = get_databricks_token(WS, force_refresh=True)
1209+
1210+
assert token == "good-token"
1211+
argv = argv_log.read_text().splitlines()
1212+
assert "--force-refresh" in argv
1213+
assert sentinel_path.exists()
1214+
1215+
def test_performs_force_refresh_when_sentinel_is_stale(self, tmp_path, monkeypatch):
1216+
monkeypatch.setattr(db_mod, "APP_DIR", tmp_path)
1217+
argv_log = tmp_path / "argv"
1218+
env = self._argv_recording_env(tmp_path, argv_log)
1219+
monkeypatch.setattr("os.environ", env)
1220+
1221+
_, sentinel_path = db_mod._refresh_lock_paths(WS, None)
1222+
sentinel_path.touch()
1223+
old = time.time() - db_mod.TOKEN_REFRESH_COALESCE_SECONDS - 5
1224+
os.utime(sentinel_path, (old, old))
1225+
1226+
token = get_databricks_token(WS, force_refresh=True)
1227+
1228+
assert token == "good-token"
1229+
argv = argv_log.read_text().splitlines()
1230+
assert "--force-refresh" in argv
1231+
1232+
def test_degrades_to_no_lock_when_fcntl_missing(self, tmp_path, monkeypatch):
1233+
monkeypatch.setattr(db_mod, "APP_DIR", tmp_path)
1234+
monkeypatch.setattr(db_mod, "fcntl", None)
1235+
env = self._fake_databricks(
1236+
tmp_path,
1237+
'echo \'{"access_token": "good-token", "token_type": "Bearer"}\'',
1238+
)
1239+
monkeypatch.setattr("os.environ", env)
1240+
1241+
token = get_databricks_token(WS, force_refresh=True)
1242+
1243+
assert token == "good-token"
1244+
1245+
def test_lock_file_path_differs_for_different_workspace_or_profile(self, tmp_path, monkeypatch):
1246+
monkeypatch.setattr(db_mod, "APP_DIR", tmp_path)
1247+
1248+
lock_a, sentinel_a = db_mod._refresh_lock_paths(WS, None)
1249+
lock_b, sentinel_b = db_mod._refresh_lock_paths("https://other.databricks.com", None)
1250+
lock_c, sentinel_c = db_mod._refresh_lock_paths(WS, "some-profile")
1251+
1252+
assert lock_a != lock_b
1253+
assert lock_a != lock_c
1254+
assert lock_b != lock_c
1255+
assert sentinel_a != sentinel_b
1256+
assert sentinel_a != sentinel_c
1257+
assert lock_a.parent == tmp_path
1258+
1259+
def test_non_force_refresh_path_ignores_lock_and_sentinel(self, tmp_path, monkeypatch):
1260+
# force_refresh=False must stay cheap/unlocked: no lock file should
1261+
# even be created.
1262+
monkeypatch.setattr(db_mod, "APP_DIR", tmp_path)
1263+
env = self._fake_databricks(
1264+
tmp_path,
1265+
'echo \'{"access_token": "good-token", "token_type": "Bearer"}\'',
1266+
)
1267+
monkeypatch.setattr("os.environ", env)
1268+
1269+
token = get_databricks_token(WS)
1270+
1271+
assert token == "good-token"
1272+
lock_path, sentinel_path = db_mod._refresh_lock_paths(WS, None)
1273+
assert not lock_path.exists()
1274+
assert not sentinel_path.exists()
1275+
1276+
11661277
class TestListDatabricksConnections:
11671278
def test_lists_paginated_connections_with_workspace_env(self, monkeypatch):
11681279
calls: list[dict] = []

0 commit comments

Comments
 (0)