sdmxlib 0.27.2__tar.gz → 0.28.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/PKG-INFO +1 -1
  2. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/pyproject.toml +1 -1
  3. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/local/_data_store.py +28 -2
  4. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/local/_registry.py +181 -63
  5. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/code_query.py +52 -17
  6. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/hierarchy_query.py +50 -17
  7. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/storage/lazy.py +173 -68
  8. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/storage/readers.py +76 -20
  9. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/README.md +0 -0
  10. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/__init__.py +0 -0
  11. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/api/__init__.py +0 -0
  12. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/api/client.py +0 -0
  13. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/api/federated.py +0 -0
  14. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/api/filters.py +0 -0
  15. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/api/policy.py +0 -0
  16. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/api/providers.py +0 -0
  17. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/api/query.py +0 -0
  18. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/api/registry.py +0 -0
  19. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/api/session.py +0 -0
  20. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/catalog.py +0 -0
  21. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/data_store.py +0 -0
  22. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/__init__.py +0 -0
  23. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_csv/__init__.py +0 -0
  24. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_csv/reader.py +0 -0
  25. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_csv/writer.py +0 -0
  26. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_json/__init__.py +0 -0
  27. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_json/metadata.py +0 -0
  28. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_json/reader.py +0 -0
  29. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_json/writer.py +0 -0
  30. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_ml21/__init__.py +0 -0
  31. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_ml21/namespaces.py +0 -0
  32. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_ml21/reader.py +0 -0
  33. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_ml21/writer.py +0 -0
  34. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_ml30/__init__.py +0 -0
  35. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_ml30/metadata.py +0 -0
  36. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_ml30/namespaces.py +0 -0
  37. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_ml30/reader.py +0 -0
  38. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_ml30/writer.py +0 -0
  39. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/local/__init__.py +0 -0
  40. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/__init__.py +0 -0
  41. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/annotations.py +0 -0
  42. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/base.py +0 -0
  43. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/binding.py +0 -0
  44. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/category.py +0 -0
  45. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/codelist.py +0 -0
  46. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/collections.py +0 -0
  47. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/concept.py +0 -0
  48. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/constraint.py +0 -0
  49. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/convert.py +0 -0
  50. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/dataflow.py +0 -0
  51. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/dataset.py +0 -0
  52. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/datastructure.py +0 -0
  53. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/expr.py +0 -0
  54. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/hierarchy.py +0 -0
  55. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/in_memory_registry.py +0 -0
  56. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/istring.py +0 -0
  57. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/mapping.py +0 -0
  58. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/message.py +0 -0
  59. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/metadataflow.py +0 -0
  60. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/metadataset.py +0 -0
  61. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/metadatastructure.py +0 -0
  62. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/organisation.py +0 -0
  63. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/provision.py +0 -0
  64. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/ref.py +0 -0
  65. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/registry.py +0 -0
  66. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/representation.py +0 -0
  67. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/urn.py +0 -0
  68. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/validation.py +0 -0
  69. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/polars.py +0 -0
  70. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/py.typed +0 -0
  71. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/rest.py +0 -0
  72. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/sql.py +0 -0
  73. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/storage/__init__.py +0 -0
  74. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/storage/schema.py +0 -0
  75. {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/storage/writers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sdmxlib
3
- Version: 0.27.2
3
+ Version: 0.28.1
4
4
  Summary: SDMX structural metadata library for Python
5
5
  Keywords: sdmx,statistics,metadata,datastructure
6
6
  Author: gabrielgellner
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sdmxlib"
3
- version = "0.27.2"
3
+ version = "0.28.1"
4
4
  description = "SDMX structural metadata library for Python"
5
5
  readme = "README.md"
6
6
  license = { text = "Apache-2.0" }
@@ -15,6 +15,7 @@ file-handle juggling.
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ import threading
18
19
  from collections.abc import Iterator
19
20
  from pathlib import Path
20
21
  from typing import TYPE_CHECKING, Literal, Self
@@ -98,6 +99,8 @@ class DuckDBDataStore:
98
99
  self._registry: Registry | None = None
99
100
  self._conn: duckdb.DuckDBPyConnection | None = None
100
101
  self._owns_connection = True
102
+ # Per-thread read cursor (#80); see Registry._read_cursor.
103
+ self._thread_local: threading.local = threading.local()
101
104
 
102
105
  @property
103
106
  def uri(self) -> Path:
@@ -171,6 +174,7 @@ class DuckDBDataStore:
171
174
  instance._registry = registry # noqa: SLF001
172
175
  instance._conn = registry.connection # noqa: SLF001
173
176
  instance._owns_connection = False # noqa: SLF001
177
+ instance._thread_local = threading.local() # noqa: SLF001
174
178
  instance._configure_session() # noqa: SLF001
175
179
  return instance
176
180
 
@@ -227,6 +231,22 @@ class DuckDBDataStore:
227
231
  if self._conn is not None and self._owns_connection:
228
232
  self._conn.close()
229
233
  self._conn = None
234
+ self._thread_local = threading.local()
235
+
236
+ def _read_cursor(self) -> duckdb.DuckDBPyConnection:
237
+ """Return this thread's read cursor for ``query`` / ``query_stream``.
238
+
239
+ Mirror of :meth:`Registry._read_cursor` (#80). Lazily created on
240
+ first call per thread, reused for the thread's lifetime.
241
+ """
242
+ cur = getattr(self._thread_local, "cur", None)
243
+ if cur is not None:
244
+ return cur
245
+ conn = self.connection
246
+ cur = conn.cursor()
247
+ cur.execute("SET search_path = 'sdmx'")
248
+ self._thread_local.cur = cur
249
+ return cur
230
250
 
231
251
  # ── DataStore protocol ───────────────────────────────────────────────
232
252
 
@@ -267,7 +287,8 @@ class DuckDBDataStore:
267
287
  order_by=order_by,
268
288
  limit=limit,
269
289
  )
