Skip to content

Commit 5331fe8

Browse files
refactor: make thread_safe read-only, use API-based fallback
- thread_safe is now read-only after initialization - Can only be set via DJ_THREAD_SAFE env var or datajoint.json - Programmatic setting raises ThreadSafetyError - ConnectionConfig now uses _use_global_fallback instead of _thread_safe - New API (from_config) uses defaults, never falls back to global - Legacy API (dj.conn) falls back to global for backward compat - Behavior is based on which API was used, not thread_safe flag - Updated tests and spec document Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 8e68710 commit 5331fe8

File tree

5 files changed

+202
-134
lines changed

5 files changed

+202
-134
lines changed

docs/design/thread-safe-mode.md

Lines changed: 53 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ This model works well for single-user scripts and notebooks but creates problems
2323

2424
## Design Principles
2525

26-
1. **One-way lock**Once enabled, thread-safe mode cannot be disabled
26+
1. **Deployment-time decision**Thread-safe mode is set via environment or config file, not programmatically
2727
2. **Explicit over implicit** — All connection parameters must be explicitly provided
2828
3. **No hidden global state** — Connection behavior is fully determined by its configuration
2929
4. **Backward compatible** — Existing code works unchanged when `thread_safe=False`
@@ -77,29 +77,27 @@ Connection parameters (set at creation, read-only after):
7777

7878
### Enabling Thread-Safe Mode
7979

80-
```python
81-
import datajoint as dj
82-
83-
# Method 1: Programmatic
84-
dj.config.thread_safe = True
80+
Thread-safe mode is read-only after initialization and can only be set via environment variable or config file:
8581

86-
# Method 2: Environment variable
87-
# DJ_THREAD_SAFE=true python app.py
82+
```bash
83+
# Method 1: Environment variable
84+
DJ_THREAD_SAFE=true python app.py
85+
```
8886

89-
# Method 3: Config file (datajoint.json)
90-
# { "thread_safe": true }
87+
```json
88+
// Method 2: Config file (datajoint.json)
89+
{ "thread_safe": true }
9190
```
9291

93-
Once enabled, cannot be disabled:
92+
Programmatic setting is not allowed:
9493
```python
95-
dj.config.thread_safe = True
96-
dj.config.thread_safe = False # Raises ThreadSafetyError
94+
dj.config.thread_safe = True # Raises ThreadSafetyError
9795
```
9896

9997
### Global Config Access in Thread-Safe Mode
10098

10199
```python
102-
dj.config.thread_safe = True
100+
# With DJ_THREAD_SAFE=true set in environment
103101

104102
# Only thread_safe is accessible
105103
dj.config.thread_safe # OK (returns True)
@@ -259,40 +257,39 @@ schema = dj.Schema("my_schema", connection=conn)
259257

260258
### Connection.config Behavior
261259

262-
When `thread_safe=False`, `conn.config` provides access to settings but **forwards to global config** for settings not explicitly provided:
260+
The behavior of `conn.config` depends on **which API created the connection**, not on the `thread_safe` setting:
261+
262+
**New API** (`Connection.from_config()`) — Uses explicit values or defaults. Never accesses global config. Works identically with `thread_safe` on or off:
263263

264264
```python
265-
dj.config.thread_safe = False
266-
dj.config.safemode = True
265+
dj.config.safemode = False # Set in global config
267266
dj.config.database_prefix = "dev_"
268267

269268
conn = dj.Connection.from_config(
270269
host="localhost",
271270
user="root",
272271
password="secret",
273-
# safemode not specified - will forward to global
272+
# safemode not specified - uses default, NOT global config
274273
)
275274

276-
conn.config.safemode # True (from dj.config)
277-
conn.config.database_prefix # "dev_" (from dj.config)
275+
conn.config.safemode # True (default, not global)
276+
conn.config.database_prefix # "" (default, not global)
278277
```
279278

280-
When `thread_safe=True`, `conn.config` uses only explicitly provided values with defaults:
279+
**Legacy API** (`dj.conn()`) — Forwards unset values to global config for backward compatibility:
281280

282281
```python
283-
dj.config.thread_safe = True
282+
dj.config.safemode = False
283+
dj.config.database_prefix = "dev_"
284284

285-
conn = dj.Connection.from_config(
286-
host="localhost",
287-
user="root",
288-
password="secret",
289-
# safemode not specified - uses default
290-
)
285+
conn = dj.conn() # Legacy API
291286

292-
conn.config.safemode # True (default)
293-
conn.config.database_prefix # "" (default)
287+
conn.config.safemode # False (from dj.config)
288+
conn.config.database_prefix # "dev_" (from dj.config)
294289
```
295290

291+
This design ensures that code using `Connection.from_config()` is portable and behaves identically whether `thread_safe` is enabled or not.
292+
296293
## Migration Path
297294

298295
Migration is immediate — adopt the new API and your code works in both modes:
@@ -351,18 +348,24 @@ class Config(BaseSettings):
351348
if name.startswith("_"):
352349
return object.__setattr__(self, name, value)
353350

