Federation¶
Recipes for the federation stack — composing a remote SDMX registry with local cache and Parquet-backed observations behind one registry.
The headline use case: structures live at a remote registry (FMR,
ECB, ESTAT), observations live in your own Parquet store, and one
LocalRegistry walks both planes.
Cache-mode federation¶
A RestRegistry configured with store=local writes every
fetched artefact through to a LocalRegistry. Subsequent fetches of
the same pinned URN skip HTTP entirely.
import sdmxlib as sl
with (
sl.LocalRegistry("cache.duckdb", source_registry="local") as local,
sl.RestRegistry(sl.Provider.ECB, store=local) as reg,
):
dsd = reg.get(sl.DataStructure.urn_for(agency="ECB", id="ECB_EXR1"), resolve=True)
What happened:
Provider.ECBcarriesid="ECB", so the local store tags every row withsource_registry="ECB". Provenance survives the cache.- The first fetch hit the network. The second time you open
cache.duckdband ask for the same URN, it's served from DuckDB — no network round-trip. (latest)URNs are the one exception: alatestis a pointer, not an artefact, so it is never answered fromcache.duckdb. How long one may be reused within a session is the provider's call — see How fresh alatestlookup is.
The local-store-as-cache replaces a separate HTTP cache layer for sdmxlib's read patterns. Provider IDs, materialise tagging, and the read-before-fetch hook all compose without extra dependencies.
Routing multiple registries by agency¶
A FederatedRegistry dispatches each URN-keyed lookup by the URN's
maintenance agency. Each agency has exactly one source.
import sdmxlib as sl
with sl.LocalRegistry("cache.duckdb", source_registry="local") as local:
estat = sl.RestRegistry(sl.Provider.ESTAT, store=local)
sdmx = sl.RestRegistry(sl.Provider.SDMX_GLOBAL, store=local)
fed = sl.FederatedRegistry(
routing={"ESTAT": estat, "SDMX": sdmx},
default=local, # locally-authored agencies dispatch here
)
# Routes by maintenance agency:
dsd = fed.get(sl.DataStructure.urn_for(agency="ESTAT", id="X", version="1.0"))
cl = fed.get(sl.Codelist.urn_for(agency="SDMX", id="CL_FREQ", version="2.1"))
own = fed.get(sl.Codelist.urn_for(agency="MY_ORG", id="CL_X", version="1.0"))
fed itself satisfies the sl.RegistryReader Protocol — it can be a source in
another FederatedRegistry if you want layered routing.
resolve= and partial= through a federation¶
Both mean what they mean on RestRegistry, and you pass them to fed.get
without knowing which source will answer:
own_urn = sl.Codelist.urn_for(agency="MY_ORG", id="CL_X", version="1.0")
with (
sl.LocalRegistry("cache.duckdb", source_registry="local") as local,
sl.RestRegistry(sl.Provider.ESTAT, store=local) as estat,
):
fed = sl.FederatedRegistry(routing={"ESTAT": estat}, default=local)
fed.get(sl.DataStructure.urn_for(agency="ESTAT", id="CPI"), resolve=True)
fed.get(own_urn, resolve=True)
The second one routes to local, whose get takes no resolve= at all —
so the argument is translated, not forwarded. Routing is by agency and the
agency is in the URN, so the federation knows which source will answer before
it asks.
resolve= is dropped for a local source. Depth is a choice about how many
requests to make, and a store has already paid for all of them: it resolves
unconditionally. The artefact you get back already satisfies resolve=True,
and resolve=False only declined work that was not going to happen.
partial=False is refused instead, because it has no such reading — a
store holds whatever was written to it, so it can neither give you the
untrimmed codelists nor tell that it has not:
with sl.LocalRegistry("cache.duckdb", source_registry="local") as local:
fed = sl.FederatedRegistry(routing={}, default=local)
try:
fed.get(own_urn, partial=False)
except TypeError as exc:
print(exc)
# partial=False routes to LocalRegistry, which cannot promise it:
# LocalRegistry.get takes no partial=, and a store holds whatever was
# written to it. Ask the source that fetches — fed.source_for(urn) —
# or accept what LocalRegistry has for …
Answering that with trimmed codelists would be a wrong answer to an explicit
question. Ask the source that fetches — fed.source_for(urn) — or accept what
the store has.
When a source is unreachable¶
None means the source answered "not here"; an unreachable source raises.
They are different facts and the caller's response differs — retry a dead
source, fall back for a missing artefact — so reporting them identically makes
the distinction unrecoverable (design §5.8).
import sdmxlib as sl
with sl.RestRegistry(sl.Provider.ESTAT) as estat:
fed = sl.FederatedRegistry(routing={"ESTAT": estat})
try:
result = fed.get(sl.Codelist.urn_for(agency="ESTAT", id="X", version="1.0"))
except sl.RegistryUnavailable as exc:
exc.source_id # which source
exc.tried # what was being read
exc.cause # the underlying transport error
else:
if result is None:
... # genuinely absent, and only that
To continue past a dead source in a sweep, write the loop that says so:
with sl.RestRegistry(sl.Provider.ESTAT) as estat:
fed = sl.FederatedRegistry(routing={"ESTAT": estat})
for urn in urns:
try:
artefact = fed.get(urn)
except sl.RegistryUnavailable as exc:
log.warning("skipping %s: %s", urn, exc)
continue
if artefact is None:
... # absent
A FailurePolicy enum used to sit here, and RETURN_NONE was documented as
"source down or artefact missing — same outcome to the caller". It was
deleted rather than re-scoped: its stated job was whether a federation
continues past a dead source, and FederatedRegistry routes each URN to
exactly one source by agency — so there was no next source to continue to and
the knob only ever converted an error into a silence.
Programmer errors (ValueError, TypeError) propagate unchanged: only
transport failures mean "could not reach". A 404 is the server answering,
so it comes back as None; a 500 is the server's own error and is raised.
How fresh a latest lookup is¶
A pinned URN names an immutable artefact and is cached indefinitely. latest
names a pointer, and how long it may go un-re-asked is the provider's
call, read from Cache-Control and ETag:
reg = sl.RestRegistry(sl.Provider.OECD) # says max-age=86400 on structures
reg.get(urn) # one request
reg.get(urn) # served locally, inside the window
Providers differ, and the difference is real — measured from recorded responses:
| provider | structures | revalidation |
|---|---|---|
| ECB, IMF | no-cache, no-store, max-age=0, must-revalidate |
none |
| OECD | public, max-age=86400 |
Last-Modified |
| SDMX Global Registry | (absent) | ETag |
Where a validator is offered, re-asking costs a 304 rather than a body.
Where nothing is offered, every ask goes to the provider — the library invents
no window of its own.
latest_ttl overrides a provider that is too stingy, and only in that
direction:
# ECB sends `no-store` on codelists, and `isFinal="false"` on everything.
# Both look like appliance defaults rather than statements about volatility.
reg = sl.RestRegistry(sl.Provider.ECB, latest_ttl=300)
It is a floor, never a ceiling: it cannot shorten a window a provider granted. To ask for something that cannot go stale, pass a pinned URN — that is not a pointer at all.
Binding a dataflow to a Parquet table¶
A DataflowBinding is a first-class artefact that says "the
observations for this dataflow live at this table location, with this
column layout, written against this DSD version." It lives in the
local store alongside structural artefacts.
from datetime import UTC, datetime
import sdmxlib as sl
flow_urn = sl.Dataflow.urn_for(agency="STATCAN", id="CENSUS_PROFILE", version="2021")
binding = sl.DataflowBinding(
dataflow=sl.Ref[sl.Dataflow].unresolved(flow_urn),
database_role="lake", # the ATTACH alias of the database owning the table
table_location="census_2021.observations",
partition_layout=("GEO_LEVEL",),
dimension_column_map={"REF_AREA": "REF_AREA", "TIME_PERIOD": "TIME_PERIOD"},
measure_column_map={"OBS_COUNT": "OBS_COUNT"},
attribute_column_map={"GEO_NAME": "GEO_NAME"},
structural_version="STATCAN:DSD_CP(2021)",
source_registry="local",
materialized_at=datetime(2026, 4, 30, 12, 0, tzinfo=UTC),
bound_at=datetime(2026, 4, 30, 12, 0, tzinfo=UTC),
)
with sl.LocalRegistry("lake.duckdb", source_registry="local") as local:
local.add(binding)
Round-trips through LocalRegistry.add / get like any other artefact:
the binding's URN mirrors the dataflow's identity (one binding per
dataflow per version).
End-to-end: structures from a registry, data from DuckLake¶
A registry holds artefacts, holds the bindings that are registrations over
them, and owns the store their rows live in — so one object answers both
planes. Federating the structural side does not change that: a
FederatedRegistry routes structure by agency, and the local registry is where
the bindings and the lake are.
import sdmxlib as sl
# The local store: bindings, cached structures, and the observation lake.
# `LocalRegistry.bundle("/srv/census-2021")` opens a published one the same way.
with sl.LocalRegistry("lake.duckdb", source_registry="local") as local:
# Federate the *structural* side into it.
estat = sl.RestRegistry(sl.Provider.ESTAT, store=local)
sdmx = sl.RestRegistry(sl.Provider.SDMX_GLOBAL, store=local)
fed = sl.FederatedRegistry(routing={"ESTAT": estat, "SDMX": sdmx}, default=local)
# Structures come from wherever the routing says…
dsd = fed.get(sl.DataStructure.urn_for(agency="ESTAT", id="CPI"), resolve=True)
# …and observations from the registry that has the rows.
obs = local.obs(
sl.Dataflow.urn_for(agency="STATCAN", id="CENSUS_PROFILE", version="2021")
)
df = obs.filter(obs.col("REF_AREA") == "CA").frame(labels="en")
Why there is no catalog to build
This example used to end by composing a FederatedCatalog from three
roles — structural, bindings, data. Two of the three were almost
always the same object, and every method it had was look up the binding
for this dataflow, then delegate. A registry does that itself.
Notes on the moving parts:
DuckDBDataStoreshares a connection withLocalRegistry. The structural and observation sides live in one DuckDB session, so cross-database joins (lazy codelists insdmx.*joining observations inlake.*) work without file-handle juggling.- Filter clauses use SDMX dimension/measure ids. The binding's column
maps determine the physical column names — for v1, identity mapping
(
REF_AREA → REF_AREA) is the common case. frame(labels="en")adds aREF_AREA_label_encolumn per enumerated component, read from the dataflow's DSD in the registry — the data store itself never sees a codelist.
Operational tooling¶
Browse all locally-bound dataflows¶
with sl.LocalRegistry("lake.duckdb") as local:
for entry in local.browse():
name = entry.name["en"] if entry.name else "(no name)"
print(f"{entry.dataflow_urn} — {name} [source={entry.source_registry}]")
browse() returns DataflowEntry rows: dataflow_urn, binding,
name (resolved from the structural side), and source_registry
(provenance). v1 lists only locally-bound dataflows — remote-only
listing is on the follow-up roadmap.
Detect drift after upstream republishes¶
with (
sl.LocalRegistry("lake.duckdb") as local,
sl.RestRegistry(sl.Provider.ESTAT, store=local) as estat,
):
fed = sl.FederatedRegistry(routing={"ESTAT": estat}, default=local)
for entry in local.browse():
for issue in fed.check_binding_drift(entry.binding):
print(f"[{issue.kind}] {entry.dataflow_urn}: {issue.description}")
check_binding_drift returns one DriftIssue per detected mismatch
— empty list means clean. Use it for inspection (display drift in a
UI, log it, decide what to do). For fail-loud enforcement (refuse to
proceed if anything has drifted), call validate_binding(binding) —
that variant raises DriftError carrying the full issue list.
The drift kinds reported in v1:
dataflow_missing— the dataflow disappeared upstream.dsd_unresolvable— the dataflow exists but its DSD ref can't be resolved.structural_version_drift— the binding's pinned DSD version no longer matches the registry's current DSD URN. The data was written against an older codelist set; re-bind or re-ingest.
Detailed codelist content diffs (added/removed/renamed codes, dimension reorder) are tracked as a follow-up.
Provenance lookup¶
Every materialised artefact carries source_registry in the
artefact spine. To audit cache provenance directly:
with sl.LocalRegistry("cache.duckdb") as local:
rows = local.execute("""
SELECT artefact_type, agency_id, resource_id, source_registry, fetched_at
FROM sdmx.artefact
WHERE source_registry IS NOT NULL
ORDER BY artefact_type, agency_id, resource_id
""")
for row in rows:
print(row)
Custom SQL against the data plane¶
DuckDBDataStore.obs (the path language over a binding) covers the
common read paths, but
some consumers need SQL those methods don't express — per-value
printf formatting, Hive-partition-pruning filters, wide pivots, custom
pagination. Two supported seams make this safe without reaching into
private helpers:
store.session()/store.execute()— the escape hatch for running your own SQL. Both use the same per-thread handle the DSL runs on, so settings and registered relations are shared with it.store.inspect()— aSchemaInspectorthat reflects the live schema (cachedDESCRIBE) and turns aDataflowBindinginto aQueryPlan, so you reuse the store's table-name convention and sidecar join-key derivation instead of rebuilding them.
with sl.LocalRegistry("lake.duckdb") as local:
store = sl.DuckDBDataStore.from_registry(local)
insp = store.inspect()
# Reflection — column discovery for your own JOINs.
cols = insp.columns("census_2021.observations")
# Planning — resolve a binding's FROM side, then splice your own SQL onto it.
plan = insp.plan(binding) # QueryPlan(main, sidecar, join_keys, from_clause)
sql = (
f"SELECT REF_AREA, printf('%d', OBS_COUNT) AS value "
f"FROM {plan.from_clause} WHERE GEO_LEVEL = ?" # from_clause is sdmxlib-derived
)
with store.session() as active:
rows = active.execute(sql, ["CTRY"]).fetchall()
# or, for one statement and no handle to hold:
rows = store.execute(sql, ["CTRY"])
insp.shared_columns(a, b) derives the join keys when observations and their
series attributes live in two tables.
store.connection is still there, but it is the store's base connection —
a single object shared by every thread, and the one the library stopped
executing on in #75/#80 because DuckDBPyConnection is not thread-safe. Use it
for setup and DDL, or to hand to a component that derives its own handle. For
running queries, use session() or execute().
inspect() returns the same inspector the internal query path uses, so its
DESCRIBE cache is shared — you don't pay repeated reflection cost, and you
don't need to memoize it yourself. The cache is invalidated automatically
when write_observations rewrites a table.
Patterns¶
- One agency, one source.
FederatedRegistryv1 routes by agency without arbitration. Two registries publishing the same URN to the same local store trigger aRuntimeWarning(last-write-wins). - Local store is the cache. Materialise-through fills the local
store; the read-before-fetch hook reads from it. Pinned URNs cache
forever;
(latest)URNs are never served from the store, only reused in-process for as long as the provider'sCache-Controlallows. - Bindings are immutable references to pinned versions. When the
upstream DSD republishes,
check_binding_driftenumerates the mismatches andvalidate_bindingraises on them; you decide whether to re-ingest or re-bind. Following(latest)in a binding is opt-in via thestructural_versionfield. - The data store knows nothing about codelists. Result
annotation runs in the registry, using the resolved DSD from the
structural side. The
DataStoreProtocol stays minimal so non-DuckDB backends (Iceberg, Spark) can drop in without re-implementing labelling.