270
- cur = conn.execute(sql, sql_params)
290
+ # Thread-local cursor for issue #75 / #80 (concurrent reads).
291
+ cur = self._read_cursor().execute(sql, sql_params)
271
292
  col_names = [d[0] for d in cur.description] if cur.description else []
272
293
  rows = cur.fetchall()
273
294
  if not rows:
@@ -365,7 +386,12 @@ class DuckDBDataStore:
365
386
  order_by=order_by,
366
387
  limit=None,
367
388
  )
368
- reader = conn.execute(sql, sql_params).to_arrow_reader(chunk_rows)
389
+ # Per-call cursor for issue #75 — concurrent ``query_stream``
390
+ # callers must not share the connection's default-cursor row
391
+ # buffer. The Arrow reader streams from this cursor and holds
392
+ # the reference until the generator is exhausted (or GC'd).
393
+ cur = conn.cursor()
394
+ reader = cur.execute(sql, sql_params).to_arrow_reader(chunk_rows)
369
395
  yield from reader
370
396
 
371
397
 
@@ -30,7 +30,7 @@ stashes the URI; the DuckDB connection is opened by ``open()`` /
30
30
  local.add(...)
31
31
  """
32
32
 
33
- from collections import OrderedDict
33
+ import threading
34
34
  from collections.abc import Iterable
35
35
  from pathlib import Path
36
36
  from typing import Any, Self
@@ -61,18 +61,30 @@ _ITEM_TO_SCHEME: dict[str, tuple[str, str]] = {
61
61
  DEFAULT_RESOLVE_CACHE_SIZE = 256
62
62
 
63
63
 
64
- class _LRUDict(OrderedDict[str, Any]):
65
- """Bounded LRU mapping used as the registry-level resolver cache.
64
+ class _FIFOCache(dict[str, Any]):
65
+ """Bounded resolver cache. FIFO eviction, lock-free reads.
66
66
 
67
- Subclasses :class:`OrderedDict` so it can be passed wherever the
68
- resolver expects ``dict[str, Any]``. Inserts evict least-recently-used
69
- entries past ``maxsize``; reads via :meth:`get` move keys to the end.
70
- ``maxsize == 0`` disables caching inserts are silently dropped, so
71
- the structure stays empty.
67
+ Subclasses :class:`dict` (not ``OrderedDict``) so that ``get`` /
68
+ ``__contains__`` / ``__getitem__`` are plain dict ops GIL-atomic
69
+ in CPython and safe for multiple concurrent reader threads with no
70
+ locking. Mutations (insert, evict, clear) hold a per-instance
71
+ :class:`threading.Lock`.
72
72
 
73
- Tracks ``hits`` / ``misses`` for :meth:`Registry.resolve_cache_info`;
74
- ``__contains__`` is treated as internal control flow (the resolver
75
- uses it for dedup) and is not counted.
73
+ Eviction is FIFO (insertion order) rather than LRU. FIFO is a
74
+ deliberate choice over LRU here: tracking recency would require
75
+ mutating the cache on every hit, which would force every cache hit
76
+ onto the lock and serialise reader threads. At realistic working-set
77
+ sizes for SDMX parent-scheme reuse, the hit-rate difference between
78
+ FIFO and LRU is small; the lock-free read path is more valuable.
79
+ Documented as a known trade-off in ``docs/concurrency.md`` — revisit
80
+ if ``resolve_cache_info()`` ever shows poor hit rates against a
81
+ working set that should otherwise fit.
82
+
83
+ ``maxsize == 0`` disables caching — inserts are silently dropped.
84
+
85
+ Hit/miss counters are best-effort under concurrency: lockless ``+=``
86
+ on the counter ints races, so under contention the reported numbers
87
+ may slightly under-count. They remain useful as a diagnostic.
76
88
  """
