Skip to content

feat: opaque partition-routing hooks for remote partition subgraphs - #829

Open
adsharma wants to merge 5 commits into
mainfrom
feat/partition-routing-hooks
Open

feat: opaque partition-routing hooks for remote partition subgraphs#829
adsharma wants to merge 5 commits into
mainfrom
feat/partition-routing-hooks

Conversation

@adsharma

Copy link
Copy Markdown
Contributor

What

Keeps ladybugdb embedded and distribution-agnostic while allowing partition subgraphs (<parent>_p<i>) to live on remote hosts. Introduces one opaque function-pointer interface — common::PartitionRoutingHooks — that a distributed wrapper installs before the first database is opened. Every place the engine touches a partition subgraph consults the hooks first; with no hooks installed (the default) behavior is unchanged bit-for-bit.

Design doc: partition-routing-hook.md (seam inventory, invariants, rejected alternatives).

Interface

Hook Purpose
locate(ctx, ref, &handle) placement: wrapper claims (parentTableID, partitionIndex) and returns an opaque handle
onPartitionCreate / onPartitionDrop lifecycle notifications (create fires for every partition so wrappers learn placement)
bindScan(ctx, ref, handle, &spec) read interception via a PartitionScanSpec (table function + bind-data factory)
insertRow / insertChunk point/bulk write interception
lookupRow MERGE lookups against remote rows

Key invariant: the engine owns the partition function and all catalog metadata; wrappers only own where a partition lives. Placement is computable locally without RPC; PartitionHandle is opaque and never inspected by the engine.

Changes by commit

  1. common: hook interface + process-global set-once registry.
  2. lifecycle/storage: catalog notifies on subgraph create/drop; storage creation, checkpoint, rollback, and metadata serialization skip claimed partitions (no local state for them).
  3. reads: binder replaces claimed partitions with engine-built substitutes (child-entry clones carrying the wrapper's scan function), collapsing fully-claimed parents onto the existing single-entry table-function scan path. CreateBindDataFunc now receives nodeUniqueName so foreign-backed entries can name output columns <node>.<prop> / <node>._ID (the convention duckdb/postgres extensions already follow). Mixed local/remote scans of one parent are rejected at bind time.
  4. writes: point inserts resolve targets from catalog truth and ship rows through insertRow (wrapper assigns nodeIDs); COPY/batch inserts keep the engine's computePartitionIndexes and dispatch row runs to local targets or insertChunk; MERGE lookups go through lookupRow.
  5. tests: api-level suite with a mock wrapper covering lifecycle/storage skip, remote point insert + scan-back, remote COPY, and mixed-scan rejection.

Known limitations (documented, follow-ups)

  • UPDATE/DELETE on remote rows not yet routed.
  • Rel tables referencing remote-partitioned parents; GDS over remote partitions.
  • Mixed local/remote scans of one parent rejected (multi-entry union scan can't host scan-function entries).

Testing

  • New PartitionRoutingTest.* api tests pass locally (4/4).
  • ddl~partitioned.* e2e suite passes; copy/insert/scan/attach e2e filters show no new failures (copy~copy_to_csv.CopyToInvalidCase fails identically on a clean tree).

…graphs

Partitioned node tables store rows in partition subgraphs (<parent>_p<i>). In a
distributed deployment those partitions may live on remote hosts. Add a plain
function-pointer interface (common/partition_routing_hook.h) that a distributed
wrapper installs process-globally before the first database is opened:

  - locate(): placement decision per (parent table ID, partition index)
  - onPartitionCreate/onPartitionDrop: lifecycle notifications
  - bindScan(): read interception via a PartitionScanSpec
  - insertRow()/insertChunk()/lookupRow(): write/lookup interception

The engine keeps ownership of the partition function, the catalog metadata and
all schema information; wrappers only own where partitions live. PartitionHandle
is opaque. Every hook is optional: with no hooks installed (the default) the
engine behaves exactly as before.

Includes the design doc covering the seam inventory, invariants, rejected
alternatives, and landing order.
- Catalog: notify the wrapper when partition subgraph entries are created
  (always, so wrappers learn about new partitions and decide placement) and
  dropped (claimed partitions only). Renames are not reported: PartitionRef is
  ID-based and IDs survive renames.
- StorageManager: skip local table creation for claimed partitions - no local
  storage, WAL or checkpoint state exists for them. Checkpoint, rollback, and
  metadata-snapshot serialization skip claimed partitions as well.
Reads: when expandPartitionedNodeTables finds a claimed partition, it asks the
wrapper for a PartitionScanSpec (table function + bind-data factory) and attaches
it to an internal clone of the partition's catalog entry - keeping the parent's
schema, table ID and partition lineage. Partitions routed to the same wrapper
scan collapse into one substitute entry, so a fully-claimed parent scans through
the existing single-entry table-function path. Mixed local/remote scans of one
parent cannot be planned and are rejected at bind time with a clear error;
a defensive check in planNodeScan guards other paths into the same restriction.

Supporting changes:
- NodeTableCatalogEntry::CreateBindDataFunc now receives nodeUniqueName so
  foreign-backed entries can name output columns <nodeUniqueName>.<prop> /
  <nodeUniqueName>._ID, matching what planner schema lookups expect (same
  convention the duckdb/postgres extensions use in their getBoundScanInfo).
- Add setScanFunction/setCreateBindDataFunc setters used to build substitutes.
- Cardinality estimator skips scan-function-backed entries like foreign tables.
Point writes (INSERT/MERGE-create): map_insert resolves partition targets from
catalog truth instead of pattern entries, so substituted scan entries don't
affect write routing. resolveTargetTable returns null for remotely routed
partitions; insertRemotely ships the evaluated row through insertRow() and uses
the wrapper-assigned nodeID. MERGE output materialization goes through
lookupRow(). The partition-function computation stays in the engine.

Bulk writes (COPY FROM / INSERT ... SELECT): NodeBatchInsert keeps the engine's
per-row computePartitionIndexes, then dispatches each row run either to its
local target or to the wrapper's insertChunk(); remote targets skip local PK
index building, error handlers and finalize.

NodePartitionWriteInfo now carries the parent table ID so executors can build
PartitionRefs without re-deriving lineage.
Add api-level tests that install a mock distributed wrapper and verify:
- lifecycle notifications fire; claimed partitions keep catalog metadata but
  get no local storage; drop notifies per partition
- point inserts route to the wrapper sink, which assigns node IDs; reads are
  served through the wrapper's consolidated scan (foreign-backed entry)
- COPY FROM routes row runs through insertChunk
- mixed local/remote scans of one parent are rejected at bind time while
  writes still route by the partition function
@adsharma
adsharma force-pushed the feat/partition-routing-hooks branch from 3c7b60e to aa221c7 Compare August 22, 2026 03:06
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.

1 participant