sdmxlib 0.28.0__tar.gz → 0.28.2__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.28.0 → sdmxlib-0.28.2}/PKG-INFO +1 -1
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/pyproject.toml +1 -1
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/local/_data_store.py +31 -2
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/local/_registry.py +65 -54
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/code_query.py +17 -2
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/hierarchy_query.py +17 -2
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/storage/lazy.py +29 -13
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/storage/readers.py +24 -2
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/README.md +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/__init__.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/api/__init__.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/api/client.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/api/federated.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/api/filters.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/api/policy.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/api/providers.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/api/query.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/api/registry.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/api/session.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/catalog.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/data_store.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/__init__.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_csv/__init__.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_csv/reader.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_csv/writer.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_json/__init__.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_json/metadata.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_json/reader.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_json/writer.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_ml21/__init__.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_ml21/namespaces.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_ml21/reader.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_ml21/writer.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_ml30/__init__.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_ml30/metadata.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_ml30/namespaces.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_ml30/reader.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/formats/sdmx_ml30/writer.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/local/__init__.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/__init__.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/annotations.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/base.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/binding.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/category.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/codelist.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/collections.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/concept.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/constraint.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/convert.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/dataflow.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/dataset.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/datastructure.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/expr.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/hierarchy.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/in_memory_registry.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/istring.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/mapping.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/message.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/metadataflow.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/metadataset.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/metadatastructure.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/organisation.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/provision.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/ref.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/registry.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/representation.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/urn.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/model/validation.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/polars.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/py.typed +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/rest.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/sql.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/storage/__init__.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/src/sdmxlib/storage/schema.py +0 -0
- {sdmxlib-0.28.0 → sdmxlib-0.28.2}/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
|
|
|
@@ -191,6 +195,15 @@ class DuckDBDataStore:
|
|
|
191
195
|
# Loading extensions can change the search_path; reset to
|
|
192
196
|
# ``sdmx`` so any colocated Registry's internal queries stay clean.
|
|
193
197
|
self._conn.execute("SET search_path = 'sdmx'")
|
|
198
|
+
if self._needs_azure():
|
|
199
|
+
# Force the named curl transport adapter. The Azure SDK's
|
|
200
|
+
# default transport on Linux uses a statically-linked HTTP
|
|
201
|
+
# stack that doesn't honour the standard ``SSL_CERT_FILE`` /
|
|
202
|
+
# ``CURL_CA_BUNDLE`` environment variables, so it fails on
|
|
203
|
+
# any image whose CA store is at a non-default path. The
|
|
204
|
+
# ``curl`` adapter delegates to libcurl, which reads those
|
|
205
|
+
# env vars at handshake time.
|
|
206
|
+
self._conn.execute("SET azure_transport_option_type = 'curl'")
|
|
194
207
|
if self._azure_credentials:
|
|
195
208
|
sql = _azure_create_secret_sql(self._azure_credentials)
|
|
196
209
|
self._conn.execute(sql)
|
|
@@ -227,6 +240,22 @@ class DuckDBDataStore:
|
|
|
227
240
|
if self._conn is not None and self._owns_connection:
|
|
228
241
|
self._conn.close()
|
|
229
242
|
self._conn = None
|
|
243
|
+
self._thread_local = threading.local()
|
|
244
|
+
|
|
245
|
+
def _read_cursor(self) -> duckdb.DuckDBPyConnection:
|
|
246
|
+
"""Return this thread's read cursor for ``query`` / ``query_stream``.
|
|
247
|
+
|
|
248
|
+
Mirror of :meth:`Registry._read_cursor` (#80). Lazily created on
|
|
249
|
+
first call per thread, reused for the thread's lifetime.
|
|
250
|
+
"""
|
|
251
|
+
cur = getattr(self._thread_local, "cur", None)
|
|
252
|
+
if cur is not None:
|
|
253
|
+
return cur
|
|
254
|
+
conn = self.connection
|
|
255
|
+
cur = conn.cursor()
|
|
256
|
+
cur.execute("SET search_path = 'sdmx'")
|
|
257
|
+
self._thread_local.cur = cur
|
|
258
|
+
return cur
|
|
230
259
|
|
|
231
260
|
# ── DataStore protocol ───────────────────────────────────────────────
|
|
232
261
|
|
|
@@ -267,8 +296,8 @@ class DuckDBDataStore:
|
|
|
267
296
|
order_by=order_by,
|
|
268
297
|
limit=limit,
|
|
269
298
|
)
|
|
270
|
-
#
|
|
271
|
-
cur =
|
|
299
|
+
# Thread-local cursor for issue #75 / #80 (concurrent reads).
|
|
300
|
+
cur = self._read_cursor().execute(sql, sql_params)
|
|
272
301
|
col_names = [d[0] for d in cur.description] if cur.description else []
|
|
273
302
|
rows = cur.fetchall()
|
|
274
303
|
if not rows:
|
|
@@ -31,8 +31,7 @@ stashes the URI; the DuckDB connection is opened by ``open()`` /
|
|
|
31
31
|
"""
|
|
32
32
|
|
|
33
33
|
import threading
|
|
34
|
-
from collections.abc import Iterable
|
|
35
|
-
from contextlib import contextmanager
|
|
34
|
+
from collections.abc import Iterable
|
|
36
35
|
from pathlib import Path
|
|
37
36
|
from typing import Any, Self
|
|
38
37
|
|
|
@@ -220,6 +219,10 @@ class Registry:
|
|
|
220
219
|
self._cursor: duckdb.DuckDBPyConnection | None = None
|
|
221
220
|
self._owns_connection = True
|
|
222
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()
|
|
223
226
|
|
|
224
227
|
@property
|
|
225
228
|
def uri(self) -> Path:
|
|
@@ -284,6 +287,7 @@ class Registry:
|
|
|
284
287
|
instance._cursor = None # noqa: SLF001
|
|
285
288
|
instance._owns_connection = False # noqa: SLF001
|
|
286
289
|
instance._resolve_cache = _FIFOCache(resolve_cache_size) # noqa: SLF001
|
|
290
|
+
instance._thread_local = threading.local() # noqa: SLF001
|
|
287
291
|
instance._initialise_schema() # noqa: SLF001
|
|
288
292
|
return instance
|
|
289
293
|
|
|
@@ -317,11 +321,19 @@ class Registry:
|
|
|
317
321
|
"""Close the database connection (only if we own it).
|
|
318
322
|
|
|
319
323
|
After ``close()``, the registry can be re-opened with ``open()``.
|
|
324
|
+
Drops the per-thread cursor cache — reopen starts fresh.
|
|
320
325
|
"""
|
|
321
326
|
if self._conn is not None and self._owns_connection:
|
|
322
327
|
self._conn.close()
|
|
323
328
|
self._conn = None
|
|
324
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()
|
|
325
337
|
|
|
326
338
|
@property
|
|
327
339
|
def _c(self) -> duckdb.DuckDBPyConnection:
|
|
@@ -354,22 +366,25 @@ class Registry:
|
|
|
354
366
|
raise RuntimeError(msg)
|
|
355
367
|
return self._conn
|
|
356
368
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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).
|
|
372
384
|
"""
|
|
385
|
+
cur = getattr(self._thread_local, "cur", None)
|
|
386
|
+
if cur is not None:
|
|
387
|
+
return cur
|
|
373
388
|
if self._conn is None:
|
|
374
389
|
msg = (
|
|
375
390
|
f"Registry({self._uri!r}) is not open. Use it as a context manager "
|
|
@@ -377,11 +392,9 @@ class Registry:
|
|
|
377
392
|
)
|
|
378
393
|
raise RuntimeError(msg)
|
|
379
394
|
cur = self._conn.cursor()
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
finally:
|
|
384
|
-
cur.close()
|
|
395
|
+
cur.execute("SET search_path = 'sdmx'")
|
|
396
|
+
self._thread_local.cur = cur
|
|
397
|
+
return cur
|
|
385
398
|
|
|
386
399
|
# ── Add ───────────────────────────────────────────────────────────────
|
|
387
400
|
|
|
@@ -449,15 +462,15 @@ class Registry:
|
|
|
449
462
|
dsd.dimensions["FREQ"].codelist() # -> LazyCodelist
|
|
450
463
|
dsd.code_map(lang="en") # works
|
|
451
464
|
"""
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
465
|
+
cur = self._read_cursor()
|
|
466
|
+
result = readers.get_artefact(cur, str(urn), eager=eager, lazy_conn=self._conn)
|
|
467
|
+
if result is None:
|
|
468
|
+
return None
|
|
469
|
+
if not isinstance(result, (LazyCodelist, LazyHierarchy)):
|
|
470
|
+
memo = _CallScopedMemo(self._resolve_cache)
|
|
471
|
+
self._resolve_refs(cur, result, memo=memo)
|
|
472
|
+
memo.promote()
|
|
473
|
+
return result # type: ignore[return-value]
|
|
461
474
|
|
|
462
475
|
def get_all[T](self, artefact_type: "type[T]") -> "list[T]":
|
|
463
476
|
"""All artefacts of the given Python type.
|
|
@@ -467,14 +480,14 @@ class Registry:
|
|
|
467
480
|
parent scheme referenced by multiple artefacts is loaded once,
|
|
468
481
|
not once per artefact.
|
|
469
482
|
"""
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
483
|
+
cur = self._read_cursor()
|
|
484
|
+
results = readers.get_all(cur, artefact_type, lazy_conn=self._conn)
|
|
485
|
+
memo = _CallScopedMemo(self._resolve_cache)
|
|
486
|
+
for r in results:
|
|
487
|
+
if not isinstance(r, (LazyCodelist, LazyHierarchy)):
|
|
488
|
+
self._resolve_refs(cur, r, memo=memo)
|
|
489
|
+
memo.promote()
|
|
490
|
+
return results # type: ignore[return-value]
|
|
478
491
|
|
|
479
492
|
# ── Ref resolution ─────────────────────────────────────────────────
|
|
480
493
|
|
|
@@ -684,22 +697,22 @@ class Registry:
|
|
|
684
697
|
|
|
685
698
|
def __contains__(self, urn: object) -> bool:
|
|
686
699
|
if isinstance(urn, SdmxUrn):
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
700
|
+
cur = self._read_cursor()
|
|
701
|
+
row = cur.execute(
|
|
702
|
+
"SELECT 1 FROM artefact WHERE urn = ?",
|
|
703
|
+
[str(urn)],
|
|
704
|
+
).fetchone()
|
|
705
|
+
return row is not None
|
|
693
706
|
return False
|
|
694
707
|
|
|
695
708
|
def __len__(self) -> int:
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
709
|
+
cur = self._read_cursor()
|
|
710
|
+
row = cur.execute("SELECT COUNT(*) FROM artefact").fetchone()
|
|
711
|
+
return row[0] # type: ignore[index]
|
|
699
712
|
|
|
700
713
|
def __repr__(self) -> str:
|
|
701
|
-
|
|
702
|
-
|
|
714
|
+
cur = self._read_cursor()
|
|
715
|
+
rows = cur.execute("SELECT artefact_type, COUNT(*) FROM artefact GROUP BY artefact_type").fetchall()
|
|
703
716
|
counts = ", ".join(f"{name}={cnt}" for name, cnt in rows)
|
|
704
717
|
return f"Registry({counts or 'empty'})"
|
|
705
718
|
|
|
@@ -759,13 +772,11 @@ class Registry:
|
|
|
759
772
|
|
|
760
773
|
def code_map(self, codelist_urn: "str | SdmxUrn[Any]", *, lang: str) -> dict[str, str]:
|
|
761
774
|
"""Return {code_id: label} for a codelist without creating Code objects."""
|
|
762
|
-
|
|
763
|
-
return readers.code_map(cur, str(codelist_urn), lang=lang)
|
|
775
|
+
return readers.code_map(self._read_cursor(), str(codelist_urn), lang=lang)
|
|
764
776
|
|
|
765
777
|
def dsd_code_map(self, dsd_urn: "str | SdmxUrn[Any]", *, lang: str) -> dict[str, dict[str, str]]:
|
|
766
778
|
"""Return {component_id: {code_id: label}} for all enumerated dimensions."""
|
|
767
|
-
|
|
768
|
-
return readers.code_map_for_dsd(cur, str(dsd_urn), lang=lang)
|
|
779
|
+
return readers.code_map_for_dsd(self._read_cursor(), str(dsd_urn), lang=lang)
|
|
769
780
|
|
|
770
781
|
# ── Bulk ──────────────────────────────────────────────────────────────
|
|
771
782
|
|
|
@@ -35,9 +35,24 @@ if TYPE_CHECKING:
|
|
|
35
35
|
import duckdb
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
import threading as _threading
|
|
39
|
+
import weakref as _weakref
|
|
40
|
+
|
|
41
|
+
# Per-thread per-connection cursor store (#80). See ``sdmxlib.storage.lazy._cur``.
|
|
42
|
+
_thread_local = _threading.local()
|
|
43
|
+
|
|
44
|
+
|
|
38
45
|
def _cur(conn: duckdb.DuckDBPyConnection) -> duckdb.DuckDBPyConnection:
|
|
39
|
-
"""
|
|
40
|
-
|
|
46
|
+
"""Return this thread's cursor for ``conn`` (issue #80)."""
|
|
47
|
+
table = getattr(_thread_local, "table", None)
|
|
48
|
+
if table is None:
|
|
49
|
+
table = _weakref.WeakKeyDictionary()
|
|
50
|
+
_thread_local.table = table
|
|
51
|
+
cur = table.get(conn)
|
|
52
|
+
if cur is None:
|
|
53
|
+
cur = conn.cursor()
|
|
54
|
+
table[conn] = cur
|
|
55
|
+
return cur
|
|
41
56
|
|
|
42
57
|
|
|
43
58
|
# ── Field expressions (AST nodes) ───────────────────────────────────────
|
|
@@ -30,9 +30,24 @@ if TYPE_CHECKING:
|
|
|
30
30
|
import duckdb
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
import threading as _threading
|
|
34
|
+
import weakref as _weakref
|
|
35
|
+
|
|
36
|
+
# Per-thread per-connection cursor store (#80). See ``sdmxlib.storage.lazy._cur``.
|
|
37
|
+
_thread_local = _threading.local()
|
|
38
|
+
|
|
39
|
+
|
|
33
40
|
def _cur(conn: duckdb.DuckDBPyConnection) -> duckdb.DuckDBPyConnection:
|
|
34
|
-
"""
|
|
35
|
-
|
|
41
|
+
"""Return this thread's cursor for ``conn`` (issue #80)."""
|
|
42
|
+
table = getattr(_thread_local, "table", None)
|
|
43
|
+
if table is None:
|
|
44
|
+
table = _weakref.WeakKeyDictionary()
|
|
45
|
+
_thread_local.table = table
|
|
46
|
+
cur = table.get(conn)
|
|
47
|
+
if cur is None:
|
|
48
|
+
cur = conn.cursor()
|
|
49
|
+
table[conn] = cur
|
|
50
|
+
return cur
|
|
36
51
|
|
|
37
52
|
|
|
38
53
|
# ── Field expressions ────────────────────────────────────────────────────
|
|
@@ -8,6 +8,9 @@ Hierarchy navigation (``roots``, ``leaves``, ``narrower``,
|
|
|
8
8
|
appropriate persisted table (``sdmx.code`` or ``sdmx.hierarchy_node``).
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
+
import threading
|
|
12
|
+
import weakref
|
|
13
|
+
|
|
11
14
|
import duckdb
|
|
12
15
|
|
|
13
16
|
from sdmxlib.model.code_query import (
|
|
@@ -46,25 +49,38 @@ def _map_to_istring(m: dict[str, str] | None) -> InternationalString:
|
|
|
46
49
|
return InternationalString.of(m)
|
|
47
50
|
|
|
48
51
|
|
|
52
|
+
# Per-thread per-connection cursor store (#75 / #80). One cursor per
|
|
53
|
+
# (thread, connection) pair, created lazily on first use, reused for the
|
|
54
|
+
# thread's lifetime. Connections are held weakly so closing a Registry
|
|
55
|
+
# (which closes its connection and drops references) lets the entry GC
|
|
56
|
+
# without needing explicit cleanup hooks — no stale cursors after id
|
|
57
|
+
# reuse.
|
|
58
|
+
_thread_local = threading.local()
|
|
59
|
+
|
|
60
|
+
|
|
49
61
|
def _cur(conn: duckdb.DuckDBPyConnection) -> duckdb.DuckDBPyConnection:
|
|
50
|
-
"""
|
|
62
|
+
"""Return this thread's cursor for ``conn``, creating on first call.
|
|
51
63
|
|
|
52
64
|
Lazy facades and query classes share a long-lived connection across
|
|
53
|
-
method calls, but each ``execute()`` needs its own cursor:
|
|
54
|
-
reuses a single default-cursor row buffer per connection, and
|
|
65
|
+
method calls, but each ``execute()`` needs its own per-thread cursor:
|
|
66
|
+
DuckDB reuses a single default-cursor row buffer per connection, and
|
|
55
67
|
concurrent ``.execute()`` calls on the same buffer overwrite each
|
|
56
|
-
other's results.
|
|
57
|
-
|
|
58
|
-
No ``SET search_path`` here — all SQL in this module qualifies
|
|
59
|
-
tables with ``sdmx.`` explicitly, so a fresh cursor's lack of
|
|
60
|
-
inherited search path doesn't matter. (Other code paths that issue
|
|
61
|
-
unqualified SQL set search_path themselves.)
|
|
68
|
+
other's results (issue #75). Thread-local reuse amortises the
|
|
69
|
+
``cursor()`` cost across the thread's lifetime (issue #80).
|
|
62
70
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
71
|
+
No ``SET search_path`` — all SQL in this module qualifies tables with
|
|
72
|
+
``sdmx.`` explicitly. (Other code paths that issue unqualified SQL
|
|
73
|
+
set search_path themselves.)
|
|
66
74
|
"""
|
|
67
|
-
|
|
75
|
+
table = getattr(_thread_local, "table", None)
|
|
76
|
+
if table is None:
|
|
77
|
+
table = weakref.WeakKeyDictionary()
|
|
78
|
+
_thread_local.table = table
|
|
79
|
+
cur = table.get(conn)
|
|
80
|
+
if cur is None:
|
|
81
|
+
cur = conn.cursor()
|
|
82
|
+
table[conn] = cur
|
|
83
|
+
return cur
|
|
68
84
|
|
|
69
85
|
|
|
70
86
|
class LazyCodelist:
|
|
@@ -13,6 +13,8 @@ Example::
|
|
|
13
13
|
cm = readers.code_map(conn, codelist_urn, lang="en") # {code_id: label}
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
|
+
import threading
|
|
17
|
+
import weakref
|
|
16
18
|
from typing import Any, cast
|
|
17
19
|
|
|
18
20
|
import duckdb
|
|
@@ -67,6 +69,26 @@ from sdmxlib.storage.writers import _CLASS_TO_NAME
|
|
|
67
69
|
_TYPE_REGISTRY: dict[str, type] = {v: k for k, v in _CLASS_TO_NAME.items()}
|
|
68
70
|
|
|
69
71
|
|
|
72
|
+
# Per-thread cursor store for ``code_map`` / ``code_map_for_dsd`` (#80).
|
|
73
|
+
# Held weakly so a closed Registry's connection doesn't pin a stale
|
|
74
|
+
# cursor entry (avoids the id-reuse hazard a plain id-keyed dict would
|
|
75
|
+
# have when Python recycles the connection's id after GC).
|
|
76
|
+
_thread_local = threading.local()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _thread_cursor(conn_or_cur: duckdb.DuckDBPyConnection) -> duckdb.DuckDBPyConnection:
|
|
80
|
+
table = getattr(_thread_local, "table", None)
|
|
81
|
+
if table is None:
|
|
82
|
+
table = weakref.WeakKeyDictionary()
|
|
83
|
+
_thread_local.table = table
|
|
84
|
+
cur = table.get(conn_or_cur)
|
|
85
|
+
if cur is None:
|
|
86
|
+
cur = conn_or_cur.cursor()
|
|
87
|
+
cur.execute("SET search_path = 'sdmx'")
|
|
88
|
+
table[conn_or_cur] = cur
|
|
89
|
+
return cur
|
|
90
|
+
|
|
91
|
+
|
|
70
92
|
# ── Shared helpers ──────────────────────────────────────────────────────
|
|
71
93
|
|
|
72
94
|
|
|
@@ -1547,7 +1569,7 @@ def code_map(
|
|
|
1547
1569
|
upfront configuration. Opens its own per-call cursor (issue #75):
|
|
1548
1570
|
safe to call concurrently from threads sharing the same connection.
|
|
1549
1571
|
"""
|
|
1550
|
-
cur = conn
|
|
1572
|
+
cur = _thread_cursor(conn)
|
|
1551
1573
|
rows = cur.execute(
|
|
1552
1574
|
f"""
|
|
1553
1575
|
SELECT code_id, name['{lang}'] AS label
|
|
@@ -1570,7 +1592,7 @@ def code_map_for_dsd(
|
|
|
1570
1592
|
|
|
1571
1593
|
Single JOIN query — no N+1. Per-call cursor as in :func:`code_map`.
|
|
1572
1594
|
"""
|
|
1573
|
-
cur = conn
|
|
1595
|
+
cur = _thread_cursor(conn)
|
|
1574
1596
|
rows = cur.execute(
|
|
1575
1597
|
f"""
|
|
1576
1598
|
SELECT dc.component_id, c.code_id, c.name['{lang}'] AS label
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|