77
89
 
78
90
  def __init__(self, maxsize: int) -> None:
@@ -83,63 +95,72 @@ class _LRUDict(OrderedDict[str, Any]):
83
95
  self.maxsize = maxsize
84
96
  self.hits = 0
85
97
  self.misses = 0
98
+ self._lock = threading.Lock()
86
99
 
87
100
  def __setitem__(self, key: str, value: Any) -> None:
88
101
  if self.maxsize == 0:
89
102
  return
90
- if key in self:
91
- self.move_to_end(key)
92
- super().__setitem__(key, value)
93
- while len(self) > self.maxsize:
94
- self.popitem(last=False)
103
+ with self._lock:
104
+ super().__setitem__(key, value)
105
+ # Evict insertion-oldest entries past the cap. ``next(iter(self))``
106
+ # is the first-inserted key for plain dicts in Python 3.7+.
107
+ while len(self) > self.maxsize:
108
+ oldest = next(iter(self))
109
+ super().__delitem__(oldest)
95
110
 
96
111
  def get(self, key: str, default: Any = None) -> Any: # type: ignore[override]
112
+ # Lock-free read: plain dict ``__contains__`` / ``__getitem__``
113
+ # are GIL-atomic in CPython. The counter increments race under
114
+ # concurrency — accepted (see class docstring).
97
115
  if super().__contains__(key):
98
116
  self.hits += 1
99
- self.move_to_end(key)
100
117
  return super().__getitem__(key)
101
118
  self.misses += 1
102
119
  return default
103
120
 
121
+ def clear(self) -> None:
122
+ with self._lock:
123
+ super().clear()
124
+
104
125
  def reset_stats(self) -> None:
105
126
  self.hits = 0
106
127
  self.misses = 0
107
128
 
108
129
 
109
130
  class _CallScopedMemo(dict[str, Any]):
110
- """Per-call resolver memo layered over a persistent LRU cache.
131
+ """Per-call resolver memo layered over the persistent FIFO cache.
111
132
 
112
- Two reasons for the overlay rather than passing the LRU directly:
133
+ Two reasons for the overlay rather than passing the cache directly:
113
134
 
114
135
  1. **Correctness under tight cache sizes (incl. ``maxsize=0``).** The
115
136
  resolver writes URN→target during one phase and reads them back in
116
137
  a later phase of the same call. If those writes went straight to
117
- the LRU, an entry could be evicted before the read — leaving Refs
138
+ the cache, an entry could be evicted before the read — leaving Refs
118
139
  unresolved for callers using a small (or disabled) cache. The
119
140
  overlay holds in-flight entries in an unbounded per-call dict so
120
141
  single-call resolution is independent of cache size.
121
142
  2. **Promote-on-success.** Entries written during the call enter the
122
- LRU only at the end of the call, via :meth:`promote`. That keeps
123
- partial-failure semantics clean: if a call raises midway, the LRU
124
- is unchanged.
143
+ persistent cache only at the end of the call, via :meth:`promote`.
144
+ That keeps partial-failure semantics clean: if a call raises
145
+ midway, the persistent cache is unchanged.
125
146
  """
126
147
 
127
- def __init__(self, lru: _LRUDict) -> None:
148
+ def __init__(self, cache: _FIFOCache) -> None:
128
149
  super().__init__()
129
- self._lru = lru
150
+ self._cache = cache
130
151
 
131
152
  def __contains__(self, key: object) -> bool:
132
- return super().__contains__(key) or self._lru.__contains__(key)
153
+ return super().__contains__(key) or self._cache.__contains__(key)
133
154
 
134
155
  def get(self, key: str, default: Any = None) -> Any: # type: ignore[override]
135
156
  if super().__contains__(key):
136
157
  return super().__getitem__(key)
137
- return self._lru.get(key, default)
158
+ return self._cache.get(key, default)
138
159
 
139
160
  def promote(self) -> None:
140
- """Copy this call's resolved entries into the persistent LRU."""
161
+ """Copy this call's resolved entries into the persistent cache."""
141
162
  for k, v in self.items():
142
- self._lru[k] = v
163
+ self._cache[k] = v
143
164
 
144
165
 
145
166
  class Registry:
@@ -164,6 +185,15 @@ class Registry:
164
185
  DuckLake catalog the caller already opened) is done via
165
186
  :meth:`from_connection`, which doesn't take ownership of the
166
187
  connection's lifecycle.
188
+
189
+ **Concurrency.** Reads (``get`` / ``get_all`` / ``codelist`` /
190
+ ``code_map`` / ``dsd_code_map`` / ``to_registry`` / ``__contains__``
191
+ / ``__len__``) are safe to call from multiple threads against the
192
+ same ``Registry``. Writes (``add`` / ``drop`` / ``replace`` /
193
+ ``add_all`` / ``ingest``) are single-threaded — serialise externally
194
+ if you need to mutate from multiple threads. See
195
+ ``docs/design/concurrency.md`` for the model and deployment
196
+ recipes.
167
197
  """
168
198
 
169
199
  def __init__(
@@ -175,18 +205,24 @@ class Registry:
175
205
  ) -> None:
176
206
  """Open or wrap a DuckDB-backed registry.
177
207
 
