Skip to content

feat(materialize): fluree materialize — native-twin builder from virtual R2RML/Iceberg datasets - #1529

Open
aaj3f wants to merge 24 commits into
audit-program/land-on-mainfrom
feat/materialize-builder
Open

feat(materialize): fluree materialize — native-twin builder from virtual R2RML/Iceberg datasets#1529
aaj3f wants to merge 24 commits into
audit-program/land-on-mainfrom
feat/materialize-builder

Conversation

@aaj3f

@aaj3f aaj3f commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

feat(materialize): fluree materialize — native-twin builder from virtual R2RML/Iceberg datasets

This is one of five surviving pull requests in the consolidated virtual-dataset review program, which collapses the #1475#1529 lineage into five reviewable units at the maintainers' request. #1529 implements fluree materialize: a bulk builder that turns a virtual R2RML-over-Iceberg graph source into a native Fluree ledger ("twin"), verifies it against the source, and writes it as a pack or a local ledger. It is the last member of the reading order. Since the consolidated body below was written, the previously-open live gate has been RUN AND PASSED (an independent full-triple diff against a natively-imported ledger at full 35.2M scale came back identical — see the acceptance section directly below), and a measured performance fix slate has landed on the branch; the sections below the acceptance report are the original consolidated body, kept intact for review continuity. Reading order across the program: #1520 (standalone MoR correctness fix) → #1507 (performance program) → #1514 (serverless Lambda-usability + BYO-IAM) → #1528 (big-Iceberg-audit implementation) → #1529 (this PR). The outward-facing audit record is on the docs/virtual-dataset-audit-2026-07 branch — the fact base for materializing Iceberg into native ledgers is in C3-materialization-facts.md and the strategy framing (with the snapshot-keyed correctness lemma) is in D1-strategy-options.md.

2026-07-29 addendum — performance fix slate + full-scale acceptance (commits 74780fd25476d08192)

A stage-by-stage performance audit of this builder against the repo's own bulk-import path (fluree create --from) found that bounded-by-design had been conflated with slow-by-implementation, and a measured fix slate landed here as eight commits on this branch (per the maintainers' one-PR consolidation rule). The audit's baseline, at equal bounds (--parallelism 2 --memory-budget-mb 1024, the 35,238,778-triple sf01 dataset over a Snowflake-managed REST catalog): build+publish 348.7s, and a verify phase that double-buffered the whole graph in RAM even in quick mode — 16.5GB RSS at 6.1M-triple dims scale, projected 95–155GB at 35M, i.e. certain OOM on the build host.

The slate, in commit order on this branch: a merge-up of the consolidated program base (74780fd25); produce-phase instrumentation spans (5e429a9fd); O2 — memory-bounded verify in both modes plus decimal value-canonicalization in the parity compare (9649c3e4d); O5 — concurrent table pin + Pass-1 FK pre-index bounded by --parallelism (2caec9194); O6 — budget honesty: sub-2GB budgets now scale the chunk size instead of silently flooring at 128MB, and the FK parent index is charged against the budget with a typed ParentIndexBudgetExceeded instead of living outside it (03787651a); O4 — per-row allocation cleanup in the enumerator hot path, byte-identical across the r2rml exact-output suite (0e6cf38bb); O1 — a parallel produce side: --parallelism sync worker threads render/intern/encode with private sinks while the non-Send Iceberg scans stay on the async driver, with an in-commit differential test asserting p=1 and p=2 produce identical triple sets, contiguous chunk indices, and exactly one stamped final chunk (e77bebc15); O3a — the OwnedTriple intermediate buffer removed, terms interned directly from the enumerator into the chunk sink (06e00004f); and a dateTime value-canonicalization fix in the verify compare (476d08192, receipt below). A ninth commit closes the recorded O5(c) residual (fa96388d3): tables are scheduled into topological waves so a non-cyclic FK parent is scanned exactly ONCE — its key→subject index is built during its own emit scan and frozen per-wave for the parallel workers — eliminating the separate pre-index pass that made cold fact tables the dominant wall; only cyclic/self-referential parents keep the pre-index fallback, and the quick gate's independent source re-scan is deliberately kept (its samples are chosen post-publish, so reusing build-pass statistics would let the build self-certify).

Measured acceptance at full 35.2M scale (record: the A1/A2/B-REMEASURE documents of the materialize-perf audit series): verify now completes bounded where it previously OOM'd — the entire quick-verified 35M run peaked at 1.41GB RSS (dims scale: 16.46GB → 1.24GB, 13.3× less); a clean-load warm-cache build ran 56.0s wall with encode spread across both workers (the cold-source wall remains dominated by fact tables being scanned for Pass-1 indexing, Pass-2 emission, and the verify oracle — the recorded O5(c) follow-up on this branch, not a separate PR); and the independent oracle gate that kept this PR draft is now closed: a full-triple streaming diff (same-renderer N-Triples export of both sides → external sort → set compare, ~1.2GB RSS) between the materialized twin and the blessed natively-imported enterprise-sf01 ledger returned zero differences in either direction at 35,238,778 triples — the enumerator is proven correct against an oracle that is not itself.

