Skip to content

Adds duckdb-backed local adapter - #625

Draft
will-langdale wants to merge 3 commits into
feat/localmbfrom
feat/localmb-localadapter
Draft

Adds duckdb-backed local adapter#625
will-langdale wants to merge 3 commits into
feat/localmbfrom
feat/localmb-localadapter

Conversation

@will-langdale

@will-langdale will-langdale commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Adds a duckdb-backed local adapter.

Part of #560.

🛠️ Changes proposed in this pull request

  • Adds a local adapter ABC
  • Adds duckdb-backed adapter class
  • Adds duckdb ORM and tests. Note that autoincrement keys needed some interesting piping

👀 Guidance to review

I tried to factor out more stuff into the common SQL module. Do you agree with my decisions? Did I go too far, or not far enough? The principle I was trying to follow was to keep common subqueries separate to where they're hooked up to specific ORM tables or dialect mechanics. This is why inner functions of insert are abstracted rather than the whole thing.

Are you happy with how components in the two ORMs are shared? I was sceptical of the ORM as there's some repeated code in things like SourceConfigs, but given the alternatives (a different type of mixins, subclasses), this way seems smallest, seeing your ORM in one place is good, and I didn't want to add leaky abstractions.

Do you agree with the decision not to have a package duckdb singleton, so you can create lots of instances if you wish? We can't control what the user does and how they import like we do on the server.

Is it time to review how resolvers are stored locally, simplify query's logic, and cut it entirely from the server? Or is it simpler to mirror the logic here and pick it up later? Two facets: is it the right move AND will is swell the PR?

We implement _cascade_invalidate because duckdb (rightly) lacks delete cascade: it's just not made for that type of OLTP type work.

🤖 AI declaration

AI written but human reviewed throughout. Went through 2-3 iterations and spikes to get to this shape, including toys to validate duckdb mechanisms like sequences.

✅ Checklist:

  • This is the smallest, simplest solution to the problem
  • I've read our code standards and this code follows them
  • All new code is tested
  • I've updated all relevant documentation (select all that apply)
    • API documentation (docstrings and indexes)
    • Tutorials
    • Developer docs

@will-langdale will-langdale changed the title feat: adds duckdb local adapter Adds duckdb-backed local adapter Jul 21, 2026
@will-langdale
will-langdale force-pushed the feat/localmb-localadapter branch 2 times, most recently from 65c7dc0 to 8605086 Compare July 22, 2026 16:07
Signed-off-by: DBT pre-commit check
@will-langdale
will-langdale force-pushed the feat/localmb-localadapter branch from 8605086 to de3ab04 Compare July 22, 2026 16:15
backend.restore(snapshot=snapshot)
_testkitdag_to_location(warehouse, dag_testkit)
if isinstance(backend, MatchboxLocalDBAdapter):
backend.bind(dag_testkit.dag)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note to self: what does binding do?

Comment thread src/matchbox/client/adapters/duckdb/adapter.py Outdated
Comment thread test/fixtures/db.py
@pytest.fixture(scope="function")
def backend_instance(
request: pytest.FixtureRequest, backend: str
) -> MatchboxClusterStoreAdapter:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note to self: what does the hierarchy of classes now look like?