178
- ``resolve_cache_size`` bounds the LRU of resolved Ref targets that
179
- :meth:`get` / :meth:`get_all` / :meth:`to_registry` share across
180
- calls. Pass ``0`` to disable caching (matches pre-v0.27.2 behaviour
181
- — one full read per Ref). The cache is cleared on every mutation
182
- (``add`` / ``drop`` / ``replace``) for invalidation correctness.
208
+ ``resolve_cache_size`` bounds the FIFO cache of resolved Ref
209
+ targets that :meth:`get` / :meth:`get_all` / :meth:`to_registry`
210
+ share across calls. Pass ``0`` to disable caching (matches
211
+ pre-v0.27.2 behaviour — one full read per Ref). The cache is
212
+ cleared on every mutation (``add`` / ``drop`` / ``replace``) for
213
+ invalidation correctness. FIFO eviction (not LRU) keeps cache
214
+ reads lock-free; see ``docs/concurrency.md``.
183
215
  """
184
216
  self._uri = Path(uri) if isinstance(uri, str) else uri
185
217
  self._source_registry = source_registry
186
218
  self._conn: duckdb.DuckDBPyConnection | None = None
187
219
  self._cursor: duckdb.DuckDBPyConnection | None = None
188
220
  self._owns_connection = True
189
- self._resolve_cache: _LRUDict = _LRUDict(resolve_cache_size)
221
+ self._resolve_cache: _FIFOCache = _FIFOCache(resolve_cache_size)
222
+ # Per-thread read cursor (#80). Each thread that reads from this
223
+ # Registry lazily gets its own cursor on first call; subsequent
224
+ # calls on the same thread reuse it. Cleared on close().
225
+ self._thread_local: threading.local = threading.local()
190
226
 
191
227
  @property
192
228
  def uri(self) -> Path:
@@ -250,7 +286,8 @@ class Registry:
250
286
  instance._conn = conn # noqa: SLF001
251
287
  instance._cursor = None # noqa: SLF001
252
288
  instance._owns_connection = False # noqa: SLF001
253
- instance._resolve_cache = _LRUDict(resolve_cache_size) # noqa: SLF001
289
+ instance._resolve_cache = _FIFOCache(resolve_cache_size) # noqa: SLF001
290
+ instance._thread_local = threading.local() # noqa: SLF001
254
291
  instance._initialise_schema() # noqa: SLF001
255
292
  return instance
256
293
 
@@ -266,6 +303,11 @@ class Registry:
266
303
  else:
267
304
  self._cursor = self._conn.cursor()
268
305
  self._cursor.execute("SET search_path = 'sdmx'")
306
+ # Also set search_path on the connection's default cursor — used
307
+ # by code paths that call ``conn.execute()`` directly without
308
+ # going through ``_read_cursor`` (lazy facades' ``code_map``
309
+ # forwarder, etc.). Cheap one-shot.
310
+ self._conn.execute("SET search_path = 'sdmx'")
269
311
 
270
312
  def __enter__(self) -> Self:
271
313
  if self._conn is None:
@@ -279,20 +321,28 @@ class Registry:
279
321
  """Close the database connection (only if we own it).
280
322
 
281
323
  After ``close()``, the registry can be re-opened with ``open()``.
324
+ Drops the per-thread cursor cache — reopen starts fresh.
282
325
  """
283
326
  if self._conn is not None and self._owns_connection:
284
327
  self._conn.close()
285
328
  self._conn = None
286
329
  self._cursor = None
330
+ # Drop per-thread cursors. The cursor objects become orphaned —
331
+ # DuckDB's close() invalidates them; Python GC reclaims the
332
+ # handles. We can't iterate other threads' attributes on the
333
+ # threadlocal, but reassigning replaces the storage for *this*
334
+ # thread and lets the prior thread-local mapping get GC'd
335
+ # (other threads' attrs become unreachable too).
336
+ self._thread_local = threading.local()
287
337
 
288
338
  @property
289
339
  def _c(self) -> duckdb.DuckDBPyConnection:
290
340
  """Internal accessor — returns the live cursor or raises a clear error.
291
341
 
