Repository navigation
Replies: 8 comments 18 replies
|
Can Ladybug support the following use case? If so, what should I do to enable it? I have three tables: two node tables, CREATE NODE TABLE `User` (
`id` STRING,
`account_id` STRING,
`usergroup_id` STRING,
`groupinfo_id` STRING,
`usertemplate_id` STRING,
`visitor_auth_type` INT64,
PRIMARY KEY(`id`)
);
CREATE NODE TABLE `Session` (
`id` STRING,
`login_ts` INT64,
`logout_ts` INT64,
`online_sec` INT64,
`access_type` INT64,
`term_cause` STRING,
`time_seg` STRING,
`total_traffic` DOUBLE,
`gw_strategy` STRING,
`actual_gw_strategy` STRING,
`proxy_name` STRING,
`policy_id` STRING,
`accounting_rule` STRING,
`package_name` STRING,
`service_suffix` STRING,
`service_id` STRING,
`is_roaming` BOOL,
`passthrough_type` STRING,
PRIMARY KEY(`id`)
);
CREATE REL TABLE `User_owns_Session` (
FROM `User` TO `Session`,
`login_ts` INT64,
`logout_ts` INT64,
`access_type` INT64,
MANY_MANY
);I also have an Iceberg data lake containing the same three tables. Their schemas are as follows: def user_schema() -> Schema:
return Schema(
NestedField(1, "id", StringType(), required=True),
NestedField(2, "account_id", StringType(), required=False),
NestedField(3, "usergroup_id", StringType(), required=False),
NestedField(4, "groupinfo_id", StringType(), required=False),
NestedField(5, "usertemplate_id", StringType(), required=False),
NestedField(6, "visitor_auth_type", LongType(), required=False),
identifier_field_ids=[1],
)
def user_arrow_schema() -> pa.Schema:
return pa.schema(
[
pa.field("id", pa.string(), nullable=False),
pa.field("account_id", pa.string()),
pa.field("usergroup_id", pa.string()),
pa.field("groupinfo_id", pa.string()),
pa.field("usertemplate_id", pa.string()),
pa.field("visitor_auth_type", pa.int64()),
]
)
def session_schema() -> Schema:
return Schema(
NestedField(1, "id", StringType(), required=True),
NestedField(2, "login_ts", LongType(), required=False),
NestedField(3, "logout_ts", LongType(), required=False),
NestedField(4, "online_sec", LongType(), required=False),
NestedField(5, "access_type", LongType(), required=False),
NestedField(6, "term_cause", StringType(), required=False),
NestedField(7, "time_seg", StringType(), required=False),
NestedField(8, "total_traffic", DoubleType(), required=False),
NestedField(9, "gw_strategy", StringType(), required=False),
NestedField(10, "actual_gw_strategy", StringType(), required=False),
NestedField(11, "proxy_name", StringType(), required=False),
NestedField(12, "policy_id", StringType(), required=False),
NestedField(13, "accounting_rule", StringType(), required=False),
NestedField(14, "package_name", StringType(), required=False),
NestedField(15, "service_suffix", StringType(), required=False),
NestedField(16, "service_id", StringType(), required=False),
NestedField(17, "is_roaming", BooleanType(), required=False),
NestedField(18, "passthrough_type", StringType(), required=False),
identifier_field_ids=[1],
)
def session_arrow_schema() -> pa.Schema:
return pa.schema(
[
pa.field("id", pa.string(), nullable=False),
pa.field("login_ts", pa.int64()),
pa.field("logout_ts", pa.int64()),
pa.field("online_sec", pa.int64()),
pa.field("access_type", pa.int64()),
pa.field("term_cause", pa.string()),
pa.field("time_seg", pa.string()),
pa.field("total_traffic", pa.float64()),
pa.field("gw_strategy", pa.string()),
pa.field("actual_gw_strategy", pa.string()),
pa.field("proxy_name", pa.string()),
pa.field("policy_id", pa.string()),
pa.field("accounting_rule", pa.string()),
pa.field("package_name", pa.string()),
pa.field("service_suffix", pa.string()),
pa.field("service_id", pa.string()),
pa.field("is_roaming", pa.bool_()),
pa.field("passthrough_type", pa.string()),
]
)
def rel_schema() -> Schema:
return Schema(
NestedField(1, "from_user_id", StringType(), required=True),
NestedField(2, "to_session_id", StringType(), required=True),
NestedField(3, "from_type", StringType(), required=True),
NestedField(4, "to_type", StringType(), required=True),
NestedField(5, "login_ts", LongType(), required=False),
NestedField(6, "logout_ts", LongType(), required=False),
NestedField(7, "access_type", LongType(), required=False),
identifier_field_ids=[1, 2],
)
def rel_arrow_schema() -> pa.Schema:
return pa.schema(
[
pa.field("from_user_id", pa.string(), nullable=False),
pa.field("to_session_id", pa.string(), nullable=False),
pa.field("from_type", pa.string(), nullable=False),
pa.field("to_type", pa.string(), nullable=False),
pa.field("login_ts", pa.int64(), nullable=True),
pa.field("logout_ts", pa.int64(), nullable=True),
pa.field("access_type", pa.int64(), nullable=True),
]
)The fields are now clearly defined. Here is my use case: I want to use Ladybug’s graph-topology capabilities. Therefore, I need to create a relationship table in the graph in order to obtain graph traversal functionality. This is a prerequisite. Consequently, the approach of joining native graph node tables with external tables does not meet my requirements. I would like the data for the The I need to support real-time or near-real-time queries. Offline queries are not acceptable for our use case. Given the currently available options, such as:
how can these technologies be combined to satisfy this use case? The root cause of this requirement is that Ladybug is an embedded graph database. At very large data volumes, it can impose significant memory and disk-resource costs. We therefore hope to keep some data in an external storage system while still using Ladybug’s computational engine for graph traversal and other graph computations. |
|
Alternatively, we could lower our requirements a little. If the data for all three tables is stored in the data lake with stream‑ingestion, can we use Ladybug to support near‑real‑time queries, with graph‑topology capabilities as a prerequisite? From an architectural perspective, this would turn into a cloud‑native storage‑compute‑separated design: Iceberg data‑lake acts as the storage layer for graph data, while Ladybug functions as a stateless compute‑only engine. Could this architecture work? I don’t fully understand the role of Iceberg‑Ladybug integration. Could you provide a detailed explanation? @adsharma |
|
Now that the fix in #872 has been completed, I believe the Iceberg Extension is ready for access via the REST Catalog. Could we move on to discuss the point mentioned earlier:
If we are to introduce a "MIXED REL table", are there any relevant design ideas? Or, to meet our expected storage‑compute separation requirements, what should be our next steps? |
|
I’d like to discuss if this solution is a good fit for our direction. Can Ladybug support this feature? https://www.puppygraph.com/blog/graph-lakehouse @adsharma |
|
https://github.com/Ladybug-Memory/duckdb-icedisk-extension |