Comment thread src/matchbox/client/adapters/duckdb/adapter.py Outdated
Comment on lines +16 to +18
_NONEXISTENT_STEP = StepPath(
collection=CollectionName("local_test"), run=RunID(1), name="nonexistent"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ignores them? What will the interface be for connecting to a DAG remotely?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DAG object owns the collection and run ID -- that information will come from there.

Comment thread test/client/local/test_cache_mixin.py
Comment thread test/client/local/test_local_mixin.py Outdated
assert self.backend.get_resolver_data(resolver.resolver.path).num_rows == 0

def test_clears_dependent_cache_only(self) -> None:
"""Only cache entries depending on the dropped step are cleared."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not an English sentence

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Only the caches of descendents of the dropped step are cleared"? In the test name too.

Comment on lines +438 to +473
# 1) Bulk-insert new clusters: anti-join, ID omitted (sequence-assigned)
session.execute(
insert(tables.Clusters).from_select(
["cluster_hash"],
select_new_cluster_hashes(resolver_hashes, hash_col="cluster_hash"),
)
)

# 2) Map parent_id to canonical cluster_id, now hashes exist
cluster_map = select_cluster_map(resolver_hashes)

# 3) ResolverClusters: distinct (step_id, cluster_id)
session.execute(
insert(tables.ResolverClusters).from_select(
["step_id", "cluster_id"],
select_resolver_membership(step.step_id, cluster_map),
)
)

# 4) Contains: new (root, leaf) pairs, anti-joined against existing
candidate_contains = select_contains_pairs(
expanded_leaves, cluster_map
).subquery()
session.execute(
insert(tables.Contains).from_select(
["root", "leaf"],
select(candidate_contains.c.root, candidate_contains.c.leaf).where(
~exists(
select(1).where(
tables.Contains.c.root == candidate_contains.c.root,
tables.Contains.c.leaf == candidate_contains.c.leaf,
)
)
),
)
)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does this follow a different order to PostgreSQL's insert_clusters()? Should something be factored out? Is exists going to work at scale?

Comment thread src/matchbox/client/adapters/duckdb/adapter.py Outdated
assert self.backend.get_cached_query("independent") is not None

def test_raw_data_survives(self) -> None:
"""drop_step_data doesn't touch RawData - it's canonical, not a cache."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

canonical?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually don't like this behaviour. We might add or remove fields, want to run it again with fresh data. I think it should drop with the step.

Comment thread src/matchbox/client/adapters/duckdb/adapter.py Outdated
Comment thread test/client/local/test_local_mixin.py Outdated
@@ -0,0 +1,182 @@
"""Tests for MatchboxLocalDuckDBLocalMixin: raw data, query cache, cascade."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

local/test_local_mixin.py

vs

local/data_mixin.py

?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps MatchboxLocalDuckDBLocalMixin is misnamed

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, will go for MatchboxLocalDuckDBCacheMixin.

Comment on lines +524 to +528
# dump_tables only covers catalog rows - physical tables need
# dumping separately, same row format.
data["__dynamic_cache_tables__"] = {
name: _dump_dynamic_table(session, name) for name in physical_names
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we solve this by just adding physical tables to the catalogue, and removing if steps change? Why handle separately?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. We can reflect to dump, but not to restore, where we infer the schema from the JSON because the tables aren't defined. You need to create the table with the right columns... which is exactly what restore does, with the materialise helper.

Comment thread src/matchbox/client/adapters/duckdb/adapter.py
Comment thread src/matchbox/client/adapters/duckdb/adapter.py Outdated
Comment thread src/matchbox/client/adapters/duckdb/db.py Outdated
@@ -0,0 +1,76 @@
"""Tests for MatchboxLocalDuckDBDataMixin: replace-on-rerun.

Local stores replace-on-rerun, the deliberate inversion of the server's

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doesn't the server also replace on resync? I guess only if the signature or whatever is called changes?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we really need an idea of where we're going with regards to the server

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once you write a run, that's it, isn't it? Or you can overwrite it for a while then once it's default you need a new run.

Comment thread src/matchbox/client/adapters/duckdb/db.py Outdated
Comment thread src/matchbox/client/adapters/duckdb/adapter.py
assert (
self.backend.get_resolver_data(resolver.resolver.path).num_rows
== before
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cascading isn't checked here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TestDropStepData.test_cascades covers at a higher level.

Comment thread src/matchbox/client/base.py
Comment on lines +44 to +71
def compute_lineage(
graph: dict[StepName, list[StepName]],
resolver: StepName,
sources: list[StepName] | None = None,
) -> list[StepName]:
"""Ordered ancestor step names for `resolver`, closest first, self first.

When `sources` is given, ancestors are restricted to those on a path
to one of `sources`: kept if it is a source, or is itself downstream
of one.
"""
ancestors = _ancestor_depths(graph, resolver)

if sources:
children: dict[StepName, list[StepName]] = {}
for name, parents in graph.items():
for parent in parents:
children.setdefault(parent, []).append(name)

allowed = set(sources)
for source in sources:
allowed.update(_ancestor_depths(children, source))
ancestors = {
name: depth for name, depth in ancestors.items() if name in allowed
}

ordered = sorted(ancestors.items(), key=lambda pair: (pair[1], pair[0]))
return [resolver, *(name for name, _ in ordered)]

@will-langdale will-langdale Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we duplicating DAG._topological_sort() and the method that rely on it? How can we reduce this repetition?

Ideas:

  • bind() uses a whole DAG, and so can defer to its methods
  • An abstract common.graph module that centralises these algorithms and any coverage of them

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed with @leo-mazzone.

  • Backend belongs to the DAG
  • Like a Step, even though it belongs to the DAG, the backend can query the DAG
  • So topological algorithms belong to the DAG
  • Note: ensuring that DAG and backend are always one is crucial. If you delete a backend from a DAG, that backend shouldn't be able to call the DAG

Comment thread src/matchbox/client/base.py Outdated
"""
self._graph_source = graph_source

def _graph(self) -> dict[StepName, list[StepName]]:

@will-langdale will-langdale Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a @property?

"result_id",
BigInteger,
primary_key=True,
autoincrement=False,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

concerned about this autoincrement=False


for tbl in order:
ddl = str(
CreateTable(tbl, include_foreign_key_constraints=[]).compile(

@joshwong-cs joshwong-cs Jul 23, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CreateTable has an if_not_exists arg, do we want to toggle it on here?

Comment on lines +32 to +33
prefixes = ("mb_raw_data__", "mb_query_cache__")
assert not any(n.startswith(prefixes) for n in names)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this so low level?

def test_dump_restore_round_trips_dynamic_tables(
matchbox_local_duckdb: MatchboxLocalDuckDB, sqla_sqlite_warehouse: Engine
) -> None:
"""dump()/restore() carry RawData/QueryCache row content, not just pointers."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pointers?

Comment thread src/matchbox/server/postgresql/utils/insert.py
Comment thread test/client/adapters/duckdb/test_duckdb_core.py
Comment thread src/matchbox/client/adapters/duckdb/adapter.py Outdated
def _translate_lineage(
session: Session, lineage_names: list[str]
) -> list[tuple[int, int | None]]:
"""Translate step names from self.lineage() into (step_id, source_config_id).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self.lineage()? This ain't a class

self.clear(certain=True)

with self._session() as session:
restore_tables(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need to reset each sequence in SEQUENCE_COLUMNS to MAX(pk) + 1 since restore_tables() inserts explicit primary keys

@@ -0,0 +1,74 @@
"""Ingest select builders, shared across relational backends.

@leo-mazzone leo-mazzone Jul 23, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

more information needs to be given about

  • the structure of the incoming TableClause and the other two Subquerys
  • conceptually, what are these functions doing

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

otherwise it's really difficult to reuse functions from this file. Does this help my use case? Who knows

@@ -0,0 +1,74 @@
"""Ingest select builders, shared across relational backends.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these functions assume a specific ORM structure, which I guess is fair for SQL adapters. But it seems we're also increasingly assuming all adapters will be relational?

@will-langdale
will-langdale force-pushed the feat/localmb-localadapter branch from 1d26fc1 to df3bd8f Compare July 23, 2026 10:38

Handles only data movement: selecting every row and base64-encoding bytes
columns for JSON-safety, or decoding and bulk-inserting them back.
Engine-specific concerns stay with the caller.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for example? What's outside "data movement"? ID sequences?

@will-langdale
will-langdale force-pushed the feat/localmb-localadapter branch from df3bd8f to d25e0a5 Compare July 23, 2026 10:41
@will-langdale
will-langdale force-pushed the feat/localmb-localadapter branch from d25e0a5 to 4e7f818 Compare July 23, 2026 10:43
"""Cache cleaned query data, keyed by query config plus upstream fingerprints.

Disposable: unlike insert_raw_data, this is just a cache, and can be
recomputed at any time.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does "at any time" mean?

disposable query cache, and explicit cascade invalidation.

Local stores replace-on-rerun (with cascade invalidation of descendant
steps), unlike server backends, which are write-once per step per run.

@leo-mazzone leo-mazzone Jul 23, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

like pointed out by many other comments elsewhere, this really does not clarify the invalidation model. I am confusion


_graph_source: HasGraph | None = None

def bind(self, graph_source: HasGraph) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bind_graph?

Comment thread src/matchbox/client/adapters/duckdb/orm.py


class SourceConfigs(LocalBase):
"""Degenerate one-row-per-source table.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

degenerate?

Comment on lines +111 to +112
Keeps the join shape identical to the server's, without needing the
rest of a real SourceConfig.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand what this means. Are these IDs the same you'll find on server?

class Clusters(CountMixin, LocalBase):
"""Table of indexed data and clusters that match it."""

__table__ = tables.Clusters.to_metadata(LOCAL_METADATA, schema=None)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is schema=None right? Where does the schema come from for these tables?

Comment on lines +215 to +228
LOCAL_TABLES = [
Steps.__table__,
SourceConfigs.__table__,
RawData.__table__,
QueryCache.__table__,
QueryCacheStep.__table__,
]
SHARED_TABLES = [
Clusters.__table__,
ClusterSourceKey.__table__,
Contains.__table__,
ModelEdges.__table__,
ResolverClusters.__table__,
]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what are these two lists for?

…esitigial helper functions

Signed-off-by: DBT pre-commit check
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants