feat: materialize Iceberg/R2RML graph sources into native ledgers + tracking worker - #1422
feat: materialize Iceberg/R2RML graph sources into native ledgers + tracking worker#1422christophediprima wants to merge 11 commits into
Conversation
|
Hi there! What do you think about this feature? Any chance it gets merged at some point? Do you think it is a good idea? |
636305d to
72e28c0
Compare
72e28c0 to
231ce37
Compare
Update — rebased onto latest
|
Update — named-graph routing in the materializer (
|
2644dc1 to
21935c9
Compare
Update — templated materialize target (fan out into a ledger per partition) + a shared watermark state ledgerI added a templated materialize target so one tracking job can fan out into many target ledgers, resolved per row from the source's own columns. Motivation: I need per-user isolation (each user sees only their own data), not just per-tenant. Fluree's read-policy engine is deliberately graph-blind (it targets subjects/properties/classes, never named graphs), and the docs recommend separate ledgers for separate access regimes — so I make the isolation boundary the ledger, one per How it works. The Watermark moved to a shared state ledger. Previously the per- Tests: a new |
b2f202f to
5e03ddd
Compare
Update — don't upsert an empty doc for type-only sourcesA type-only source (subject + |
Update — tracking jobs survive a restartCloses the "worker state is in-memory" limitation above, which turned out to be worse in practice than it reads. A restart silently stopped every materialization until a client noticed and re-issued Jobs now persist as
Why not the graph-source record, which would have been the cheaper mirror of how the BM25 maintenance worker persists its own Also fixes a latent race the new writes would have made hotter: opening the state ledger was a bare check-then-create, and the loser of a race between two pollers got Tests ( |
Adds the read half of incremental materialization: - SendScanPlanner::plan_incremental(from, to): the data files ADDED in a (from, to] sequence-number window (live entries whose effective data sequence number > from.sequence_number), reusing the existing manifest walk. - SendScanPlanner::plan_scan_with_selection: full scan at a chosen snapshot. - effective_sequence_number(): Iceberg null/0 seq inheritance from the manifest. - TableMetadata::snapshot_window / window_is_append_only: parent-chain ancestry walk + append-only detection, so the materialize layer can fall back to a full re-read when the window has overwrite/delete/replace (updates/deletes the added-files scan can't see) or from is not an ancestor of to. Unit tests cover seq-number inheritance, the ancestry walk (incl. branch/expired errors), and append-only detection. Row-level delete-file (v2 MoR) support is future work. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds, on FlureeR2rmlProvider: - prepare_iceberg_scan(): shared setup (REST/Direct × GCS/S3 × creds × metadata cache). - read_scan_tasks(): bounded-parallel Parquet read of a task set. - current_snapshot_id(): the source table's current snapshot (materialize 'to' point). - scan_table_incremental(from, to): scans only data files ADDED in the snapshot window, via SendScanPlanner::plan_incremental. These back the materialization layer. NOTE: scan_table still inlines the same setup/read (TODO dedup once the incremental path is verified end-to-end). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tracking worker
Materialize an R2RML/Iceberg graph source into a native Fluree ledger so
native features (BM25, vector/RAG, reasoning) can run over external tables.
- Engine: Fluree::materialize_r2rml_graph_source(source, target, force_full)
reuses the query-path R2RML term materializers (subject-IRI parity),
aggregates one JSON-LD node per subject, and upserts into the target ledger.
- Incremental: scan only files added in the (from, to] snapshot window when it
is append/compaction-only (TableMetadata::window_is_incremental_safe);
overwrite/delete or expired/branched history fall back to a full re-read.
Compaction (replace) stays incremental because Iceberg preserves each row's
data_sequence_number, so the sequence-number window excludes rewritten rows.
- Watermark persisted in the target ledger (string-encoded snapshot id),
written atomically in the same upsert; a no-delta poll commits nothing.
- Tracking worker (MaterializeTrackingWorker): a Send polling task spawned on
non-peer nodes that keeps tracked source->target jobs fresh on an interval.
- Routes: POST /v1/fluree/iceberg/{materialize,track,untrack},
GET /v1/fluree/iceberg/tracking.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…zation
Extend materialization with change-data-capture semantics so a materialized
native ledger tracks source updates and deletes, not just inserts.
- DeleteConvention { column, deleted_values: Vec<Option<String>> } on
IcebergGsConfig: a row is a delete when its column value is in deleted_values;
a null entry matches a NULL column (Debezium null-payload). Threaded through
the create builders + /iceberg/map route; validated at graph-source creation.
- order_by column enables latest-by-key: per subject the highest-ordered row
wins (value-orderable int/date/timestamp only, enforced) with a whole-subject
replace that clears fields dropped in a newer revision; a tombstone retracts
the whole subject via a typed-@id wildcard update. Two ordered commits keep the
watermark advancing only in the upsert (crash/failure self-heals next poll).
- Per-(source,table) watermark with an injective subject; each table is read once
with its own (from,to] window. Dual-mode: with neither delete nor order_by set
the pass is the legacy additive merge (unchanged). Fails loud on a
non-orderable order_by column or multiple triples maps per table.
- term.rs exposes column_string / batch_has_column / column_sort_key /
column_is_orderable. subjects_retracted on MaterializeResult / route / worker.
- Docs: a "Materialization" guide in docs/graph-sources/iceberg.md plus the
materialize/track/untrack/tracking endpoints in docs/api/endpoints.md.
- Tests: latest-by-key ordering + retraction-doc-shape unit tests, and an
end-to-end integration test proving retraction actually deletes (with a control
proving the earlier bare-string form was a silent no-op).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A static bearer for a Google Iceberg REST catalog (BigLake) is a short-lived
OAuth access token: it expires after ~1h and `BearerTokenAuth` cannot renew it,
so a long-running materialization tracking worker starts returning 401s.
Add `AuthConfig::GoogleMetadata` + `GoogleMetadataAuth`, which mints and
auto-refreshes tokens from the GCE/GKE instance metadata server (Workload
Identity), mirroring the existing `OAuth2ClientCredentials` cache+refresh — the
jittered-expiry `CachedToken` is now shared via `auth::token`. Wire it through
`{Iceberg,R2rml}CreateConfig::with_auth_google_metadata` and the `/iceberg/map`
`auth_google_metadata` field.
The GCS reader's storage HMAC keys are unaffected (static, non-expiring). The
metadata server is only reachable on GCE/GKE; local runs keep using a static
`auth_bearer`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When several graph sources materialize into one target ledger (a shared knowledge graph, a join table that only adds an edge to a parent entity, or an entity_type-style table adding a secondary class), their classes must UNION, not clobber. Additive-mode materialize now asserts `rdf:type` via an idempotent `insert` and upserts only the non-type predicates, so classes accumulate across sources instead of the last writer replacing them per predicate. A `rr:predicate rdf:type` object map is routed to the subject's `@type` (the union path) rather than treated as an ordinary predicate. This is the only way to express per-row, data-driven typing, since `rr:class` is constant-only; as an ordinary predicate it would be upserted and clobber other sources' classes. Tests: type-union + single-upsert-clobber-control integration tests, and a build_live_node unit test for the rdf:type-POM-as-class routing. Docs updated (docs/graph-sources/iceberg.md). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… v4.1.3 Rebasing this branch onto current upstream surfaced API drift a textual merge could not detect: - `LoadTableResponse` gained a `metadata` field — set `None` on the Direct metadata-location cache-hit path in `prepare_iceberg_scan` (r2rml.rs). - The `IcebergConnectionConfig` refactor moved the auth builders to delegate to the connection; re-add `with_auth_google_metadata` on `IcebergCreateConfig` (config.rs), consistent with `with_auth_bearer` / `with_auth_oauth2`. - Upstream's redaction tests construct `IcebergGsConfig` and match `AuthConfig` exhaustively; cover the new `delete` / `order_by` fields and the `GoogleMetadata` variant (ledger_info.rs). - fmt normalization of the resolved re-export list (lib.rs). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hMap)
The materializer only ever wrote the default graph — rr:graphMap/rr:graph were
vocab constants but unparsed and unused. Honor a subject-map-level graph map so
each source row's triples land in a per-row named graph (constant, column, or
template over the row's columns); no graph map keeps the existing default-graph
behavior.
This lets the SAME subject IRI carry independent per-graph statements: when one
real-world entity is contributed by several partitions of a source (e.g. the
same actor/article under different tenant/user ids), a shared subject in one
graph makes per-predicate upsert last-writer-wins across partitions. Routing
each partition's row into its own named graph (e.g. .../tenant/{t}/user/{u}) is
the RDF-native provenance/override boundary and the basis for graph-scoped read
isolation over a shared materialized twin.
No transaction-core changes — named graphs are already first-class (flakes carry
a graph id, staging keys on (GraphId, Sid), and generate_upsert_deletions scopes
retract-existing by graph_id):
- fluree-db-r2rml: a GraphMap term map (rr:graph constant shortcut, or
rr:graphMap [ rr:template | rr:column | rr:constant ]) parsed onto SubjectMap;
materialize_graph_from_batch resolves the per-row graph IRI, reusing the
subject materializer's template/column expansion.
- fluree-db-api (r2rml_materialize.rs): the accumulator keys on (graph, subject),
so the same IRI in two graphs is two independent keys. Live nodes are emitted
with a per-node "@graph" STRING selector ({"@id":s,"@graph":"<g>",...}) — the
named-graph form parse_insert/parse_upsert accept (they resolve the graph per
node and scope every triple to it); default-graph nodes and the materialization
watermark stay plain top-level. The JSON-LD ENVELOPE {"@id":g,"@graph":[...]}
(an @graph ARRAY) is NOT accepted by insert/upsert — its @graph key is skipped
and the wrapper collapses to @id-only — only the UPDATE parser reads a top-level
graph key. Whole-subject retraction is scoped per graph via that UPDATE graph
key; the upsert's own retract-existing is already graph-scoped. Additive
@type-union and per-predicate upsert now apply within each subject's graph.
Tests: rr:graphMap/rr:graph parsing; (graph, subject) isolation of the same IRI
across graphs (record + merge_live); graph-scoped retract doc; per-graph doc
flattening; and an end-to-end round-trip through the real transaction parser
(it_materialize_named_graph) that inserts/upserts the emitted per-node "@graph"
shape into a memory ledger, asserts it lands in the named graph (not the default
graph) and stays isolated per graph, plus a control proving the envelope form is
rejected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…er partition
The materialize/track `target` may now contain `{column}` placeholders (e.g.
`silver_{tenant_id}_{user_id}:main`), resolved per row from the source's own
columns, so ONE tracking job fans out into a separate native ledger per
partition (tenant,user). This is the isolation boundary for per-user access:
Fluree's read policy is graph-blind and the docs recommend separate ledgers for
separate access regimes, so isolation rides the existing per-ledger bearer-scope
rather than making a named graph an authorization axis. A placeholder-free
target keeps the existing single-target behavior — fully backward compatible.
- Per-row target resolution reuses `expand_template_from_batch`; a row whose
template columns are null is skipped (it cannot be routed to a ledger).
- The latest-by-key accumulator keys on `(target, graph, subject)` (was
`(graph, subject)`); the finalized state is grouped by target ledger and each
target's retract / @type-insert / predicate-upsert runs as its own commit
chain, creating the ledger on first sight (correct for append-only CDC).
Named-graph routing (rr:graphMap) still applies orthogonally within a target.
- The per-(source, table) watermark moves OUT of the target ledger into a shared
`fluree_materialize_state:main` ledger, keyed (source, target-spec, table) —
one watermark per source table for a fan-out job (a single scan feeds all its
targets), and read BEFORE the scan (targets are discovered from rows, so the
watermark cannot live in a not-yet-known target). Advanced AFTER every target
data commit so a crash re-reads the window (safe: writes are idempotent). This
removes bookkeeping triples from user data and unifies plain + templated jobs
on one generic, job-linked state location.
Tests: +record_isolates_same_subject_across_targets (same IRI in two target
ledgers = two independent keys); watermark helpers keyed (source, target-spec,
table) with a 3-segment injectivity test; single-target + named-graph suites
still green (19 unit + 8 materialize-integration). `--features iceberg`; fmt +
clippy (-D warnings) clean.
Docs: docs/graph-sources/iceberg.md + docs/api/endpoints.md updated — document
the templated target / fan-out, and correct the (now stale) "watermark in the
target ledger" language to the shared fluree_materialize_state:main state ledger.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A type-only source (e.g. an r2rml `entity_type` map: subject + rdf:type, no other predicates) splits into a @type node and NO predicate node, so the additive-mode predicate document is empty (`[]`). The materialize loop upserted it unconditionally, and the transactor rejects an empty upsert ("Upsert must contain at least one predicate or @type"). That error aborts the sync BEFORE its per-(source,table) watermark is written (the watermark write is the last step), so the source never gets a watermark: every poll full-rescans, re-inserts the same @type triples as a fresh commit, and re-fails — the target ledger's `t` climbs on every poll forever even though nothing in the source changed (pure churn). Guard the additive-mode predicate upsert with `if !pred_doc.is_empty()`, mirroring the existing `if !type_doc.is_empty()` guard on the @type insert: a type-only source now commits its classes once and advances its watermark, so subsequent polls short-circuit (0 rows) with no commit. Also guard the latest-by-key upsert the same way: a delete-only window (tombstones, no live rows) leaves the live doc empty; the retracts have already applied, so skip the empty upsert rather than error. Tests: +type_only_source_yields_no_predicate_upsert (a type-only SubjectNode splits to a @type node + no predicate node, and the additive predicate doc it produces is empty → caller must skip the upsert). Full materialize suite green (21 unit tests); fmt + clippy (-D warnings) clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The materialize tracking worker kept its job set only in memory, and the server never restored it: a restart silently stopped every materialization until a client noticed and re-issued `POST /iceberg/track`. Nothing surfaced the gap — `GET /iceberg/tracking` reported the worker running with zero jobs, which reads exactly like "nobody has tracked anything yet". For a fan-out job feeding a ledger per (tenant,user), that is an outage that looks like an empty system. Jobs now persist as `urn:fluree:materialize#Job` rows in the shared `fluree_materialize_state:main` ledger — the store this worker already owns, already writes on every committed poll, and that already carries the job's source and target-spec on its watermark rows. The job is kept as one serialized JSON blob (extensible without a schema migration, and self-validating on read: only a string that deserializes into a whole `PersistedMaterializeJob` is taken) plus duplicate `source`/`target` triples so operators can query jobs with the same SPARQL they already use for watermarks. `MaterializeTrackingWorker::run` restores the set before its first tick. Putting restore there rather than in the server's construction path inherits peer-exclusion for free — peers never spawn a worker, and `/iceberg/track` already 400s on them. Recovery is incremental: the per-(source, target-spec, table) watermarks live in the same ledger and are read by the materialize call itself, so a restored job re-reads nothing it had already materialized. `untrack` is durable too — it sets `tracked = false` rather than retracting the row, which keeps an audit trail and avoids a delete path. Restore filters on that flag. Not the graph-source record, which would have been the cheaper mirror of the BM25 worker's `tracked` flag: `IcebergGsConfig` has no catch-all field and both create paths serialize a fresh struct, so any key written there is dropped on the next config write; and `rest_client_cache_key` hashes the raw config JSON, so writing to the record would discard the cached catalog client and its OAuth token on every change. Also fixes a latent race the new writes would have made hotter: opening the state ledger was a bare check-then-create, and the loser of a race between two pollers got `LedgerExists` instead of a ledger, failing the whole materialization pass. Both call sites now share one helper that tolerates it. Known limitation, unchanged in kind but now permanent: both background workers spawn on every non-peer node. A restart used to clear duplicate jobs in a multi-node deployment; persisted jobs will be restored by every node. Leader-gating background workers remains the open follow-up noted at state.rs:293-297. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
69be7f6 to
6f107cb
Compare
Adds a third read-only MCP tool, `fql_query`, alongside `sparql_query` and
`get_data_model`. It runs a SELECT-style Fluree FQL (JSON-LD) body through
`run_jsonld_subquery` — the same connection path the HTTP /v1/fluree/query route
uses, which wires the BM25 index provider — so an embedded `f:searchText`
full-text block executes in-process. BM25 is FQL-only, so this is the tool that
gives an agent full-text / graph-aware-RAG retrieval that `sparql_query` cannot.
- Forces `opts.identity` from the MCP token (non-spoofable); rejects non-SELECT
and `from`-less bodies. Returns the same byte-budgeted Agent JSON envelope as
`sparql_query`, governed by the same --mcp-agent-json-max-bytes /
--mcp-query-timeout-ms settings.
- Tests (mcp_agent_json): fql_agent_json_envelope_shape,
fql_requires_authenticated_identity, fql_rejects_non_select_and_missing_from.
- Docs: new docs/query/mcp.md ("Querying over MCP"); cross-linked from the query
README, the SPARQL/JSON-LD pages, the AI overview, and the MCP config reference.
Stacks on the iceberg-materialize (fluree#1422) + bm25-search (fluree#1543) branches; kept on
the fork for now.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
aaj3f
left a comment
There was a problem hiding this comment.
🔄 Request changes — with genuine appreciation for the work, and a concrete proposal below for how we get all of it landed.
@christophediprima this is a substantial contribution, and the sustained engagement shows — the rejected-alternatives reasoning in the job-persistence commit and the churn analysis in the empty-upsert commit are the kind of commit bodies I wish more PRs had. Your core premise also survives scrutiny: I verified fluree-db-policy has no graph-scoped enforcement, so you are right that per-ledger scope is the real isolation boundary today. And the CDC semantics you've worked out — tombstone deletion, latest-by-key, the additive rdf:type union, watermarks, restart-surviving jobs — are exactly the hard-won details this capability needs.
Five things block the PR in its current shape; the inline comments carry the detail and the suggested fixes. (1) plan_incremental reimplements the Iceberg manifest walk and so misses the merge-on-read fail-closed guard that merged in #1520 roughly nine hours after your rebase — and materialization raises the stakes, because a caveated query result becomes permanent, uncaveated state in a ledger the watermark never revisits. (2) The materialize pass buffers the whole table, the whole subject accumulator, and the full JSON-LD expansion simultaneously inside the server process, so the first sync of a large CDC table — the stated use case — can take query serving down with it. (3) Target-ledger creation is a check-then-create race; your newest commit fixed exactly this for the state ledger, and the target path needs the same tolerant fallback. (4) Mutation testing shows no test calls the materialization engine: reintroducing the exact bugs that commits cf472f9ab and a985bc6b5 fix leaves all 28 tests green. (5) rr:graphMap silently drops POM-level graph maps, repeated graph maps, and rr:defaultGraph, which collapses two tenants' rows into one accumulator key — the exact clobbering named-graph routing exists to prevent.
Now the structural part, which I'd rather be transparent about than let surface awkwardly later: we have an internal draft (#1529) exploring the same native-twin capability through the bulk-import finalizer rather than transaction replay. That architecture resolves the memory blocker structurally — chunked, bounded, parallel ingest with serial finalization — rather than by adding knobs to the replay path. Neither effort should win by default, but having now reviewed both closely, I think the right landing is: the bulk-builder as the engine foundation, with your CDC semantics on top of it — #1529 has none of the tombstone/latest-by-key/watermark/job-persistence thinking this PR gets right, and that half is the harder half to get right.
So here is my concrete proposal for landing this work rather than orbiting it. Split the PR: (1) the Google metadata-server auth commit (10e27961a) is self-contained, fixes a real expiring-token bug, and I would approve it in its own PR today; (2) rr:graphMap named-graph routing stands alone as a genuine R2RML vocabulary gap — with the fail-loud fixes from the inline comment; (3) the append-only incremental scan stands alone once it routes through the shared MoR-guarded planner; (4) for the materialization engine itself, let's converge on the bulk-import foundation and port your CDC semantics onto it — I'd genuinely value doing that part together, since you've thought harder about CDC ordering and deletion semantics than anyone else on this codebase. The ledger-per-(tenant,user) fan-out deserves its own design discussion before it ships under either engine, since it converts a policy gap into unbounded ledger multiplication and graph-scoped policy may be the better long-term investment.
One housekeeping items (somewhat implied by the above). The branch currently has 5 conflicts against main, two of them exactly where #1520's guard exports live, so the MoR interaction should be resolved deliberately rather than by whatever conflict resolution happens to produce.
Adherence to repo commitments:
- Patterns/abstractions: ✖ Reinvents bulk ingest instead of extending the memory-bounded chunked path in
fluree-db-api/src/import.rs, adds a second Iceberg manifest walk instead of routing through the guarded planner, and implements a partialrr:graphMapthat silently drops the R2RML constructs it does not support. - Performance (speed first, memory second): ✖ CRITICAL — unbounded buffering on a task spawned inside the server process. No query hot path is touched.
- Testing:
⚠️ The new tests run (correctly wired intogrp_graphsource.rs) and the durability suite is excellent, but the engine itself has zero coverage — two mutations reintroducing shipped bugs leave all 28 tests green.
| /// is append-only via [`TableMetadata::window_is_append_only`](crate::metadata::TableMetadata::window_is_append_only) | ||
| /// and fall back to a full re-read otherwise — overwrite/delete/replace | ||
| /// snapshots carry updates/deletions this scan cannot see. | ||
| pub async fn plan_incremental( |
There was a problem hiding this comment.
Blocking. CRITICAL: this is a third Iceberg read entry point that will not inherit the merge-on-read fail-closed guard, and the merge that introduces the gap is textually clean.
plan_incremental reimplements the manifest-list walk instead of routing through plan_scan_for_snapshot_inner. Compare :219 (parse_manifest_list) and :226 (if manifest_entry.is_deletes() { continue; }) against the identical two lines at :94/:109 in the full-scan path. PR #1520 ("fail closed on merge-on-read delete files", still open on fix/iceberg-mor-delete-guard) installs mor_guard::ensure_no_summary_deletes + ensure_no_delete_manifests at exactly those two points in plan_scan_for_snapshot_inner, in both planners — and nowhere else. I verified git merge-tree --write-tree HEAD 771f6e88b returns exit 0 with no conflict, so whichever of these two merges second, git will report nothing and the incremental path will silently ship ungated.
To be fair about the exposure: the common MoR timeline is caught, because MoR delete files arrive under overwrite/delete snapshot operations, window_is_incremental_safe rejects those, and the fallback goes through plan_scan which #1520 does gate. The residual holes are (a) replace snapshots, which the Iceberg spec defines as "data and delete files were added and removed" and which table.rs:187 explicitly safelists, and (b) any writer whose summary operation string understates what the snapshot did. Both produce a materialized native twin that durably contains rows the source considers deleted — and unlike a virtual query, a twin is not re-read, so the wrong answer persists and is served at native speed as ground truth.
Suggested fix: hoist the manifest-list load + guard into one shared helper used by all three planners, or at minimum add the same two calls at the top of plan_incremental:
let allow_mor = crate::mor_guard::mor_deletes_allowed();
crate::mor_guard::ensure_no_summary_deletes(to_snapshot, &self.metadata.location, allow_mor)?;
// ...then parse_manifest_list_with_deletes + ensure_no_delete_manifests, as in plan_scan_for_snapshot_innerWhichever PR lands second should carry this. Worth coordinating explicitly with #1520 rather than leaving it to the merge.
| /// classes UNION across sources — several sources (or a join table adding an | ||
| /// edge to a parent) can contribute types to the same subject in a shared | ||
| /// target without clobbering each other. | ||
| pub async fn materialize_r2rml_graph_source( |
There was a problem hiding this comment.
Blocking. A 1365-line engine plus a 381-line worker ship with zero end-to-end test coverage, and the tests that claim to cover them do not.
grep -rn "materialize_r2rml_graph_source" --include=*.rs . finds no test caller anywhere in the repo — only routes/iceberg.rs:288,389 and materialize_worker.rs:277. The three new it_materialize_*.rs files are wired correctly into grp_graphsource.rs:18-23 and their 7 tests do run (confirmed by name under --all-features), but they hand-copy the JSON shapes rather than calling the code that produces them — it_materialize_retract.rs:38 even says so: "mirrors build_retract_doc".
I proved this is not theoretical. Reverting build_retract_doc:699 to the bare-string binding — the exact bug the PR body says it_materialize_retract is a regression test for — turns the unit test build_retract_doc_shape red (so there is a real guard, credit where due) but leaves all 7 PR-new integration tests green, materialize_retract_shape_actually_retracts included. The PR body's claim that it "asserts the triples are actually gone end-to-end" is true of the shape and false of the producer.
What's missing is a test that drives materialize_r2rml_graph_source itself. That needs an Iceberg source, which is the hard part — but the accumulator → doc → transaction → query round trip could be exercised against a memory ledger with a stubbed scan_for_materialize, and that is where every one of the bugs in this PR's own commit history lived (the bare-string retract, the @graph envelope, the empty-upsert abort). Three shipped-then-fixed bugs in the same seam is the argument for the test.
| } | ||
| }) | ||
| .buffer_unordered(concurrency) | ||
| .try_collect::<Vec<Vec<ColumnBatch>>>() |
There was a problem hiding this comment.
Blocking. CRITICAL (performance/memory): the materialize path buffers the entire table, twice, with no streaming or byte budget.
read_scan_tasks does try_collect::<Vec<Vec<ColumnBatch>>>(), so every Parquet file in the plan is decoded and held before a single row is processed. materialize_r2rml_graph_source:234-288 then accumulates one SubjectNode per distinct subject across all tables into MaterializeAccum, and :398 serializes the whole set into a single JSON-LD array per target. build_retract_doc:696 has the same shape — every subject IRI in the window goes into one values array in one update call.
An initial materialization (from = None → full scan, which is also what force_full and every expired/branched-history fallback take) therefore holds the decoded table plus its full JSON-LD expansion simultaneously. This does not degrade gracefully on a large table; it OOMs. Given the PR's stated motivation is large CDC tables, that ceiling deserves at least a documented row limit, and ideally chunked commits keyed off a byte budget.
Note the query engine itself is unaffected — I verified plan_scan_for_snapshot_inner and r2rml.rs::scan_table are byte-unchanged and the new helpers have no query-path callers, so there is no virtual-query regression.
| // per-row template (e.g. one graph per tenant/user) routes each row into its | ||
| // own graph so the same subject IRI holds independent per-graph statements. | ||
| // A null graph value materializes to `None` -> default graph (never dropped). | ||
| let graph_iri = match &tm.subject_map.graph_map { |
There was a problem hiding this comment.
Blocking. rr:graphMap is honored only by the materialize path, so the virtual and materialized views of the same source disagree about where the triples live.
grep -rn "graph_map" across fluree-db-query/ and fluree-db-api/ returns exactly one consumer: this line. The query path never reads SubjectMap::graph_map. So for a mapping with a subject-level rr:graphMap, querying the graph source virtually returns every triple in the default graph, while the materialized twin puts them in per-row named graphs — and a query written against one returns nothing against the other. That breaks the PR's own premise that materializing gives you the same data at native speed, and it is silent.
Either teach the query path to honor graph maps too (parity), or gate materialization on the mapping having no graph map until it does.
Separately, R2RML permits rr:graphMap on predicate-object maps as well as subject maps; extractor.rs:extract_graph_map only reads the subject-map position, so a POM-level graph map is silently ignored rather than rejected — that one should at minimum fail loud.
| let storage_vend_scope = resolve_storage_vend_scope(&config); | ||
|
|
||
| // Spawn the R2RML/Iceberg materialization tracking worker on write | ||
| // nodes. Peers forward writes elsewhere, so they don't run it. (In Raft |
There was a problem hiding this comment.
Optional, but please confirm the deployment story. (Covers :285-287.)
The code comment concedes it: "In Raft mode the worker currently runs on every node and writes directly via Fluree::upsert; gating it to the leader is a follow-up." Every non-Peer node spawns its own worker with its own in-memory job set.
If a multi-node non-Peer deployment is reachable today, two workers polling the same (source, target) will interleave their retract and upsert commits — and since the retract at r2rml_materialize.rs:376 and the upsert at :400 are two separate transactions, node A's retract landing after node B's upsert leaves subjects missing until the next poll.
The same race exists between the worker and a concurrent POST /iceberg/materialize for the same pair even on a single node, since nothing serializes them. A per-(source, target) mutex around the pass would close the single-node half cheaply.
| /// (fan-out: one ledger per partition, e.g. per (tenant,user)). Returns `None` | ||
| /// when a template placeholder column is null/missing — the row cannot be routed | ||
| /// to a ledger and is skipped (it could not be isolated to a user anyway). | ||
| fn resolve_target_ledger(target: &str, batch: &ColumnBatch, row: usize) -> Option<String> { |
There was a problem hiding this comment.
Design discussion — worth resolving before the fan-out ships; non-blocking for the rest.
First, your stated premise is correct, and I verified it rather than assuming: fluree-db-policy/src/ has no graph-scoped enforcement (12 "graph" references, all about the policy model graph; graphs: None at index.rs:327,545). Named graphs genuinely are not an authorization axis here, so per-ledger bearer scope genuinely is the isolation boundary. The reasoning in eb62eaa4e is sound.
The open question is whether ledger-per-(tenant,user) is the right answer to that constraint: it converts a policy gap into unbounded infrastructure multiplication — every partition gets a nameservice record, a commit chain, and its own index set, created implicitly from row data with no bound, no quota, and no operator visibility beyond the ledger list. It also puts the PR in the odd position of shipping two isolation mechanisms (named graphs and separate ledgers) where only one can be the boundary. I'd like us to weigh graph-scoped policy as the longer-term investment before the fan-out lands, rather than settling it implicitly inside this PR.
| self.bump(|s| s.syncs_noop += 1); | ||
| debug!(source = %job.source, target = %job.target, "materialize tracking: no delta"); | ||
| } | ||
| Err(e) => { |
There was a problem hiding this comment.
Optional.
A permanently failing sync is a warn! plus a syncs_failed counter, so a target ledger can diverge from its source indefinitely while GET /iceberg/tracking still reports the worker healthy. Given the MoR refusal recommended in the scan-path comment will make exactly this happen post-merge for MoR tables, consider surfacing per-job last_error and last_success timestamps on the tracking endpoint.
| entity**, not individual columns — a `null` in an ordinary column of a *live* row | ||
| just clears that one predicate. | ||
|
|
||
| ### Assumptions and limitations |
There was a problem hiding this comment.
Optional.
The "Assumptions and limitations" section is genuinely good on CDC shape (one row per subject revision, one triples map per table, orderable order_by, dedicated target, tombstones required). It omits four things the code does: merge-on-read is unhandled on the incremental path, the full sync is unbounded in memory, the worker runs on every non-peer node so multi-node deployments double-materialize, and a templated target creates ledgers without bound. #1520 added an MoR section to this same file on main — the materialization section should cross-reference it after the rebase.
| @@ -0,0 +1,224 @@ | |||
| //! Materialization tracking jobs must survive a server restart. | |||
There was a problem hiding this comment.
Praise.
This is the strongest-tested part of the PR: four tests that drive the real persist_materialize_job / forget_materialize_job / tracked_materialize_jobs API and the worker's actual restore path, including the negative cases (untracked jobs stay out, re-tracking replaces rather than duplicates, restore is a no-op with no state ledger). This is exactly the pattern the materialization engine tests should follow.
| #[cfg(feature = "iceberg")] | ||
| let v1_admin_protected_writes = | ||
| v1_admin_protected_writes.route("/iceberg/map", post(iceberg::iceberg_map)); | ||
| let v1_admin_protected_writes = v1_admin_protected_writes |
There was a problem hiding this comment.
Praise.
The four new routes are correctly mounted on the existing v1_admin_protected_writes / v1_admin_protected_reads layers rather than inventing a new auth path, so they inherit admin-token enforcement and leader-forward ordering for free. Correct altitude.
|
Taking the split — it's the right call, and thank you for laying it out that concretely rather than just blocking. Answering per part. Part 1 — up as #1567
Your wildcard-free canary in Part 2 —
|
Summary
Adds the ability to materialize an Iceberg / R2RML graph source into a native Fluree ledger and
keep it fresh with a background tracking worker. Querying a graph source directly re-reads and
re-joins the raw Iceberg rows on every request; materializing into a native ledger gives deduped,
indexed, time-travelable state that queries at native speed and refreshes incrementally.
Five commits:
fluree-db-iceberg).fluree-db-api).fluree-db-api,fluree-db-server).Iceberg REST catalog keeps authenticating past the ~1h a static bearer lasts.
Builds on the existing Iceberg graph-source support on
main.Motivation
A graph source exposes an Iceberg table as read-on-query triples. That's ideal for ad-hoc access,
but not for a dataset you query repeatedly:
versions per entity. A graph source surfaces all of them; there's no "current state" of a subject.
?s a X ; p1 ?a ; p2 ?b …) join the rawappend-only rows on each request, which blows up combinatorially and re-does the scan every time.
Materializing solves all three: collapse the append-only rows to one node per subject (upsert),
write them into a native ledger (native indexes, SPARQL, history), and refresh incrementally off
the source's snapshot log. The tracking worker automates the refresh.
Changes
fluree-db-iceberg— incremental scan + snapshot windows (scan/planner.rs,scan/send_planner.rs,metadata/table.rs,config.rs): plan a scan over only the data filesadded between two snapshots (append-only incremental), plus helpers to resolve the current
snapshot and the delta from a watermark. Falls back to a full scan when an incremental window
isn't safe (e.g. a non-append snapshot). + unit tests.
fluree-db-api— R2RML materialization (graph_source/r2rml_materialize.rs): readgraph-source rows (full or incremental) and materialize them into a native ledger.
Latest-by-key dedup collapses append-only rows to one node per subject (upsert); whole-subject
tombstone retraction removes a subject when its key disappears from the source. A per-
(source, table)watermark node (urn:fluree:materialize-state:{source}:{table}, source:escaped sothe encoding is injective) persists the last-materialized snapshot id in the target ledger, so a
refresh resumes incrementally;
force_fullignores it. + unit tests (watermark encoding/injectivity,latest-by-key, retract shape).
fluree-db-api— tracking worker (materialize_worker.rs): a per-job worker(
TrackedJob { source, target, interval, next_due }) driven by a base-granularity ticker; each jobre-syncs on its own poll interval. Public API:
MaterializeTrackingWorker,MaterializeWorkerConfig/Handle/Stats. + unit tests (due-job selection + rescheduling).fluree-db-server— endpoints (routes/iceberg.rs,routes/mod.rs,state.rs): the fourendpoints below.
/trackruns an immediate first sync on registration so the target ispopulated without waiting a cycle;
/trackingis an admin-protected read of this node's workerstate. Peer nodes forward writes to the write node, consistent with the existing iceberg routes.
fluree-db-iceberg— refreshable catalog auth (auth/google_metadata.rs,auth/token.rs,auth/mod.rs): agoogle_metadataAuthConfigvariant that mints and auto-refreshes catalogOAuth tokens from the GCE/GKE metadata server (Workload Identity). A
CachedTokenhelper isextracted from the existing OAuth2 provider and shared, so the new provider is the same small shape
as
oauth2.rs(leanreqwestclient + cache, no new dependencies). Exposed viaauth_google_metadataon the map endpoint. + unit tests (wiremock:Metadata-Flavorheader,scopes, caching, failure surfacing).
fluree-db-api(graph_source/r2rml.rs): incremental-scan wiring onFlureeR2rmlProvider—prepare_iceberg_scan(shared REST/Direct storage + table-metadata setup overS3IcebergStorage),read_scan_tasks,current_snapshot_id, andscan_table_incremental.docs: materialize / track / untrack / tracking in
docs/api/endpoints.md; a materialization +tracking section (incl. append-only dedup and the watermark) in
docs/graph-sources/iceberg.md; theauth_google_metadataoption.New endpoints
/iceberg/materialize{source, target, force_full?}target. Returns{from_snapshot_id, to_snapshot_id, incremental, committed, rows_read, subjects_upserted, subjects_retracted}./iceberg/track{source, target, poll_interval_secs?}source → targetjob, run an immediate first sync, then keeptargetfresh on the interval (default 30s). Returns the effective interval + the first-sync result./iceberg/untrack{source, target}/iceberg/trackingAuth
The tracking worker holds a job open for hours, so catalog auth has to survive token expiry. For a
workload running as a GCP service account (GKE Workload Identity / GCE), set
auth_google_metadata: true: tokens are minted and auto-refreshed from the instance metadata server,so tracked jobs against a Google Iceberg REST catalog (e.g. BigLake) keep authenticating. A static
auth_bearerstill works for one-shot map/query and for local use, but expires after ~1h.Design note: the provider talks to the metadata endpoint directly rather than pulling a full
Application-Default-Credentials crate. That keeps
fluree-db-iceberglean and runtime-agnostic(
AuthConfigstays an explicit, declared config rather than ambient credential discovery), and itmirrors the existing
oauth2_client_credentialsprovider exactly — same struct shape, sameCachedToken, no new dependencies. It's precisely how a GKE workload authenticates. Storage-sideHMAC interop keys are unaffected — they don't expire.
Testing
fluree-rust-dev, workspace pinned toolchain):cargo test -p fluree-db-iceberg --lib→ 148 passed (incremental scan / snapshot windows,google_metadataprovider,CachedToken).cargo test -p fluree-db-api --features iceberg --lib→ 669 passed (watermarkencoding/injectivity, latest-by-key dedup, worker due-job scheduling/rescheduling).
cargo test -p fluree-db-api --features iceberg --test grp_graphsource→ 103 passed, incl.it_materialize_retract— a regression test that whole-subject retraction binds?sas a typed@id(a bare-string binding parses to a literal that joins nothing and silently retracts zerorows) and asserts the triples are actually gone end-to-end.
cargo fmt --all --checkclean;cargo clippy --all-targetsclean on the touched crates,with the
icebergfeature both off and on (the materialize code is#[cfg(feature = "iceberg")],so it must be linted with the feature enabled).
R2RML mappings, tracked several tables into a single native ledger via
/iceberg/track, confirmed theimmediate first sync + an incremental re-sync on the worker (only added files re-read), and ran SPARQL
over the deduped materialized ledger — one node per subject, expected counts.
Known limitations / follow-ups
re-registered); persisting the
ApplyRecordis noted as a follow-up in the routes. The materializeddata and its watermark are durable (they live in the target ledger), so a re-registered job
resumes incrementally rather than re-reading everything.
read; combined with latest-by-key upserts this is still correct (idempotent), just less cheap.
google_metadatarequires GCE/GKE (the metadata server isn't reachable elsewhere); locally, usea static
auth_bearer.Breaking changes
None. Everything is additive: four new endpoints, a new
AuthConfigvariant, and additive requestfields (
poll_interval_secs,auth_google_metadata). All new server/API code is behind the existingicebergfeature; nothing changes for builds without it.