354-
# thread_safe: one-way lock (can only go False -> True)
351+
# thread_safe is read-only after initialization
355352
if name == "thread_safe":
356-
current = object.__getattribute__(self, "thread_safe")
357-
if current and not value:
358-
raise ThreadSafetyError("Cannot disable thread-safe mode once enabled.")
353+
try:
354+
object.__getattribute__(self, "thread_safe")
355+
# If we get here, thread_safe already exists - block the set
356+
raise ThreadSafetyError(
357+
"thread_safe cannot be set programmatically. "
358+
"Set DJ_THREAD_SAFE=true in environment or datajoint.json."
359+
)
360+
except AttributeError:
361+
pass # First time setting during __init__ - allow it
359362
return object.__setattr__(self, name, value)
360363

361364
# Block everything else in thread-safe mode
362365
if object.__getattribute__(self, "thread_safe"):
363366
raise ThreadSafetyError(
364-
f"Setting '{name}' is connection-scoped in thread-safe mode. "
365-
"Modify it via connection.config instead."
367+
"Global config is inaccessible in thread-safe mode. "
368+
"Use Connection.from_config() with explicit configuration."
366369
)
367370

368371
return object.__setattr__(self, name, value)
@@ -376,8 +379,9 @@ class Connection:
376379
use_tls=None, backend=None, *, _config=None):
377380
# ... existing connection setup ...
378381

379-
# Store connection-scoped config
380-
self.config = _config or ConnectionConfig()
382+
# Connection-scoped configuration
383+
# Legacy API (dj.conn()) uses global fallback for backward compatibility
384+
self.config = _config if _config is not None else ConnectionConfig(_use_global_fallback=True)
381385