Two verify-gate false-reject classes were found by that acceptance run and fixed in the parity compare's value canonicalization: decimals (stored E-notation vs the oracle's plain f64 rendering) and dateTimes (storage pads fractional seconds to six digits, …T00:16:42.000000Z, while the oracle's formatter drops the fraction when zero — the gate now normalizes insignificant fractional-second zeros and zero UTC offsets on both sides). Both were fail-CLOSED failures — the gate refused correct twins and dropped them loudly, never the reverse. Known residuals, recorded here rather than deferred to other PRs: a non-zero-UTC-offset dateTime is deliberately not collapsed to its UTC instant (the schema under test is all-UTC); graph-source resolution is CWD-relative (./.fluree) and does not honor FLUREE_HOME for sources — a UX wart worth a look; and whether a first-class skip-verify mode for operators running their own independent diff deserves productizing is an open maintainer question.

Original PR body

Base re-pointed by consolidation onto the family-C branch beneath it; no other PR's scope folded in — this survivor remains the standalone materialize builder, original text below.

fluree materialize — a native twin from a virtual (R2RML-over-Iceberg) dataset

Implements the materialization design's first deliverable: bulk-build a native Fluree ledger ("twin") from a virtual R2RML-over-Iceberg graph source, verify it against the source, and write it as a .flpack pack or a local ledger. This is the categorical fix identified by the materialization assessment — it ends the per-query cold-parquet storm and the admission-gate tail-chase for datasets that warrant a native copy, with guaranteed 1:1 native query semantics.

First-principles per the AJ directive — zero anchoring to PR #1422; no transact-replay. The build reuses the existing bulk create --from finalize → spool-sort → k-way-merge index → publish pipeline verbatim, pointed at the R2RML scan stream instead of a TTL parser.

Design + assessment record: C3-materialization-facts.md (machinery + arithmetic) and D1-strategy-options.md (the strategy space).

What a reviewer should walk

The build is one new producer branch feeding an unchanged downstream. Read it in dependency order:

  • Enumerator (Chunk A, prior): fluree-db-r2rml/src/materialize/graph.rs — the engine-free TriplesMap → (s,p,o) whole-graph enumerator. Composes the SAME per-term helpers the query operator uses (term/datatype fidelity by construction), dims-first FK resolution.
  • Streaming driver + adapter (Chunk B, prior): fluree-db-api/src/materialize.rs — scans each table through the enumerator, encodes byte-budgeted ParsedChunks via ImportSinkObserver, ships them into the existing finalizer channel (ImportSource::Virtual).
  • C2 ac21c0711 — per-table snapshot capture: IcebergCatalogSession.snapshots recorded at the single scan_table_inner caller of load_table_context (first-writer-wins), pinned_tables(gs), FlureeR2rmlProvider::build_watermark override. fluree-db-api/src/graph_source/{catalog_session.rs,r2rml.rs}.
  • C3 fdcbc11f7 — pin-all pre-pass (all snapshots pinned up front → cross-table skew bounded to seconds) + WatermarkStamp assembly (watermark vector + SHA-256 mapping hash + builder version) + fail-loud empty-watermark guard.
  • D ee7897e1e — persist the completion stamp in the twin's FINAL commit: ParsedChunk.txn_meta (empty → byte-identical for every text import) + finalize_parsed_chunk consume; the stamp is ns-encoded through the sink and rides the last non-empty commit via a one-chunk lookahead (completion-marker semantics: a valid twin is one whose head commit carries a parseable stamp). fluree-db-transact/src/{import.rs,import_sink.rs}, fluree-db-query/src/r2rml/provider.rs.
  • E 743f5e4ae — the post-build parity gate verify_twin: class counts + stratified per-subject sample (quick, always-on) and a full-triple diff (full), enumerator-as-oracle vs a native wildcard query, both rendered to identical N-Triples.
  • F+G bf27d545a — the fluree materialize CLI + MoR build-time UX. fluree-db-cli/src/commands/materialize.rs.

Correctness proofs (all hermetic; no live runs in this PR)

  • Engine-equivalencematerializer_driver_matches_engine_wildcard: the driver's triple set is byte-identical to the query engine's ?s ?p ?o wildcard over the same scan.
  • Queryable twin fidelitymaterialize_builds_queryable_native_twin: a real indexed+published twin round-trips class counts, an FK edge to the parent IRI, an xsd:decimal (value+datatype), and a lang tag (value+@en).
  • Stamp survives pack/restorematerialize_stamp_survives_pack_restore: archive_ledger → restore_ledger preserves the head-commit stamp byte-for-byte and read_stamp parses it identically.
  • Parity gateverify_twin_passes_on_faithful_twin (pass, quick+full) and verify_twin_fails_on_corrupted_twin (an injected instance fails on both the class-count check and the full diff).

Parity-gate value-semantics (ratified)

A parity gate asks "same graph in value space." "5.00"^^xsd:decimal and "5"^^xsd:decimal are one value; comparing pre-intern lexical (enumerator) against post-intern canonical (twin) is a diff artifact, not a data defect. The gate strips trailing fractional zeros for xsd numeric datatypes on both sides. Where lexical renderings genuinely differ for the same value, the twin's native rendering is contract-correct by the parity direction (native is the fixed contract) — so any client-observable decimal-lexical drift on cutover is the virtual path shedding a deviation, not the twin introducing one. The lead-run live native-sf01 full-triple diff must carry the same value-level canonicalization.

Gate record

fmt clean; scoped clippy 0 new warnings; cargo check -p fluree-db-api --no-default-features --features aws,iceberg,shacl (no-native featureset) compiles; CLI compiles with and without iceberg. Tests: fluree-db-transact lib 303, fluree-db-query lib 116, fluree-db-api materialize lib 6, it_graph_source_r2rml 60 (2 ignored ext-infra), fluree-db-cli materialize helpers 5.

Machine-safety posture

The CLI default is co-resident-tolerant (fixed parallelism=2, memory_budget_mb=512, never own-the-box auto-sizing); --memory-budget-mb / --parallelism raise it explicitly, --max-performance opts into host auto-sizing on a cleared machine. Every finalizer-touching test runs with threads=2 + memory_budget_mb=256. Verification uses publish-then-verify-then-drop: the gate needs a queryable twin, so a failed gate drops the twin — nothing unverified stays announced. (Deliverable-2 rider: solo's alias repoint must sequence strictly AFTER gate-pass.)

Residuals (deferred, tracked)

  • C-2 memory optimization — the FK parent index stores key → parent-subject-term (general); the exists-set + apply-parent-template form saves memory when parent subject-template columns ⊆ the join key. Guarded by MaterializeStats.ref_edges per-(child,predicate) counts. Not trivial → left as a guarded-threshold note.
  • Two-stage producer — the virtual import requires a multi-thread tokio runtime (block_on off a dedicated thread); a runtime-flavor-agnostic async-task → bridge-thread producer is deferred until a caller needs it.
  • --output s3 — not yet wired in the file-backed CLI (rejected up front). The direct-S3 CAS publish path exists (designed and verified CAS-safe); it needs an S3-backed home the CLI does not construct.
  • Per-property counts — skipped-with-note in the gate; the manifest-sound non-nullable fast path is a real-Iceberg optimization.
  • Bounded stratified sample — the sample currently enumerates the source; a read_task_sample-bounded sample is the scale follow-up.
  • Deliverable 3 (freshness) — snapshot-diff delta-pack shipping builds on this watermark; out of scope here.

Package-end gate (lead-run, not in this PR): the live native-sf01 full-triple diff and any live build against Snowflake. No live runs were performed here.

Warehouse-root direct resolution (follow-on, AJ live-gate registration-gap fix)

bf27d545a6fcf7fc64. The local CLI's Direct mode was single-table, blocking registration of a multi-table R2RML source over a catalog-less bucket copy (a Snowflake-managed Iceberg database whose table dirs carry random suffixes, fact_order.UIHGsQex/). Now a Direct --table-location may be a warehouse ROOT: auto-detected when its leaf dir doesn't name the requested table, each rr:tableName resolves to its own dir under the root via one session-cached S3 LIST (SendIcebergStorage::list_dir), matched by the pure match_warehouse_table_dir (<name>.<suffix>/ or bare <name>/, case-insensitive, namespace-stripped). Ambiguity/miss fails loud naming the candidates. Single-table direct is byte-unchanged (self-diff), no new flag / config change (rule documented in iceberg map --help). Registration shape: fluree iceberg map <name> --mode direct --table-location s3://bucket/warehouse/dw --s3-region <r> --r2rml <ttl> — pure ambient IAM, zero catalog/OAuth. Hermetic: 5 match tests; iceberg lib 251, api it_graph_source_r2rml 62 green. The live S3 LIST is part of the lead-run gate.

Consolidated scope

#1529 folds in no other pull requests. It implements the native-twin builder set out in the materialization design record and was built first-principles, reusing the existing bulk-ingestion finalize → spool-sort → k-way-merge → publish pipeline pointed at the R2RML scan stream. Its pre-consolidation tip is preserved at tag archive/pre-refactor-2026-07-21/feat_materialize-builder; nothing was rewritten. Its base is re-pointed onto the family-C branch beneath it (perf/family-c-filter-join), which is a sibling of the fork point, so the diff renders builder-only while GitHub notes the base is some commits ahead.

Verification of record

Every proof in this PR is hermetic; the live gate is deferred and is the basis for the draft status.

The hermetic proofs, all green: materializer_driver_matches_engine_wildcard (the driver's triple set is byte-identical to the query engine's ?s ?p ?o wildcard over the same scan); materialize_builds_queryable_native_twin (a real indexed and published twin round-trips class counts, an FK edge, an xsd:decimal, and a lang tag); materialize_stamp_survives_pack_restore (archive→restore preserves the head-commit stamp byte-for-byte); and the parity-gate pair verify_twin_passes_on_faithful_twin and verify_twin_fails_on_corrupted_twin (an injected instance fails on both the class-count check and the full diff). The independent adversarial review is recorded under R-1529 in pr-reviews-impl.md: verdict SHIP (draft) with no blocking finding — the shared text-import path is byte-untouched, the completion stamp rides the structurally-final commit so partial builds are detectable, the CLI's Box::leak is shell-only and one-per-process, and every fail-loud guard fires. The review's load-bearing observation, which a reader must internalize: the parity gate is an ingestion-integrity gate, not an enumerator-correctness gate — its oracle is the enumerator, so a fault in the enumerator itself is only caught by the deferred live native-sf01 diff.

The build/serve/sync arithmetic and the pack substrate anchors are in C3-materialization-facts.md; the native-twin-promotion strategy and its snapshot-keyed correctness lemma are in D1-strategy-options.md.

Native-impact appendix

  • One shared-path field, self-diff byte-identical for every text import. ParsedChunk.txn_meta plus a finalize_parsed_chunk consume land on the shared bulk import / transact path (fluree-db-transact/src/import.rs and import_sink.rs). Every text-import ParsedChunk site sets txn_meta = Vec::new() (parse_chunk, the ndjson constructor, and finalize), and is_virtual = config.virtual_source.is_some() is false for all file and remote imports; the new ImportSource::Virtual arm is inserted before the remote-parallel path with no existing body touched. The import.rs diff is 138 insertions / 1 deletion (the only change to an existing line is is_channel_fed gaining || is_virtual). The build_watermark trait default is empty, so non-materialize providers are unaffected, and record_snapshot is fire-and-forget first-writer-wins with no extra I/O. Verified: transact lib 303 + import 10 green, and materialize_stamp_survives_pack_restore.
  • The rest is new code reusing shared helpers. The new modules (fluree-db-r2rml/src/materialize/graph.rs, fluree-db-api/src/materialize.rs, the CLI, and the warehouse-root list_dir) compose the shared per-term helpers the query operator uses without modifying them.

Confirmed negatives (git-checked): no arrow / datafusion / parquet dependency bump, and no .github/workflows or rust-toolchain edits.

Draft status and open gates

This PR is deliberately a draft. Two gates are open and both must be run before any live cutover relies on the twin:

  • The live native-sf01 full-triple diff — now CLOSED (2026-07-29): run at full 35.2M scale against the natively-imported ledger, zero differences in either direction (see the addendum above). The enumerator-correctness gap the hermetic gate could not cover is discharged.
  • The warehouse-root direct-resolution S3 LIST arm is exercised only by a live probe against a real catalog-less bucket copy; the match logic is hermetically tested (5 cases) but the live LIST has not been run.

Until both are exercised, the builder invites hermetic review as a draft with the live parity gate stated as pending; it should not be flipped ready on the hermetics alone.

aaj3f added 13 commits July 21, 2026 02:23
…hunk A)

The engine-free core of `fluree materialize`: enumerate every (s,p,o) triple
for a compiled R2RML mapping directly off row batches, with no query plan,
operator, or fuel tracker. It reuses the exact per-term materialize helpers
the query operator uses (materialize_subject/predicate/object_from_batch,
get_join_key_from_batch), so term and datatype fidelity match the engine by
construction — the only new logic is the whole-graph orchestration.

Foreign-key edges are resolved lookup-free and dims-first: parent triples maps
are processed before their children (topological order), and a parent
key->subject index built during the parent scan resolves each child FK by hash
lookup. A dangling or null FK produces no triple (R2RML inner-join semantics).
Cyclic and self-referential parents are fully pre-indexed. Emits into a
TripleObserver (an N-Triples collector for the parity diff; an ingest-sink
adapter lands in chunk B) and collects MaterializeStats incl. per-(child,
predicate) FK-edge counts for the parity gate.

9 hermetic golden tests cover multi-class type emission, null-subject and
null-object drop, typed/lang/constant/template objects, FK inner-join, dangling
and null FK drop, composite keys, self-referential FK pre-indexing, and
dims-first ordering determinism.
… chunk B)

materialize_graph(provider, gs, observer) streams each logical table through the
whole-graph enumerator over the R2rmlProvider/R2rmlTableProvider scan path — the
provider-backed counterpart to the in-memory reference driver, with identical
dims-first ordering and the same per-batch index/emit calls. The ordering +
pre-index logic is now a shared materialize::plan() so both drivers stay in lock
step, and ParentIndexSet::needed_parent_columns feeds scan projection.

Engine-equivalence checkpoint: materializer_driver_matches_engine_wildcard runs
the driver over the same airline mock the engine `?s ?p ?o` wildcard uses and
asserts the two decoded triple sets are identical (12 triples) — the
deterministic hermetic analog of the live native-sf01 full-triple diff, proving
the new enumerator agrees with the proven query engine over an identical scan.
Plus materializer_driver_resolves_fk_across_tables: dims-first FK resolution and
dangling-drop over streamed per-table scans. Full it_graph_source_r2rml suite
green (55 passed).
ImportSinkObserver bridges the whole-graph enumerator to the native bulk
ingestion pipeline: each materialized (s,p,o) is interned and encoded through
the SAME ImportSink term_iri/term_blank/term_literal + emit_triple the Turtle
and JSON-LD parsers use, so term/datatype/language fidelity is preserved by
construction (a "9.99"^^xsd:decimal interns to a decimal flake, a "hola"@es to
a lang-tagged string). The lang-vs-explicit-datatype-vs-implicit-xsd:string
decision — the one spot literal fidelity could silently narrow — is factored
into literal_sink_args and unit-tested for all three cases.
…1, chunk B)

The materialization side of the end-to-end builder: build_virtual_chunk encodes
a buffer of materialized triples into a ParsedChunk by mirroring parse_chunk
exactly (WorkerCache + ImportSink::new_cached + SpoolContext + finish), replaying
interned triples through ImportSinkObserver instead of parsing Turtle. Zero
transact edits — every primitive is public and ParsedChunk fields are pub.