292
- Replaces direct ``self._cursor`` use after the Path-style refactor;
293
- every method that touches the database routes through this so the
294
- error message points at the lifecycle problem instead of the
295
- downstream ``AttributeError`` you'd see with a bare ``None``.
342
+ Used by write paths (``add`` / ``drop`` / ``replace``) which are
343
+ documented as single-threaded. Read paths must go through
344
+ :meth:`_read_cursor` instead so concurrent reads each get an
345
+ isolated cursor see ``docs/concurrency.md`` and issue #75.
296
346
  """
297
347
  if self._cursor is None:
298
348
  msg = (
@@ -302,6 +352,50 @@ class Registry:
302
352
  raise RuntimeError(msg)
303
353
  return self._cursor
304
354
 
355
+ def _require_open(self) -> duckdb.DuckDBPyConnection:
356
+ """Return the live connection or raise a clear lifecycle error.
357
+
358
+ Use when a caller needs the long-lived connection itself (e.g. to
359
+ hand to a lazy facade) rather than a short-lived cursor.
360
+ """
361
+ if self._conn is None:
362
+ msg = (
363
+ f"Registry({self._uri!r}) is not open. Use it as a context manager "
364
+ "(``with Registry(uri) as reg: ...``) or call ``.open()`` first."
365
+ )
366
+ raise RuntimeError(msg)
367
+ return self._conn
368
+
369
+ def _read_cursor(self) -> duckdb.DuckDBPyConnection:
370
+ """Return this thread's read cursor, creating it on first call.
371
+
372
+ Each thread gets its own cursor lazily; subsequent calls on the
373
+ same thread reuse it. DuckDB requires per-thread cursors (the
374
+ connection's default-cursor row buffer is overwritten by every
375
+ ``execute()`` — see #75), but it does *not* require recreating
376
+ the cursor per call. Thread-local reuse amortises the
377
+ ``cursor()`` + ``SET search_path`` cost across the thread's
378
+ lifetime — typically the entire process for a long-lived
379
+ FastAPI/uvicorn threadpool.
380
+
381
+ Cleanup: the thread-local storage is dropped by :meth:`close`;
382
+ otherwise it lives until the thread dies (Python GC closes the
383
+ cursor object).
384
+ """
385
+ cur = getattr(self._thread_local, "cur", None)
386
+ if cur is not None:
387
+ return cur
388
+ if self._conn is None:
389
+ msg = (
390
+ f"Registry({self._uri!r}) is not open. Use it as a context manager "
391
+ "(``with Registry(uri) as reg: ...``) or call ``.open()`` first."
392
+ )
393
+ raise RuntimeError(msg)
394
+ cur = self._conn.cursor()
395
+ cur.execute("SET search_path = 'sdmx'")
396
+ self._thread_local.cur = cur
397
+ return cur
398
+
305
399
  # ── Add ───────────────────────────────────────────────────────────────
306
400
 
307
401
  def add(self, artefact: Any, *, source_registry: str | None = None) -> None:
@@ -313,6 +407,12 @@ class Registry:
313
407
  Clears the resolver cache: any cached resolved target may now be
314
408
  stale (e.g. a freshly-added Codelist replaces the one a previous
315
409
  ``get`` had cached).
410
+
411
+ **Single-threaded.** Mutations are not safe under concurrent
412
+ callers — do not invoke ``add`` / ``drop`` / ``replace`` from
413
+ multiple threads against the same ``Registry``. Concurrent reads
414
+ through ``get`` / ``get_all`` / ``code_map`` etc. are safe. See
415
+ ``docs/concurrency.md``.
316
416
  """
317
417
  tag = source_registry if source_registry is not None else self._source_registry
318
418
  writers.put_artefact(self._c, artefact, source_registry=tag)
@@ -342,7 +442,12 @@ class Registry:
342
442
  """
343
443
  from sdmxlib.storage.readers import make_lazy_codelist # noqa: PLC0415
344
444
 
345
- return make_lazy_codelist(self._c, str(urn))
445
+ # make_lazy_codelist opens its own internal cursor for spine
446
+ # reads; the returned LazyCodelist holds the long-lived
447
+ # connection for later method calls. ``_require_open`` enforces
448
+ # the lifecycle precondition without creating a cursor we don't
449
+ # need.
450
+ return make_lazy_codelist(self._require_open(), str(urn))
346
451
 
347
452
  def get[T](self, urn: "SdmxUrn[T]", *, eager: bool = False) -> "T | None":
348
453
  """Look up an artefact by URN.
@@ -357,12 +462,13 @@ class Registry:
357
462
  dsd.dimensions["FREQ"].codelist() # -> LazyCodelist
358
463
  dsd.code_map(lang="en") # works
359
464
  """
360
- result = readers.get_artefact(self._c, str(urn), eager=eager)
465
+ cur = self._read_cursor()
466
+ result = readers.get_artefact(cur, str(urn), eager=eager, lazy_conn=self._conn)
361
467
  if result is None:
362
468
  return None
363
469
  if not isinstance(result, (LazyCodelist, LazyHierarchy)):
364
470
  memo = _CallScopedMemo(self._resolve_cache)
365
- self._resolve_refs(result, memo=memo)
471
+ self._resolve_refs(cur, result, memo=memo)
366
472
  memo.promote()
367
473
  return result # type: ignore[return-value]
368
474
 
@@ -374,11 +480,12 @@ class Registry:
374
480
  parent scheme referenced by multiple artefacts is loaded once,
375
481
  not once per artefact.
376
482
  """
377
- results = readers.get_all(self._c, artefact_type)
483
+ cur = self._read_cursor()
484
+ results = readers.get_all(cur, artefact_type, lazy_conn=self._conn)
378
485
  memo = _CallScopedMemo(self._resolve_cache)
379
486
  for r in results:
380
487
  if not isinstance(r, (LazyCodelist, LazyHierarchy)):
381
- self._resolve_refs(r, memo=memo)
488
+ self._resolve_refs(cur, r, memo=memo)
382
489
  memo.promote()
383
490
  return results # type: ignore[return-value]
384
491
 
@@ -386,6 +493,7 @@ class Registry:
386
493
 
387
494
  def _resolve_refs( # noqa: C901
388
495
  self,
496
+ cur: duckdb.DuckDBPyConnection,
389
497
  obj: Any,
390
498
  *,
391
499
  memo: "dict[str, Any] | None" = None,
@@ -438,7 +546,7 @@ class Registry:
438
546
  needed.append(ref.urn)
439
547
 
440
548
  if needed:
441
- self._resolve_urns(needed, memo)
549
+ self._resolve_urns(cur, needed, memo)
442
550
 
443
551
  for owner, name, ref in pending:
444
552
  if ref._resolved is not None: # noqa: SLF001
@@ -479,6 +587,7 @@ class Registry:
479
587
 
480
588
  def _resolve_urns( # noqa: C901
481
589
  self,
590
+ cur: duckdb.DuckDBPyConnection,
482
591
  urns: "list[SdmxUrn[Any]]",
483
592
  memo: "dict[str, Any]",
484
593
  ) -> None:
@@ -507,7 +616,7 @@ class Registry:
507
616
  if scheme_info is None:
508
617
  continue
509
618
  _scheme_class_name, items_attr = scheme_info
510
- parent = self._load_parent_scheme(parent_urn_str, sample, memo)
619
+ parent = self._load_parent_scheme(cur, parent_urn_str, sample, memo)
511
620
  if parent is None:
512
621
  continue
513
622
  items = getattr(parent, items_attr, None)
@@ -522,7 +631,7 @@ class Registry:
522
631
  urn_str = str(parsed)
523
632
  if urn_str in memo:
524
633
  continue
525
- target = readers.get_artefact(self._c, urn_str)
634
+ target = readers.get_artefact(cur, urn_str, lazy_conn=self._conn)
526
635
  if target is not None:
527
636
  memo[urn_str] = target
528
637
 
@@ -547,6 +656,7 @@ class Registry:
547
656
 
548
657
  def _load_parent_scheme(
549
658
  self,
659
+ cur: duckdb.DuckDBPyConnection,
550
660
  parent_urn_str: str,
551
661
  sample: "SdmxUrn[Any]",
552
662
  memo: "dict[str, Any]",
@@ -559,12 +669,12 @@ class Registry:
559
669
  cached = memo.get(parent_urn_str)
560
670
  if cached is not None:
561
671
  return cached
562
- parent = readers.get_artefact(self._c, parent_urn_str, eager=True)
672
+ parent = readers.get_artefact(cur, parent_urn_str, eager=True)
563
673
  if parent is None and sample.version == "latest":
564
674
  scheme_info = _ITEM_TO_SCHEME.get(sample.artefact_class)
565
675
  if scheme_info is not None:
566
676
  scheme_class_name, _ = scheme_info
567
- row = self._c.execute(
677
+ row = cur.execute(
568
678
  """
569
679
  SELECT urn FROM artefact
570
680
  WHERE artefact_type = ? AND agency_id = ? AND resource_id = ?
@@ -572,7 +682,7 @@ class Registry:
572
682
  [scheme_class_name, sample.agency, sample.id],
573
683
  ).fetchone()
574
684
  if row:
575
- parent = readers.get_artefact(self._c, row[0], eager=True)
685
+ parent = readers.get_artefact(cur, row[0], eager=True)
576
686
  if parent is not None:
577
687
  memo[parent_urn_str] = parent
578
688
  return parent
@@ -587,7 +697,8 @@ class Registry:
587
697
 
588
698
  def __contains__(self, urn: object) -> bool:
589
699
  if isinstance(urn, SdmxUrn):
590
- row = self._c.execute(
700
+ cur = self._read_cursor()
701
+ row = cur.execute(
591
702
  "SELECT 1 FROM artefact WHERE urn = ?",
592
703
  [str(urn)],
593
704
  ).fetchone()
@@ -595,11 +706,13 @@ class Registry:
595
706
  return False
596
707
 
597
708
  def __len__(self) -> int:
598
- row = self._c.execute("SELECT COUNT(*) FROM artefact").fetchone()
709
+ cur = self._read_cursor()
710
+ row = cur.execute("SELECT COUNT(*) FROM artefact").fetchone()
599
711
  return row[0] # type: ignore[index]
600
712
 
601
713
  def __repr__(self) -> str:
602
- rows = self._c.execute("SELECT artefact_type, COUNT(*) FROM artefact GROUP BY artefact_type").fetchall()
714
+ cur = self._read_cursor()
715
+ rows = cur.execute("SELECT artefact_type, COUNT(*) FROM artefact GROUP BY artefact_type").fetchall()
603
716
  counts = ", ".join(f"{name}={cnt}" for name, cnt in rows)
604
717
  return f"Registry({counts or 'empty'})"
605
718
 
@@ -610,6 +723,8 @@ class Registry:
610
723
 
611
724
  Clears the resolver cache (cached resolved targets may reference
612
725
  the dropped artefact).
726
+
727
+ **Single-threaded** — see :meth:`add`.
613
728
  """
614
729
  result = writers.drop_artefact(self._c, str(urn))
615
730
  self._resolve_cache.clear()
@@ -620,6 +735,8 @@ class Registry:
620
735
 
621
736
  Each leg clears the resolver cache via :meth:`add` / :meth:`drop`;
622
737
  the net effect is a single fully-cleared cache at the end.
738
+
739
+ **Single-threaded** — see :meth:`add`.
623
740
  """
624
741
  writers.drop_artefact(self._c, str(old_urn))
625
742
  self.add(new)
@@ -655,11 +772,11 @@ class Registry:
655
772
 
656
773
  def code_map(self, codelist_urn: "str | SdmxUrn[Any]", *, lang: str) -> dict[str, str]:
657
774
  """Return {code_id: label} for a codelist without creating Code objects."""
658
- return readers.code_map(self._c, str(codelist_urn), lang=lang)
775
+ return readers.code_map(self._read_cursor(), str(codelist_urn), lang=lang)
659
776
 
660
777
  def dsd_code_map(self, dsd_urn: "str | SdmxUrn[Any]", *, lang: str) -> dict[str, dict[str, str]]:
661
778
  """Return {component_id: {code_id: label}} for all enumerated dimensions."""
662
- return readers.code_map_for_dsd(self._c, str(dsd_urn), lang=lang)
779
+ return readers.code_map_for_dsd(self._read_cursor(), str(dsd_urn), lang=lang)
663
780
 
664
781
  # ── Bulk ──────────────────────────────────────────────────────────────
665
782
 
@@ -687,12 +804,13 @@ class Registry:
687
804
  """
688
805
  reg = InMemoryRegistry()
689
806
  memo = _CallScopedMemo(self._resolve_cache)
690
- rows = self._c.execute("SELECT urn, artefact_type FROM artefact").fetchall()
691
- for urn_str, _artefact_type in rows:
692
- obj = readers.get_artefact(self._c, urn_str, eager=True)
693
- if obj is None:
694
- continue
695
- self._resolve_refs(obj, memo=memo)
696
- reg.add(obj)
807
+ with self._read_cursor() as cur:
808
+ rows = cur.execute("SELECT urn, artefact_type FROM artefact").fetchall()
809
+ for urn_str, _artefact_type in rows:
810
+ obj = readers.get_artefact(cur, urn_str, eager=True)
811
+ if obj is None:
812
+ continue
813
+ self._resolve_refs(cur, obj, memo=memo)
814
+ reg.add(obj)
697
815
  memo.promote()
698
816
  return reg