382386
@classmethod
383387
def from_config(cls, cfg=None, *, host=None, user=None, password=None,
@@ -393,14 +397,9 @@ class Connection:
393397
# ... merge cfg dict with kwargs ...
394398
# ... validate required fields (host, user, password) ...
395399

396-
# Determine thread-safe mode (check global config safely)
397-
from .settings import config
398-
is_thread_safe = config.thread_safe
399-
400-
# Build ConnectionConfig with explicit values only
401-
# (unset values will forward to global or use defaults)
400+
# Build ConnectionConfig - new API never falls back to global config
402401
conn_config = ConnectionConfig(
403-
_thread_safe=is_thread_safe,
402+
_use_global_fallback=False,
404403
**({"safemode": safemode} if safemode is not None else {}),
405404
**({"stores": stores} if stores is not None else {}),
406405
**({"database_prefix": database_prefix} if database_prefix is not None else {}),
@@ -428,8 +427,9 @@ class ConnectionConfig:
428427
"""
429428
Connection-scoped configuration (read/write).
430429
431-
When thread_safe=False, unset values forward to global dj.config.
432-
When thread_safe=True, unset values use defaults (no global access).
430+
Behavior depends on how the connection was created:
431+
- New API (from_config): Uses explicit values or defaults. Never accesses global config.
432+
- Legacy API (dj.conn): Forwards unset values to global dj.config.
433433
"""
434434

435435
_DEFAULTS = {
@@ -448,7 +448,9 @@ class ConnectionConfig:
448448

449449
def __init__(self, **explicit_values):
450450
self._values = {} # Mutable storage for this connection
451-
self._thread_safe = explicit_values.pop("_thread_safe", False)
451+
# If True, forward unset values to global config (legacy API behavior)
452+
# If False, use defaults only (new API behavior)
453+
self._use_global_fallback = explicit_values.pop("_use_global_fallback", False)
452454
self._values.update(explicit_values)
453455

454456
def __getattr__(self, name):
@@ -459,12 +461,12 @@ class ConnectionConfig:
459461
if name in self._values:
460462
return self._values[name]
461463

462-
# If thread_safe=False, forward to global config
463-
if not self._thread_safe:
464+
# Legacy API: forward to global config for backward compatibility
465+
if self._use_global_fallback:
464466
from .settings import config
465467
return getattr(config, name, self._DEFAULTS.get(name))
466468

467-
# If thread_safe=True, return default
469+
# New API: use defaults only (no global config access)
468470
return self._DEFAULTS.get(name)
469471

470472
def __setattr__(self, name, value):

src/datajoint/connection.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,13 @@ class ConnectionConfig:
3232
"""
3333
Connection-scoped configuration (read/write).
3434
35-
Provides access to settings that can vary per connection. When ``thread_safe=False``,
36-
unset values forward to global ``dj.config``. When ``thread_safe=True``, unset values
37-
use defaults (no global access).
35+
Provides access to settings that can vary per connection. Behavior depends on
36+
how the connection was created:
37+
38+
- **New API** (``Connection.from_config()``): Uses explicit values or defaults.
39+
Never accesses global config. Works identically with ``thread_safe`` on or off.
40+
- **Legacy API** (``dj.conn()``): Forwards unset values to global ``dj.config``
41+
for backward compatibility.
3842
3943
Parameters
4044
----------
@@ -111,7 +115,9 @@ class ConnectionConfig:
111115

112116
def __init__(self, **explicit_values: Any) -> None:
113117
object.__setattr__(self, "_values", {}) # Mutable storage for this connection
114-
object.__setattr__(self, "_thread_safe", explicit_values.pop("_thread_safe", False))
118+
# If True, forward unset values to global config (legacy API behavior)
119+
# If False, use defaults only (new API behavior)
120+
object.__setattr__(self, "_use_global_fallback", explicit_values.pop("_use_global_fallback", False))
115121
self._values.update(explicit_values)
116122

117123
def __getattr__(self, name: str) -> Any:
@@ -122,13 +128,13 @@ def __getattr__(self, name: str) -> Any:
122128
if name in self._values:
123129
return self._values[name]
124130

125-
# If thread_safe=False, forward to global config
126-
if not self._thread_safe:
131+
# Legacy API: forward to global config for backward compatibility
132+
if self._use_global_fallback:
127133
global_path = self._GLOBAL_CONFIG_MAP.get(name)
128134
if global_path:
129135
return config[global_path]
130136

131-
# Return default
137+
# New API: use defaults only (no global config access)
132138
return self._DEFAULTS.get(name)
133139

134140
def __setattr__(self, name: str, value: Any) -> None:
@@ -389,7 +395,8 @@ def __init__(
389395
self.dependencies = Dependencies(self)
390396

391397
# Connection-scoped configuration
392-
self.config = _config if _config is not None else ConnectionConfig(_thread_safe=config.thread_safe)
398+
# Legacy API (dj.conn()) uses global fallback for backward compatibility
399+
self.config = _config if _config is not None else ConnectionConfig(_use_global_fallback=True)
393400

394401
@classmethod
395402
def from_config(
@@ -614,9 +621,9 @@ def from_config(
614621
"Database password is required. " "Provide password= argument or include 'password' in config dict."
615622
)
616623

617-
# Create ConnectionConfig with thread-safe flag
624+
# Create ConnectionConfig - new API never falls back to global config
618625
conn_config = ConnectionConfig(
619-
_thread_safe=config.thread_safe,
626+
_use_global_fallback=False,
620627
**config_kwargs,
621628
)
622629

src/datajoint/settings.py

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -644,7 +644,11 @@ def _update_from_flat_dict(self, data: dict[str, Any]) -> None:
644644
if env_var and os.environ.get(env_var):
645645
logger.debug(f"Skipping {key} from file (env var {env_var} takes precedence)")
646646
continue
647-
setattr(self, key, value)
647+
# thread_safe is read-only after init, but we allow setting from config file
648+
if key == "thread_safe":
649+
object.__setattr__(self, key, value)
650+
else:
651+
setattr(self, key, value)
648652
elif len(parts) == 2:
649653
group, attr = parts
650654
if hasattr(self, group):
@@ -923,21 +927,23 @@ def __setattr__(self, name: str, value: Any) -> None:
923927
------
924928
ThreadSafetyError
925929
If thread-safe mode is enabled and trying to modify config,
926-
or if trying to set thread_safe from True to False.
930+
or if trying to set thread_safe programmatically.
927931
"""
928932
# Always allow setting private attributes (pydantic internals)
929933
if name.startswith("_"):
930934
return object.__setattr__(self, name, value)
931935

932-
# Special handling for thread_safe: one-way lock
936+
# thread_safe is read-only after initialization
933937
if name == "thread_safe":
934-
# Check current value (may not exist during __init__)
938+
# Allow setting during __init__ (when attribute doesn't exist yet)
935939
try:
936-
current = object.__getattribute__(self, "thread_safe")
937-
if current and not value:
938-
raise ThreadSafetyError("Cannot disable thread-safe mode once enabled.")
940+
object.__getattribute__(self, "thread_safe")
941+
# If we get here, thread_safe already exists - block the set
942+
raise ThreadSafetyError(
943+
"thread_safe cannot be set programmatically. " "Set DJ_THREAD_SAFE=true in environment or datajoint.json."
944+
)
939945
except AttributeError:
940-
pass # First time setting during __init__
946+
pass # First time setting during __init__ - allow it
941947
return object.__setattr__(self, name, value)
942948

943949
# Block all other modifications in thread-safe mode
@@ -977,12 +983,11 @@ def __getitem__(self, key: str) -> Any:
977983

978984
def __setitem__(self, key: str, value: Any) -> None:
979985
"""Set setting by dot-notation key (e.g., 'database.host')."""
980-
# Special handling for thread_safe: allow setting but enforce one-way
986+
# thread_safe is read-only - cannot be set programmatically
981987
if key == "thread_safe":
982-
if self.thread_safe and not value:
983-
raise ThreadSafetyError("Cannot disable thread-safe mode once enabled.")
984-
self.thread_safe = value
985-
return
988+
raise ThreadSafetyError(
989+
"thread_safe cannot be set programmatically. " "Set DJ_THREAD_SAFE=true in environment or datajoint.json."
990+
)
986991

987992
if self.thread_safe:
988993
raise ThreadSafetyError(

0 commit comments

Comments
 (0)