drive_virtual_import streams a virtual R2RML source through the enumerator
(dims-first, same pre-index pass) into a byte-budgeted ChunkingObserver and hands
each completed ParsedChunk to an emit callback in contiguous idx order (t=idx+1).
Chunk size is derived from BYTES (the memory budget), never a fixed row count —
the machine-safety chunk-size knob. R2rmlBuildProvider is the combined provider
trait for the builder, with a compile-time Send+Sync assertion (the ?Send/Send
trait-duality guard). Byte-budget chunk cutting is unit-tested.
…03 D1, chunk B COMPLETE)

Wires the materializer into the bulk import pipeline as a new ImportSource::Virtual
branch that REUSES the battle-tested downstream unchanged. A single materializer
thread scans the R2RML source (async, driven via Handle::block_on off a dedicated
thread), enumerates triples dims-first, and encodes byte-budgeted ParsedChunks that
feed the SAME commit_parsed_chunks_in_order consumer + spool-sort + build_all_indexes
+ publish_index the text paths use.

Provider rides on ImportConfig::virtual_source (Arc<dyn R2rmlBuildProvider>, Send+Sync
asserted); the arm is inserted before the remote-parallel arm so every existing
Local/Remote/streaming/Files arm body is byte-identical (diff = 138 insertions, 1
deletion — only is_channel_fed gains `|| is_virtual`). Chunk byte-size derives from
the memory budget (machine-safety). Entry: CreateBuilder::import_r2rml, doc'd to set
explicit --parallelism/--memory-budget-mb.

End-to-end test (multi-thread, tight bounds threads=2 + memory_budget_mb=256): builds
a real indexed+published twin from a dim+fact+FK mock with lang and xsd:decimal
literals, then queries it back — class counts, FK-edge resolution to the parent IRI,
xsd:decimal value+datatype round-trip (not narrowed to string), and lang-tag
round-trip all confirmed. All 83 bundled import tests + the 56-test R2RML suite green.
…DEC-003 D1)

The virtual materializer drives its async scan via Handle::block_on off a
dedicated thread, which deadlocks on a single-threaded tokio runtime. Detect the
runtime flavor at the arm entry and return a typed ImportError::UnsupportedRuntime
instead of hanging; production servers and the CLI run multi-thread. A code
comment + this error document the remote-style two-stage producer as the
runtime-flavor-agnostic future alternative. Guard test on a current-thread
runtime confirms the loud failure.
…DEC-003 D1, chunk C)

Adds the per-table build-watermark capture seam: R2rmlProvider::build_watermark
returns HashMap<table, TableWatermark> with a DEFAULT of empty, so every existing
provider keeps compiling and a real Iceberg-backed provider overrides it to report
its catalog session's pins. TableWatermark carries the authoritative
metadata_location pin plus best-effort snapshot_id/sequence_number (typed Option,
for the Deliverable-3 snapshot diff). The bulk builder will fail loud when this is
empty for a non-empty table set, so a provider that forgets to override cannot
publish an unstamped twin.
…og session (DEC-003 D1, chunk C2)

Record each table's pinned Iceberg snapshot (metadata_location + snapshot_id +
sequence_number) into a new IcebergCatalogSession.snapshots map at the single
scan_table_inner caller of load_table_context, first-writer-wins. Expose it as
pinned_tables(gs) -> {table -> TableWatermark} and override
FlureeR2rmlProvider::build_watermark to surface it. Harmless for ordinary
queries; the driver reads it only during a materialize build.
…003 D1, chunk C3)

Add a metadata-only pin-all pre-pass to drive_virtual_import so every table's
snapshot is pinned before emission (cross-table skew bounded to seconds; emit
scans reuse the pins, no duplicate loadTable GETs). Assemble the twin's
WatermarkStamp — per-table watermark vector + SHA-256 mapping hash + builder
version — with a fail-loud guard that errors on an empty watermark for a
non-empty table set (§17(a)/(b)). The stamp is logged here; chunk D persists it
into the final commit. Mocks override build_watermark with fakes.
…ommit (DEC-003 D1, chunk D)

Add ParsedChunk.txn_meta (empty for every text-import chunk → byte-identical
commits) and consume it in finalize_parsed_chunk. The materialize driver
ns-encodes the WatermarkStamp (builder version + mapping hash + watermark vector
as sorted JSON) into ONE chunk's txn_meta via the sink's new
intern_meta_predicate (so the stamp predicates' ns codes publish in that
commit's namespace_delta). A one-chunk lookahead makes the stamp ride the LAST
non-empty commit — completion-marker semantics: a valid twin is one whose head
commit carries a parseable stamp. TableWatermark gains serde derives; read_stamp
parses it back. Round-trip test: build -> archive_ledger -> restore_ledger ->
stamp survives byte-identical and parses.
…e + full diff (DEC-003 D1, chunk E)

verify_twin compares the built twin against its virtual source using the Chunk-A
enumerator as the oracle and a native wildcard query on the twin, both rendered
to identical N-Triples: class counts (always-on), a stratified per-subject
sample (Quick, always-on, full node compare of the first N subjects/class), and
a full-triple diff (Full). Per-property counts are skipped-with-note (the
manifest-sound fast path is a real-Iceberg optimization). Numeric object
literals are value-canonicalized on both sides so a value-preserving typed-
literal normalization (stored xsd:decimal re-rendering '5.00' as '5') is not
misreported — the same treatment the LIVE full-triple diff will need. Gate
failure => report FAILED, twin not announced (publish-then-verify-then-drop, the
sound ordering; the CLI drops on failure). Hermetic tests: faithful twin passes
Quick+Full; a corrupted twin (injected instance) fails on the count + full diff.
…3 D1, chunks F+G)

New `fluree materialize <graph-source>` command: build a native twin from a
virtual R2RML source, verify it, and write it. Flags: --into (default -twin),
--output pack|ledger|s3 (pack default; s3 rejected up front as not-yet-wired in
the file-backed CLI), --output-path, --verify quick|full, --max-performance,
--home, and (G) --allow-mor-deletes. §14 co-resident posture: conservative fixed
defaults (parallelism=2, memory_budget_mb=512) unless the user sets an explicit
value or --max-performance (own-the-box auto). Publish-then-verify-then-drop: a
failed gate drops the twin so nothing unverified stays announced. One-shot CLI
leaks the Fluree for a 'static real provider (single build-scoped session).
G: the MoR scan-path guard's refusal is surfaced with the --allow-mor-deletes
override (sets FLUREE_ICEBERG_ALLOW_MOR_DELETES). Command self-gates on iceberg
(default on); a not(iceberg) build gets a clear error. 5 helper unit tests.
…dget honesty, drop-branch fix (DEC-003 D1)

Four review-response items (all nonblocking, one commit):
1. Verify-default honesty: print the Quick gate's shared-oracle epistemics at run
   time (catches ingest/index corruption, NOT enumerator logic — run --verify
   full + the native diff before cutover). Make the stratified sample
   seeded-deterministic (WatermarkStamp.sample_seed, recorded as a sampleSeed
   Long, derived from the mapping hash) and spread across the class via a
   seed-mixed hash instead of the lexicographic first-3 — tail-subject bugs are
   now sampleable, reproducible, and coverage widens as the seed rotates.
2. Quick negative test: a per-subject property corruption (counts unchanged)
   fails the Quick sample arm.