Uh oh!
There was an error while loading. Please reload this page.
1. What Is a Foreign Table?
In Ladybug, a foreign table is a table whose data lives outside the
native Ladybug storage engine — typically in an external SQL database such as
DuckDB, PostgreSQL, SQLite, or any database accessible via ADBC. Foreign tables
are registered through the
ATTACHcommand:When a database is attached, its tables become visible to Cypher queries through
a catalog extension (e.g.,
DuckDBCatalog,PgClientCatalog,ADBCCatalog).Each foreign table is represented in the catalog as either a
NodeTableCatalogEntry(for node tables) or aRelGroupCatalogEntry(for reltables), backed by a scan function that delegates reads to the remote database
via SQL queries.
Because the data is not managed by Ladybug's transaction engine, foreign tables
are read-only — inserts, updates, and deletes are rejected at the storage
layer.
2. Representing Graphs in Foreign Tables
Ladybug uses a convention-based approach to recognize graph elements in a
foreign SQL database. When a database is attached, the catalog extension
enumerates the tables in the remote schema and classifies them by name prefix.
2.1. Naming Convention
node_*NodeTableCatalogEntry(shadow entry)rel_*RelGroupCatalogEntry+ForeignRelTablefkrel_*RelGroupCatalogEntry+ForeignRelTableNodeTableCatalogEntry(generic, no graph semantics)The two rel-table variants correspond to different physical representations of
edges in the foreign database:
rel_*— CSR-like Foreign Key ColumnsA
rel_*table stores edges with explicitsrc/dstcolumns that act likeCSR (Compressed Sparse Row) foreign keys — they reference the primary keys of
the corresponding
node_*tables. The naming mirrors the adjacency-list mentalmodel:
Here
srcREFERENCESnode_person(id)anddstREFERENCESnode_person(id).The table is backed by a
ForeignRelTablewhose scan function fetches rows fromthe remote database, and the optimizer uses the FK metadata to plan joins.
fkrel_*— Foreign-Key-backed Rel TableA
fkrel_*table is identical in purpose but thesrc/dstcolumns areexplicitly declared as foreign keys in the remote database. The catalog
extension queries
information_schema.table_constraintsto discover the FKrelationships:
This produces a scan-driven rel table — the optimizer generates hash joins
between the foreign node tables and the rel table at query time, rather than
pre-building CSR columns.
2.2. Initialization Order
Node tables must be registered before rel tables because
fkrel_*andrel_*entries need to resolve theirsrc/dstnode table IDs at cataloginitialization time. The catalog extension performs a two-pass initialization:
3. Alternatives & Design Trade-offs
There are several possible strategies for combining foreign SQL tables with
Ladybug's native graph engine. Each has different engineering costs, query
performance characteristics, and levels of planner integration.
3.1. Read Node/Rel Tables into Arrow Memory, Run Cypher Locally
The simplest approach: stream all rows from the foreign tables into Arrow
columnar batches, then run Cypher queries entirely inside Ladybug's native
engine. This is the default path for the DuckDB scan function —
LOAD FROMand
MATCHon a single foreign node table pull data through theTableFunctioninterface into Arrow vectors, which are then processed by thestandard Cypher operator pipeline.
Pros:
Cons:
3.2. Mixed Tables: Foreign Node Tables + Foreign Rel Tables (Shipped Design)
This is the primary shipped design. When attaching a foreign database, the
catalog extension creates two catalog entries for each foreign table:
PgClientTableCatalogEntry) in theextension's own catalog set — this holds the scan function and bind data.
NodeTableCatalogEntryconstructed with theShadowTag{}marker) — thismakes the table visible to the Cypher query planner and binder.
The shadow entry holds a pointer (
referencedEntry) back to the attachedentry, so the planner can find the scan function when it needs to read data:
For rel tables, a
RelGroupCatalogEntryis created in the main catalog soMATCH ... -[...]-> ...patterns can find the relationship. The rel table isbacked by a
ForeignRelTable(which extendsRelTable) and uses the foreignscan function directly, mixing foreign node table data (read via scan function)
with the foreign rel table (also read via scan function). Both are resolved at
query time through hash joins.
The shadow mechanism does mean there are two catalog entries for the same
logical table — one in the extension's catalog and one as a shadow in the main
catalog. The planner resolves the shadow to find the scan function, then
delegates to the attached entry for actual data access. This allows Cypher
queries to reference foreign tables transparently without the core planner
needing to know about each extension's catalog layout.
3.3. Transpile Graph Traversal into SQL Joins
The
ForeignJoinPushDownOptimizerattempts to detect Cypher graph patternswhere all nodes and relationships are backed by foreign tables from the
same external database, and rewrite the entire pattern into a single SQL
JOINquery that is pushed down to the foreign database.The optimizer detects a pattern like:
And rewrites it into a single
TABLE_FUNCTION_CALLthat executes one SQL JOINquery on the foreign database.
Requirements for the rewrite:
TABLE_FUNCTION_CALLwithsupportsPushDown = truescanFunction(foreign-backed)Current status: This optimizer exists (
ForeignJoinPushDownOptimizer) butis not yet producing optimal SQL for complex graph traversals. The Ladybug
planner does not currently optimize general graph traversal patterns into
recursive SQL joins — the pushdown is limited to simple star patterns.
Optimizing arbitrary graph traversals (e.g., variable-length path patterns,
multi-hop traversals) into efficient SQL remains open work.
3.4. Copy
rel_*Tables into the Ladybug Catalog at Attach TimeFor
rel_*tables (CSR-style), another strategy is to materialize the remotedata into a local on-disk CSR rel table during
ATTACH. Rather than scanningthe foreign table at every query, the data is bulk-copied once into Ladybug's
native CSR storage format. Subsequent queries run entirely against the local
copy with full performance (CSR adjacency-list traversal, columnar scans,
compression).
This approach is noted in the source as a TODO:
Once materialized, the local rel table can be combined with foreign node tables
(the mixed table approach from §3.2) — node data is still read from the
foreign database via scan functions, but edge traversal uses the local CSR
storage, giving fast extend operations without moving all edge data on every
query.
Pros:
Cons:
after attach are invisible until re-attach.
4. Rowid Challenges
A critical challenge when representing graphs in foreign SQL databases is the
internal node identifier — Ladybug's native storage uses a compact
offsetwithin each table as the internal
_IDof a node. When the data lives in aforeign database, there is no universal mechanism to get a cheap internal ID.
4.1. DuckDB: Rowid Support
DuckDB exposes a
rowidpseudo-column for each table (unless the table is aview or has a WITHOUT ROWID-like structure). The DuckDB scan function uses
rowidas the internal_ID:This works well because DuckDB's
rowidis a stable 64-bit identifier for eachrow that can be used as the node's internal offset. The
internalID_tstructpairs this
offsetwith atableIDto form a globally unique node reference:4.2. PostgreSQL: No Rowid
PostgreSQL does not have a stable
rowidanalogue. Thectidsystem columnis a physical location (
(page, tuple index)) that changes onVACUUMor rowupdate and is not a stable identifier. There is no
rowidpseudo-column.For the PostgreSQL connector (
pg_clientextension), the workaround is to usethe primary key column of the node table as the internal ID. The first
column of the table is convention-bound to be the primary key:
This imposes a constraint: every
node_*table in PostgreSQL must have anumeric primary key (typically
INTEGERorBIGINT) as its first column.String or composite primary keys would not work as internal IDs because Ladybug
expects
INT64offsets.4.3. ADBC: Delegated to the Driver
ADBC (Arrow Database Connectivity) delegates the rowid question to the
underlying driver. The ADBC extension uses the primary key column as the
internal ID, similar to the PostgreSQL approach, but relies on the ADBC driver
to provide a stable ordering.
4.4. Implications for Rel Tables
The absence of stable rowids affects how rel tables reference nodes. For
fkrel_*tables, thesrc/dstcolumns must contain values that match theinternal ID scheme of the corresponding node tables:
srcanddstshould containrowidvalues from the node tablessrcanddstshould contain primary key values from thenode tables
The FK discovery code in
PgClientCatalog::createForeignRelTablehandles thisby looking up the referenced tables and using their catalog entry IDs, ensuring
the rel table's
srcTableID/dstTableIDmatch the entries thatMATCHqueries will resolve to:
5. ForeignRelTable Implementation
The
ForeignRelTableclass (insrc/include/storage/table/foreign_rel_table.h)is the storage-layer adapter that bridges a foreign rel table into Ladybug's
native rel table abstraction. It extends
RelTablebut overrides the scanpath to use the external scan function instead of native CSR columns.
Key design points:
Scan-driven: Unlike native rel tables which read pre-built CSR adjacency
lists from disk,
ForeignRelTabledelegates to aTableFunctionthat issuesSQL queries to the foreign database.
No CSR columns: The
setToTableoverride skips the CSR-column resolutionthat native
RelTableperforms (which would dereference an emptydirectedRelDataand throw). All column pointers are set tonullptr.No mutations:
insert,update, anddelete_all throwRuntimeException.Morsel-driven parallelism: The scan function's shared state is lazily
created on the first
initScanStatecall and shared across all workerthreads. Each worker acquires disjoint offset ranges (morsels) from the
foreign table via the scan function.
Owns its own scan state:
ForeignRelTableScanStateholds a localdataChunk, asharedState, and alocalState— all managed by thescan function's lifecycle.
6. Summary
The mixed tables approach (foreign node tables shadowed in the main catalog
ForeignRelTable) is Ladybug's shipped design asof this writing. The
ForeignJoinPushDownOptimizerrepresents a path towardfull SQL transpilation, and the
rel_*COPY strategy offers a middle groundfor deployments that want fast CSR traversal without sacrificing relational
storage for edges.
All reactions