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.
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/PKG-INFO +1 -1
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/pyproject.toml +1 -1
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/local/_data_store.py +28 -2
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/local/_registry.py +181 -63
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/code_query.py +52 -17
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/hierarchy_query.py +50 -17
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/storage/lazy.py +173 -68
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/storage/readers.py +76 -20
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/README.md +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/__init__.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/api/__init__.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/api/client.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/api/federated.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/api/filters.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/api/policy.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/api/providers.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/api/query.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/api/registry.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/api/session.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/catalog.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/data_store.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/__init__.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_csv/__init__.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_csv/reader.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_csv/writer.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_json/__init__.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_json/metadata.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_json/reader.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_json/writer.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_ml21/__init__.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_ml21/namespaces.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_ml21/reader.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_ml21/writer.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_ml30/__init__.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_ml30/metadata.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_ml30/namespaces.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_ml30/reader.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/formats/sdmx_ml30/writer.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/local/__init__.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/__init__.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/annotations.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/base.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/binding.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/category.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/codelist.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/collections.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/concept.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/constraint.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/convert.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/dataflow.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/dataset.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/datastructure.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/expr.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/hierarchy.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/in_memory_registry.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/istring.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/mapping.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/message.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/metadataflow.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/metadataset.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/metadatastructure.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/organisation.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/provision.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/ref.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/registry.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/representation.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/urn.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/model/validation.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/polars.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/py.typed +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/rest.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/sql.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/storage/__init__.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/storage/schema.py +0 -0
- {sdmxlib-0.27.2 → sdmxlib-0.28.1}/src/sdmxlib/storage/writers.py +0 -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
65
|
-
"""Bounded
|
|
64
|
+
class _FIFOCache(dict[str, Any]):
|
|
65
|
+
"""Bounded resolver cache. FIFO eviction, lock-free reads.
|
|
66
66
|
|
|
67
|
-
Subclasses :class:`
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
self.
|
|
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
|
|
131
|
+
"""Per-call resolver memo layered over the persistent FIFO cache.
|
|
111
132
|
|
|
112
|
-
Two reasons for the overlay rather than passing the
|
|
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
|
|
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
|
-
|
|
123
|
-
partial-failure semantics clean: if a call raises
|
|
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,
|
|
148
|
+
def __init__(self, cache: _FIFOCache) -> None:
|
|
128
149
|
super().__init__()
|
|
129
|
-
self.
|
|
150
|
+
self._cache = cache
|
|
130
151
|
|
|
131
152
|
def __contains__(self, key: object) -> bool:
|
|
132
|
-
return super().__contains__(key) or self.
|
|
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.
|
|
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
|
|
161
|
+
"""Copy this call's resolved entries into the persistent cache."""
|
|
141
162
|
for k, v in self.items():
|
|
142
|
-
self.
|
|
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
|
|
179
|
-
:meth:`get` / :meth:`get_all` / :meth:`to_registry`
|
|
180
|
-
calls. Pass ``0`` to disable caching (matches
|
|
181
|
-
— one full read per Ref). The cache is
|
|
182
|
-
(``add`` / ``drop`` / ``replace``) for
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|