3. Budget-floor honesty in --help + the gate doc: --memory-budget-mb is advisory
   sub-2GB (chunk floors at 128MB), peak RAM ~= 2GB + parallelism*128MB*2.5, and
   the FK parent index sits OUTSIDE the budget (~ largest parent table).
4. Drop-on-fail test (dropped_twin_is_unpublished) — it surfaced a real CLI bug:
   drop_ledger rejects a :branch suffix (HTTP 400), so the gate-fail drop would
   have errored and left the unverified twin announced. FIXED: strip the branch
   (ledger_name_no_branch) and surface a loud manual-drop warning if the drop
   still errors instead of swallowing it.
…i-table sources (DEC-003 D1)

AJ-driven registration-gap fix: the local CLI's Direct mode was single-table, so
a multi-table R2RML source over a catalog-less bucket copy (a Snowflake-managed
Iceberg database whose table dirs carry random suffixes, e.g.
fact_order.UIHGsQex/) could not register. Now, when a Direct table_location is a
WAREHOUSE ROOT (auto-detected: its leaf dir does not name the requested table),
each rr:tableName resolves to its own dir under the root via ONE session-cached
S3 LIST, matching <name>.<suffix>/ or bare <name>/ case-insensitively
(namespace-stripped). Ambiguity or a miss fails loud, naming the candidates.

- New SendIcebergStorage::list_dir (S3 ListObjectsV2 + delimiter; default-errors
  for other backends; LazyStorage forces + delegates).
- Pure match_warehouse_table_dir in fluree-db-iceberg (5 hermetic tests:
  suffixed, bare, case, ambiguity, miss).
- FlureeR2rmlProvider::resolve_direct_table_location + IcebergCatalogSession
  warehouse-listing cache (one LIST per build).
- Single-table direct behavior byte-unchanged (self-diff: only the info! moved;
  api it_graph_source_r2rml 62 green, iceberg lib 251 green).
- No new flag / no config-schema change (table_identifier already tolerates a
  root; Direct registration does no probe). Rule documented in iceberg map --help.
@aaj3f
aaj3f changed the base branch from perf/browse-parity to perf/family-c-filter-join July 21, 2026 20:26
@aaj3f aaj3f changed the title feat(materialize): fluree materialize — native twin builder (DEC-003 Deliverable 1) feat(materialize): fluree materialize — native-twin builder from virtual R2RML/Iceberg datasets Jul 21, 2026
Base automatically changed from perf/family-c-filter-join to perf/audit-mainmerge-base July 29, 2026 01:34
@aaj3f
aaj3f changed the base branch from perf/audit-mainmerge-base to audit-program/land-on-main July 29, 2026 15:00
aaj3f added 7 commits July 29, 2026 11:09
… feat/materialize-builder

# Conflicts:
#	fluree-db-transact/src/import.rs
Per-phase tracing spans on the materialize produce side: pin_all_tables,
per-table table_scan_emit (scan+render+emit wall + triple delta), and an
encode_total accumulator across build_virtual_chunk calls. These are the
spans the A2 A/B phase attribution was measured with; they ship with the
fix slate so the same numbers are reproducible on any build.

The two verify-phase spans in this commit are superseded by the O2 verify
redesign that follows in this slate.
…zation

verify_twin double-buffered the ENTIRE graph twice in RAM even in Quick mode
(full source re-enumeration into a BTreeSet + full twin wildcard into another;
~26.8GB footprint at 6.1M triples, certain OOM at 35M). Redesign both modes to
be memory-bounded:

- Quick (default): peak RSS = O(sampled subjects). Class counts come from a
  single streamed source pass (SampleAndCountObserver — counts per-class type
  triples, retains full triple sets ONLY for the sampled subjects, drops the
  rest as it streams) plus bounded twin COUNT queries; the per-subject sample
  pulls a seeded window of subjects on each side via bound-subject queries.
- Full (--verify full): spools BOTH sides to on-disk N-Triples (source streamed
  through the enumerator, twin paged through bounded wildcard queries), external
  merge-sorts each under a bounded working set, then streams a k-way diff. Peak
  RSS = O(one sort run). Replaces the whole-graph double-buffer.

- Decimal false-reject fix (A2 receipt): canonicalize_numeric_nt is now value-
  canonical for double/float — parse to f64 and re-render the shortest round-trip
  form so XSD E-notation (Fluree storage, '-1.5521762E1') and the enumerator's
  plain f64::to_string() ('-15.521762') compare equal. decimal/integer keep exact
  lexical canonicalization (no lossy float round-trip). Regression tests included.

Drop-on-fail already checks the drop result and warns if the drop errored (CLI).
The two verify-phase spans from the instrumentation commit are superseded here.
…-parallelism)

The produce side pinned snapshots and pre-indexed FK-parent tables strictly
serially. A2 measured ~30s of serial loadTable round-trips (pin_all_tables) and
~217s of strictly-serial cold-S3 fact-table fetch (Pass-1 pre-index) at 35M.

- pin_all_tables: the 8-16 serial loadTable GETs now run concurrently via
  buffer_unordered, bounded by the --parallelism knob.
- Pass-1 pre-index: pre-index tables now scan concurrently (bounded by
  --parallelism), each into its OWN partial ParentIndexSet, merged back once all
  scans complete (ParentIndexSet::split_empty + merge_from). Parent IRIs are
  disjoint across pre-index tables, so the merge is a union. Peak extra memory =
  parallelism x (one batch + one table's partial index); the partials sum to the
  final index, resident regardless of ordering.

drive_virtual_import takes a parallelism argument (the existing knob, previously
only sizing the result channel); the virtual arm passes the effective parse
threads. Machine-safety: all new concurrency is capped by the knob (default 2,
co-resident).

O5(c) - eliminating the fact double-read (Pass-1 index scan + Pass-2 emit scan of
the same table) - is DEFERRED: it is naturally subsumed by O1's produce-side pool
(topological parent-first emit that indexes during the emit scan), and doing it
standalone is more invasive than warranted here. Recorded as a follow-up.
…x accounting

Two bounds-correctness holes the audit flagged (O6):

(a) effective_chunk_size_mb underflowed for sub-2GB budgets: (budget-2048)
    saturated to 0 and the clamp then floored the chunk at 128MB REGARDLESS of
    budget — so a 512MB-budget import used the same 128MB chunk as a 2GB one, and
    a 128MB chunk under a 512MB budget blows the budget once the ~2.5x parse/
    inflight expansion is counted. Below 2GB the chunk now scales as
    budget*0.6 / working-set, clamped to [16,128] MB (continuous with the
    large-budget floor at the 2GB boundary). The >=2GB path is unchanged.

(b) the FK ParentIndexSet is fully resident for the whole build and sat OUTSIDE
    any budget. It is now charged against a fraction (~50%) of the import memory
    budget and the build FAILS LOUD (MaterializeError::ParentIndexBudgetExceeded,
    mirroring the QueryError::MemoryBudgetExceeded pattern) if it would overflow —
    checked after Pass-1 and after each lazily-indexed dimension. Estimate via
    ParentIndexSet::estimated_bytes (key + term string bytes + per-entry
    overhead). drive_virtual_import takes a memory_budget_bytes argument; the
    virtual arm passes the effective import budget (0 disables the guard).

The materialize --help BUDGET note is rewritten to describe the new model (it
claimed the budget was advisory below 2GB and the FK index was outside it — both
now fixed). Tests: sub-2GB chunk scaling, large-budget unchanged, budget-fraction
math, empty/disabled guard, estimated_bytes nonzero on a populated index.
…t path

Byte-identical allocation reductions on the per-row produce path (verified by the
exact-triple-set enumerator tests and the template tests — 121 r2rml tests green):

- expand_template_from_batch: replace the per-placeholder `result.replace(...)`
  loop — each call a full re-scan + re-alloc of the whole growing string, O(placeholders x len)
  per row, and prone to double-expanding a value containing another placeholder's
  text — with a single-pass build from the original template's segments into one
  reserved String. One pass, one allocation, exact R2RML semantics.

- emit_batch: hoist per-batch what was rebuilt per row: the rdf:type object terms
  (was RdfTerm::iri(class.clone()) cloning every class IRI on every row) are built
  once; each POM's constant predicate is borrowed via Cow (the common case) so the
  hot data-triple path skips the per-row predicate clone; and this TriplesMap's
  triple total folds into stats.per_tm ONCE per batch (one tm.iri clone per batch,
  not one per emitted triple).

SCOPE: this is the low-risk, self-contained half of O3/O4. The OwnedTriple double-
materialization drop (O3a — intern directly from the enumerator into the chunk
sink, cutting on true encoded size) and the per-TriplesMap compiled-template cache
are DEFERRED to the O1 produce-parallelism PR: they restructure the same
emit->chunk pipeline O1 rearchitects, so landing them together (validated by the
35M re-measure) avoids conflicting rework. Recorded as a follow-up.
…rallelism

The entire produce side ran on ONE virtual-materializer thread; --parallelism only
sized the result channel, so the create-from path's measured 3.9x (1->2 threads)
scaling was fully foregone (A2). This activates the knob as a real produce pool.

Design (mirrors the proven text-import N-worker + reordering-consumer pattern):

- Pass-1 now pre-indexes EVERY FK parent (not just cyclic/self-referential), so the
  ParentIndexSet is COMPLETE and READ-ONLY before Pass-2 — a child may emit on any
  worker in any order, so its parent must already be indexed. Acyclic dim parents
  that were indexed lazily during their own emit scan are therefore scanned twice
  (index + emit); the double-read on (usually small) dimensions is the cost O1
  accepts (eliminating it is the deferred O5(c)). The pre-index scans stay
  concurrent (O5), each into a partial index merged back after.

- Pass-2: `parallelism` sync worker threads render + intern + encode batches, each
  with its OWN private chunker + encoding sink. This driver scans each table (async)
  and dispatches its batches over a bounded channel; a blocking send on the
  dedicated producer thread is natural backpressure. The r2rml scan futures are
  !Send, but workers do only SYNC render/encode (the async scans stay on the
  driver), so no Send bound is needed — sidestepping the non-Send trap.

- Chunk idx comes from a shared atomic; commit_parsed_chunks_in_order reorders by
  idx, so out-of-order arrivals are fine. The completion stamp rides ONE dedicated
  final chunk (highest idx -> committed last -> the twin's head), produced after the
  workers join — the already-supported empty-stamp-commit shape.

Peak produce memory = parallelism x (one chunk buffer + one encoding sink) — the
same N x chunk model the text path lives under; bound = --parallelism (default 2,
co-resident), no new knob. drive_virtual_import now takes a Send+Clone SyncSender
instead of an FnMut sink so the pool can fan chunks in; the virtual arm keeps a
sender clone to surface a build error after the driver drops its copy.

Correctness is validated hermetically (no 35M re-measure needed — that stays the
separate perf phase): parallel_produce_matches_serial_and_stamps_once drives a
fixture mapping at p=1 and p=2 and asserts identical MaterializeStats, identical
total op_count (no triple lost/duplicated), unique + contiguous chunk indices, and
EXACTLY ONE stamped chunk at the highest idx. SpoolConfig gains #[derive(Clone)]
(all-Arc fields) so each worker gets its own handle; fluree-db-tabular becomes a
direct api dep to name ColumnBatch on the work channel.

DEFERRED (recorded here, in this PR — not a second PR): O3a (drop the OwnedTriple
intermediate; intern directly from the enumerator into the chunk sink) does not
ride naturally on this worker restructure — it rewrites the same per-worker
emit->chunk path — so it lands as a follow-up commit on this branch.
aaj3f added 2 commits July 29, 2026 13:11
…ect to sink

The produce path materialized every triple TWICE: the enumerator emitted it, the
ChunkingObserver cloned it into an owned (RdfTerm, String, RdfTerm) buffer, then
build_virtual_chunk re-interned the whole buffer into a fresh ImportSink — three
touches per term (alloc, clone, hash+copy). This removes the intermediate.

Each produce worker now interns each batch's triples DIRECTLY into an open
per-chunk ImportSink as the enumerator emits them (new InterningObserver), so a
term is allocated once by the enumerator and interned once into the sink
dictionary — the OwnedTriple buffer and the second intern pass are gone. The
worker cuts a chunk on the running byte estimate at BATCH boundaries: it secures a
batch, claims a chunk idx (only once a batch is in hand, so no idx is wasted on an
empty chunk — a gap would stall the in-order consumer), fills the sink batch by
batch until the estimate crosses the budget, then finalizes + ships. Chunk sizing
stays byte-budgeted (triple_weight estimate, same threshold as before); the one
change is granularity — a chunk may overshoot by at most one batch, which the
memory model already tolerates (peak = parallelism × chunk, unchanged).

ImportSink borrows its allocator (&mut), so an open sink cannot be stored across
recv() calls without a self-reference; the per-chunk sink therefore lives in an
inner scope (created fresh per chunk, mirroring build_virtual_chunk's finalize),
which sidesteps the lifetime problem cleanly. build_virtual_chunk is retained for
the one dedicated stamp chunk (empty triples + completion stamp). ChunkingObserver
and its now-obsolete unit test are removed (VecDeque import with them).

Re-ran the O1 differential after this rewrite of the per-worker emit→chunk path:
parallel_produce_matches_serial_and_stamps_once PASSES — p=1 and p=2 still produce
identical MaterializeStats, identical total op_count (no triple lost/duplicated),
unique + contiguous chunk indices, and exactly one stamped final chunk at the
highest idx.

NOTE: "cut on true encoded size" is approximated by the triple_weight byte
estimate — ImportSink exposes no mid-stream encoded-size accessor, and adding one
is out of scope; the estimate matches the prior chunker's semantics.
…n receipt)

The 35M acceptance run false-rejected a PROVEN-identical twin (independent-oracle
diff: 35,238,778 triples both sides, zero diff both directions) on FACT_WEB_EVENT
— the same class of verify-canonicalization gap as the decimal fix, on the one
datatype it hadn't covered. EVENT_TS is the schema's only xsd:dateTime: storage/
export pads whole-second timestamps to 6-digit microseconds
("2026-06-28T00:16:42.000000Z") while the enumerator oracle's format_timestamp
(r2rml term.rs) DROPS the fraction when it is zero ("2026-06-28T00:16:42Z"). The
numeric canonicalizer never touched dateTime, so the sample compare reported
TripleDiff{missing:1,extra:1} per sampled event.

Fix (mirrors the decimal trailing-zero strip, applied to BOTH sides):
canonicalize_numeric_nt is generalized to canonicalize_value_nt, which now also
normalizes xsd:dateTime / dateTimeStamp / date / time — drop insignificant
trailing fractional-second zeros (and a bare "."), and normalize a zero UTC offset
(+00:00 / -00:00) or Z to canonical Z. So "…:42.000000Z" and "…:42Z" agree, as do
"…:42.500000Z" and "…:42.5Z" and "…Z" vs "…+00:00"; distinct instants and non-zero
offsets stay distinct. Lexical (dependency-free) rather than parse-to-instant — it
directly matches term.rs's formatter shapes; a non-zero offset is left intact (it
denotes a different instant without date arithmetic, and this schema is all-UTC —
a documented residual). Regression tests mirror the decimal ones: padded-vs-
unpadded, Z-vs-+00:00, and distinct-instant / non-zero-offset non-collapse.

OPEN QUESTION for the maintainer (NOT implemented here, per instruction): the
re-measure worktree carries a throwaway `--verify none` CLI flag. Whether a
first-class "skip verify" mode deserves productizing — for operators who run their
own independent-oracle diff and want to skip the in-process gate — is a product
call, flagged not decided.
@aaj3f
aaj3f marked this pull request as ready for review July 29, 2026 22:08
@aaj3f

aaj3f commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Flipped to ready: the draft-status reason (the unrun independent live gate) is discharged — see the 2026-07-29 addendum at the top of the body for the fix slate, the full-scale acceptance numbers, and the zero-diff oracle verdict at 35,238,778 triples. Reading order still applies: this reviews on top of #1554 (the landing PR for the audit program content).

…parent double-read

The O1 parallel-produce rewrite pre-indexed EVERY foreign-key parent in a separate
Pass-1 and then re-scanned each table to emit, so every big parent fact (e.g.
FACT_ORDER, parent of FACT_ORDER_LINE) was scanned twice over cold S3 — the
dominant cold-build lever the B-REMEASURE O5 verdict named.

Replace the global "pre-index every parent" pass with topological WAVE scheduling:
tables are layered so a non-preindex FK parent emits in a strictly earlier wave than
its children, and its key->subject index is built DURING its own single emit scan
(index-during-emit). Each wave freezes the cumulative parent index behind an Arc for
a fresh O1 worker pool; after the wave drains, its wave-local index folds into the
cumulative for the next wave. A non-preindex parent is therefore scanned exactly once
for the build. Only cyclic / self-referential parents keep the pre-index fallback
(they cannot be indexed in a single forward pass).

r2rml: MaterializationPlan gains `waves` (dependency_waves) + an enumerate_by_waves
reference enumerator with tests (star, two-level chain, cyclic fallback, triple-set
parity vs enumerate_from_batches).

api: drive_virtual_import rewritten to the per-wave loop; spawn_produce_workers
helper extracts the O1 worker body; the hermetic p=1-vs-p=2 differential is extended
with a two-level chain (a parent that is also a child) and a mutual-cycle fixture.

Peak produce memory is unchanged (parallelism x chunk + the resident parent index,
one live wave); no new knob. Chunk idx stays contiguous across waves via the shared
atomic, with exactly one stamped final chunk. verify_twin's source-oracle re-scan is
kept (considered-and-declined removal): the Quick sample subjects are chosen
post-publish from the twin, so the pre-publish build cannot retain them, and the
re-scan's independent re-execution is the gate's value.

@bplatz bplatz left a comment

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.

Approving. Please walk the inline notes below and decide which belong in this PR versus a follow-up — a few of them (1, 4) I think are worth folding in here; the rest are your call.

Verified locally

  • cargo clippy --all --all-features --all-targets -- -D warnings — clean.
  • cargo nextest run -p fluree-db-api -p fluree-db-r2rml -p fluree-db-cli -p fluree-db-transact -p fluree-db-iceberg --all-features --no-fail-fast — 4373 passed, 0 failed.

The one finding that is not inline

No docs. This adds a new top-level fluree materialize command with no docs/cli/materialize.md and no docs/SUMMARY.md entry; every one of the other ~43 CLI commands has both. docs/graph-sources/r2rml.md and docs/graph-sources/iceberg.md are the natural home for the twin / watermark / completion-stamp concept and for the new warehouse-root --table-location resolution rule, which the body says is documented only in iceberg map --help. Per the repo's docs mandate this belongs in the same changeset.

Process note

The base audit-program/land-on-main is 45 commits and ~10k lines ahead of main, so this diff is ~6.4k of the ~16.5k that actually reaches main through it. Worth stating explicitly for whoever sequences the landing.

What is strong

The wave-scheduled index-during-emit design is the right shape, and claiming a chunk idx only AFTER securing the first batch — so an empty chunk can never leave a gap that stalls the in-order consumer forever — is exactly the kind of detail that is easy to get wrong and was gotten right. Publish-then-verify-then-drop is sound: I confirmed create ... import rejects a ledger that already has commits, so the hard drop can only ever hit a ledger this build just created, and the :branch strip closes the one way that guard could have been defeated. The verify-default honesty message printed to the operator is the right instinct. And apart from the chunk-sizing note, the shared-path footprint really is as small as the appendix claims.

// buffers + index build and divide the rest across the working set;
// floor at 16MB (still worth a commit), cap at 128MB (continuous with
// the large-budget floor at the 2GB boundary).
let working = budget_mb as f64 * 0.6 / denominator;

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.

Shared-path behavior change that the native-impact appendix does not list.

The appendix says the only shared-path change is ParsedChunk.txn_meta, "self-diff byte-identical for every text import." This rewrite of effective_chunk_size_mb changes chunk sizing for EVERY import whose budget is under 2048 MB — Turtle/TriG/JSON-LD included, not just materialize.

Concretely, at memory_budget_mb = 512 with max_inflight = 2: before, 512.saturating_sub(2048) = 0clamp(128, 768) = 128 MB. After: 512 * 0.6 / 6 = 51 MB. That is ~2.5x the commit count and a different ledger shape for the same input.

The default budget is 80% of RAM, so most hosts land above 2048 and are unaffected. What IS affected: containers under roughly 2.5 GB, and anyone who passes --memory-budget-mb explicitly — which the PR's own machine-safety posture actively encourages, and which every finalizer-touching test in this repo already does (memory_budget_mb = 256).

The fix itself reads correct to me — the old floor genuinely ignored the budget below 2 GB and the comment explains exactly why. The ask is just that the appendix name it as a second shared-path change with its blast radius, rather than the byte-identical claim standing unqualified.

let mut offset = 0usize;
loop {
let q = format!(
"SELECT ?s ?p ?o WHERE {{ ?s ?p ?o }} ORDER BY ?s ?p ?o LIMIT {PAGE} OFFSET {offset}"

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.

OFFSET paging over the whole twin is O(n^2), and moves the memory cost onto the query side.

At 35M triples this is ~176 pages, and each page asks the engine for a full ORDER BY ?s ?p ?o over the entire twin and then skips M rows. Each page also materializes 200k bindings as serde_json::Value via to_sparql_json.

So the boundedness O2 won is real on the verify side (external sort, SORT_RUN_LINES runs) but the twin-read side is neither bounded nor linear. The addendum's 35.2M full-triple diff reads like an external same-renderer N-Triples export rather than a run of verify_twin_full, so I do not think this specific path has been exercised at that scale — worth confirming.

An index-ordered scan or the existing export path would give a single linear pass; spool_twin_ntriples is most of the way to re-implementing one already.

let mapping = provider.compiled_mapping(graph_source_id, None).await?;
let classes = mapping_classes(&mapping);

let dir = std::env::temp_dir().join(format!(

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.

std::env::temp_dir() may be tmpfs, which would undo the bounded-memory rewrite.

Full mode writes two complete N-Triples renderings of the graph plus their sorted runs here — tens of GB at the scales this builder targets. On many Linux hosts /tmp is tmpfs and therefore RAM-backed, so the verify that was just rewritten to be memory-bounded would put it all back in memory, on exactly the large datasets where it matters.

There is also no override. Suggest defaulting to the import's spool dir (or somewhere under .fluree) and adding a --tmp-dir. TmpDirGuard cleans up on drop but not on SIGKILL, which is a second reason not to leave GBs in a shared /tmp.

/// under `materialize:watermark`. The whole stamp must fit the 64 KiB txn_meta
/// cap; a schema with a very large table count would need a compacter encoding
/// (documented residual).
fn encode_stamp(

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.

The 64 KiB cap this doc comment asserts is not enforced on this path.

MAX_TXN_META_BYTES is checked in exactly two places — fluree-db-transact/src/parse/txn_meta.rs:407 and parse/trig_meta.rs:1398 — both of which validate txn-meta arriving from user TriG/JSON-LD. encode_stamp builds TxnMetaEntrys directly and hands them to finalize_parsed_chunk, so neither validator runs.

The watermark JSON grows with table count times metadata_location length; S3 metadata URIs plus JSON framing run roughly 350 bytes per table, so ~190 tables. That is not an unreasonable warehouse.

Every other guard on this path is fail-loud with a typed error (EmptyWatermark, ParentIndexBudgetExceeded). This one should be too, rather than a comment describing a cap and a downstream failure mode that is not stated.

/// `Some` here (all three stamp fields present). Keys on the stable predicate
/// LOCAL NAMES (the namespace code is per-ledger); the local names are
/// materialize-specific, so requiring all three together identifies the stamp.
pub fn read_stamp(txn_meta: &[TxnMetaEntry]) -> Option<WatermarkStamp> {

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.

This ignores the namespace that intern_meta_predicate was added to publish.

The reason GraphSink::intern_meta_predicate exists (and the reason encode_stamp routes the stamp predicates through the chunk's own sink) is so their namespace codes land in this commit's namespace_delta and resolve when the commit is read back. Then read_stamp matches on bare local names, so any commit carrying builderVersion + mappingHash + watermark string entries in ANY namespace parses as a valid twin stamp.

Given the stated contract is "a twin is valid iff a head-walk finds the stamp," it seems worth resolving the ns code and requiring it to be MATERIALIZE_NS. The information is already there by construction — the mechanism was built and then not used on the read side.

/// sink, so the stamp predicates' namespace codes publish in this commit's
/// namespace_delta. Every other chunk passes `None`, leaving `txn_meta` empty
/// (byte-identical to a text-import chunk).
pub fn build_virtual_chunk(

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.

Vestigial after O3a — triples is now always empty.

Since the O3a commit removed the OwnedTriple intermediate, the only remaining call is build_virtual_chunk(&[], ctx, t, idx, Some(&stamp)) at line 1006. So the triples parameter is dead on every path, ImportSinkObserver exists solely to serve it (its only use is the loop at 335-337 that never iterates), and the OwnedTriple alias exists only to name the dead parameter.

All three are pub, so no dead-code warning fires and nothing will surface this later — precisely the rot the repo's dead-code policy is aimed at. Suggest build_stamp_chunk(ctx, t, idx, stamp) and deleting ImportSinkObserver + OwnedTriple, or keeping them with the #[expect(dead_code)]-style why/when comment the policy asks for.


/// Subjects per class fully compared by the stratified sample (deterministic:
/// the lexicographically first N subjects of each class).
const SAMPLE_SUBJECTS_PER_CLASS: usize = 3;

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.

Worth surfacing in --help: the default gate's sample strength is three subjects per class. Combined with the class-count check that is a reasonable smoke test, but "quick verify" reads stronger than it is, and the CLI already prints an honesty note about the shared oracle — the sample size belongs in the same breath.

Separately, seeded_offset can put the window near the end of a class, so twin_sample_subject_iris issues ORDER BY ?s LIMIT 3 OFFSET N with N close to the class size — a full sort and skip per class. Fine at the sizes tested; the same shape as the full-mode paging note above.

entry
.entry(cols.clone())
.or_default()
.insert(key, subject.clone());

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.

Last-wins on a duplicate parent join key. This MATCHES the generic query operator (R2rmlScanOperator's ParentLookup is also a plain HashMap<Vec<String>, RdfTerm>), so the twin agrees with the virtual path and the parity gate is right to pass — no divergence introduced here.

The reason it is still worth a note: the fused aggregate deliberately DECLINES this shape (insert_dim_gkeys returns false on any duplicate, with a comment explaining that a single-entry-per-key map cannot represent the fan-out), and materializing bakes the last-wins result into a permanent native ledger where fixing the operator no longer fixes the data. If the intent is that the twin is the durable artifact, a loud decline here — matching the fused path's posture — may be worth more than silent agreement with the deviation.

/// The env var read by fluree-db-iceberg's fail-closed merge-on-read guard. A
/// stable public contract (`fluree_db_iceberg::mor_guard::ALLOW_MOR_DELETES_ENV`);
/// hard-coded here so the CLI need not take a direct dependency on that crate.
const ALLOW_MOR_DELETES_ENV: &str = "FLUREE_ICEBERG_ALLOW_MOR_DELETES";

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.

The comment calls this a stable public contract, but a hard-copied literal with no compile-time link is the drift hazard the repo handles elsewhere with // SYNC: comments at both ends (the OTEL init pair in telemetry.rs / main.rs, and the Cypher "5.4.0" version string). A // SYNC: here and on mor_guard::ALLOW_MOR_DELETES_ENV would at least make a rename visible to whoever does it.

Note also that this file is #![cfg(feature = "iceberg")], and under that feature fluree-db-iceberg is already in the tree via fluree-db-api — so importing the constant may be cheaper than the comment suggests.

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.

2 participants