Skip to content

perf(iceberg): cold/floor program — parallel catalog resolution, persisted catalog cache, 429 hardening (PR-8)#1491

Open
aaj3f wants to merge 4 commits into
perf/r2rml-pr6-rollup-fusedaggfrom
perf/r2rml-pr8-cold-floor
Open

perf(iceberg): cold/floor program — parallel catalog resolution, persisted catalog cache, 429 hardening (PR-8)#1491
aaj3f wants to merge 4 commits into
perf/r2rml-pr6-rollup-fusedaggfrom
perf/r2rml-pr8-cold-floor

Conversation

@aaj3f

@aaj3f aaj3f commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Roadmap PR-8 (docs/audit/2026-07-virtual-dataset-perf/ROADMAP.md; design + measurement in 11-pr8-cold-floor.md): the cold/first-touch floor. The floor was decomposed with dedicated sub-spans before any lever was built: per table cold = loadTable GET ~1.5–2.0s (irreducible — it carries the vended credentials, and per the security ruling no tokens/credentials are ever persisted to disk) + metadata/manifest reads 0.3–1.1s (persistable) + OAuth ~0.35s (once per process). Multi-table queries paid the GETs serially.

Three slices (three commits)

Slice 1 — parallelize catalog resolution + session storage-cache (7fef307b8, switch FLUREE_R2RML_PARALLEL_CATALOG): best-effort prefetch_tables warms the per-query session pin concurrently (buffered(8), deduped against already-pinned tables, engagement-provable via the r2rml.prefetch span); and the session now caches the constructed Arc<S3IcebergStorage> keyed like the loadTable pin (creds-refresh invalidated, unit-tested) — fixing a pre-existing defect where every scan (and every correlated-join re-scan) rebuilt the AWS SDK client. First implementation failed its own A/B (the double SDK build cancelled the gain) — the fold is what made it net-positive by construction. Warm-disk A/B: q008 ON 3.20s vs OFF 6.05s median (−47%), non-overlapping ranges, hashes == oracle.

Slice 2 — DiskCatalogCache (9dd2655db, switch FLUREE_ICEBERG_CATALOG_DISK_CACHE): persists three content-addressed layers keyed by the immutable metadata_locationTableMetadata, unfiltered scan_files, and the COUNT-path manifest stats. No TTL, no pointer, zero secrets: a table change ⇒ new location ⇒ clean miss. Versioned envelope with deserialize-failure-as-miss, temp-file+atomic-rename writes, 512 MiB oldest-first prune at startup; dedicated sibling dir so the cold protocol clears data and catalog caches independently. Gate (cold data, warm catalog): q036 4.2s → 1.6s with read_metadata and count_manifest_read collapsed to n=0; q001 read_metadata+scan_plan → n=0; parity identical. (Manifest slice varies 450–990 ms with Snowflake load — recorded as a range.)

Slice 3 — 429 hardening (c4a9b799e, FLUREE_ICEBERG_CATALOG_MAX_RETRIES/FLUREE_ICEBERG_CATALOG_CONCURRENCY): request_with_retry now retries 429/503 honoring Retry-After (else exponential backoff + full jitter, base 250ms cap 8s, default 4 retries) and every catalog round-trip passes a process-wide semaphore (default 8) — transparently bounding the slice-1 prefetch fan-out. 401-refresh preserved (permit dropped before recursing — no single-permit self-deadlock). Behavioral tests via wiremock: 429-then-200 retry counting, give-up-after-N, Retry-After honored, semaphore wave-serialization. Live smoke: warm-disk q008 median 3.08s — zero added latency.

Cold floor, before → after (full three-slice binary)

state q036 (COUNT) q001 (dim star) q008 (2-hop rollup)
cold, pre-PR-8 3.0s 2.9s ~48s (fetch-dominated)
cold, post (catalog-persist off) 2.3s 2.9s→2.6s-class 42s (fetch-dominated; catalog slice parallelized)
cold data, warm catalog 1.6s ~2.0s
warm-disk first touch 3.1s (was ~6.1s)

The remaining cold residual is the credentials-bearing loadTable GET (+ the data fetch on full scans — PR-7 pruning territory, scoped out). BSBM deferred to an idle-machine run before merge-to-main.

Battery (all eight phases green)

iceberg (aws) / api (iceberg) / query suites exit 0 · W3C suite green · native corpus 54/54, 0 hash mismatches (2 perf violations, environmental per protocol) · cold subset 0-mismatch · kill-switch fidelity: parity clean with each new switch off/tuned. Measurement instrumentation (catalog sub-spans) ships in slice 1 as permanent counters.

Stacks on #1490 (PR-6) ← #1487#1485#1484#1482#1478#1476#1475#1450.

aaj3f added 4 commits July 15, 2026 12:15
…PR-8 slice 1)

A multi-table virtual-dataset query resolved each table's Iceberg `loadTable`
SERIALLY — the fused aggregate-over-join scanned fact + each dim one at a time,
and the generic multi-TriplesMap loop did the same — so a k-table query paid k
sequential ~1.7s REST round-trips before any data moved. This adds a best-effort
`prefetch_tables` that warms a query's per-table catalog contexts CONCURRENTLY
(`buffered(8)`, request-order-preserving per 0ade90c) before the serial scan
loops, so the per-table GETs overlap instead of summing. It is purely additive:
errors are swallowed and the real scan re-resolves and surfaces them, it dedupes
against tables already resolved in the session pin, and it is gated by
`FLUREE_R2RML_PARALLEL_CATALOG` (off => today's serial behavior).

Folded in — the missing half of the session-pin design: the per-query
`IcebergCatalogSession` pinned the `loadTable` RESPONSE but not the
`S3IcebergStorage` client built FROM it, so EVERY scan rebuilt the AWS SDK client
(`aws_config` load + S3 + HTTP client). That is a pre-existing defect on its own
(a correlated join re-scanning a dim paid it per re-scan), and it made naive
prefetch a net loss (warm + scan = a double build that canceled the parallelism
gain). The session now caches `Arc<S3IcebergStorage>` keyed like the pin and
reuses it; `store_load_table` invalidates it on any fresh loadTable so a
credential refresh rebuilds the client and a stale-creds client is never served
(unit-tested: store_load_table_invalidates_cached_storage_on_creds_refresh).

Also lands the PR-8 cold-floor decomposition sub-spans (`r2rml.read_metadata`,
`r2rml.count_manifest_read`) and the slice-1 engagement span (`r2rml.prefetch`,
with `requested`/`warmed`), all allowlisted + callsite-verified in
fluree-bench-virtual, plus the PR-8 design doc.

Validated in the WARM-DISK state (fresh process = catalog caches cold, on-disk
artifact cache warm = fast scan), where the catalog slice is the wall rather than
being drowned in ~40s of cold S3 fetch. q008 (fact + 2 dims), median of 3 reps:

  prefetch ON   3200 ms   (reps 4475 / 3190 / 3200)
  prefetch OFF  6051 ms   (reps 6051 / 8779 / 5685)   -> 2.85s / 47%, non-overlapping

Engagement proven: `r2rml.prefetch` n=1 in every ON rep, absent in every OFF rep.
`r2rml.scan_table` span-sum collapses ON 0.65s vs OFF 5.7s (scans hit the warm
pins + reused client), while OAuth stays a single exchange even fully concurrent
(buffered's cooperative polling builds the client once). Parity: all six ON/OFF
result hashes identical to the blessed native oracle — warming caches and reusing
a client cannot change rows.

Cold walls stay data-fetch-dominated for every corpus fact query, so the cold A/B
cannot resolve the catalog slice (±4s S3 noise > the ~3s effect) — the warm-disk
A/B is slice-1's DoD gate; see 11-pr8-cold-floor.md sect 7. Cross-pattern
non-fused joins resolve tables in separate operators the join pulls serially and
are out of an in-operator prefetch's reach; deferred as a documented follow-up.
…he (PR-8 slice 2)

Every cold process re-read a table's parsed `TableMetadata`, its manifest-derived
scan file list, and (for a `COUNT(*)`) its manifest record-count stats from S3 —
even though all three are IMMUTABLE for a given Iceberg snapshot. This persists
them to a small on-disk cache so a cold process with a warm catalog dir serves
them from local disk instead. It does NOT persist any credential or token (ruling
ii): a cold process still issues one `loadTable` GET for fresh vended creds — the
irreducible floor — this only removes the metadata + manifest S3 round-trips that
follow it.

No TTL, no pointer, no invalidation logic. The key is the `metadata_location`, a
content-addressed S3 path: a table commit yields a NEW location = a NEW key = a
clean miss, and a given key's value can never go stale. That is the whole design —
three content-addressed stores keyed by `metadata_location`:
  - `TableMetadata`                 -> removes the `r2rml.read_metadata` S3 GET
  - `scan_files` (unfiltered list)  -> removes the scan path's `iceberg.scan_plan`
  - COUNT(*) manifest stats         -> removes `r2rml.count_manifest_read`
The scan_files entry is the UNFILTERED full file list; the in-memory cache is
already bypassed when a pushdown filter prunes, so it is immutable per snapshot.

New module `disk_catalog_cache.rs`, wired at the three read sites in `r2rml.rs`
(in-memory miss -> disk -> S3), in a DEDICATED dir sibling to the Parquet/binary
artifact cache (`<dir>-catalog`) so the cold benchmark protocol clears data while
keeping catalog persistence. Switch `FLUREE_ICEBERG_CATALOG_DISK_CACHE` (default
on). Durability: a versioned `{format_version, payload}` envelope (mismatch or any
deserialize failure = miss + drop the file, never a surfaced error), atomic
temp-file + rename writes (a mid-write crash can't leave a torn file a read
trusts), and an mtime-LRU prune to 512 MiB once per process. `DataFile` (+
`FileFormat`, `PartitionData`) gained `Serialize/Deserialize` in fluree-db-iceberg
to persist the file lists.

Validated with the COLD-DATA / WARM-CATALOG gate (two `--cold` runs sharing one
`--cache-dir`; run 2 clears the data artifacts but reads the warm catalog
sibling):

  q036 (COUNT)  run1 wall 4274ms -> run2 2060ms
    r2rml.read_metadata        n=1 (298ms) -> n=0
    r2rml.count_manifest_read  n=1 (986ms) -> n=0
    r2rml.load_table           n=1         -> n=1   (the irreducible GET stays)
  q001 (scan)   run1 wall 3964ms -> run2 3723ms
    r2rml.read_metadata        n=1 (154ms) -> n=0
    iceberg.scan_plan          n=1 (113ms) -> n=0

Parity: run1 == run2 == blessed native oracle on both (compare: 0 mismatches).
Persisting immutable, secret-free data can't change rows. Hermetic tests cover the
serde round-trip, version-mismatch-as-miss, corrupt-file-as-miss, and the disabled
switch; 99/99 graph_source lib tests regression-clean. See 11-pr8-cold-floor.md
sect 8.
… (PR-8 slice 3)

The REST catalog client had no rate-limit handling at all: `request_with_retry`
retried a 401 (token refresh) once and treated every other non-2xx as terminal,
and nothing bounded how many catalog requests could be in flight at once. PR-2's
raised scan concurrency, the slice-1 prefetch fan-out, and the wildcard crawl can
each issue a burst of `loadTable`/list calls, so a `429 Too Many Requests` from
Snowflake Horizon would surface as a hard query error. This adds the two missing
pieces — safety hardening, not a latency lever.

429/503 backoff: `request_with_retry` now loops. A `429` or `503` is retried
honoring a `Retry-After` header when present, else exponential backoff with full
jitter (base 250ms, doubling, capped at 8s), bounded to
`FLUREE_ICEBERG_CATALOG_MAX_RETRIES` (default 4) attempts, then the error is
surfaced cleanly (a `Catalog` error naming the status). The existing
401-refresh-and-retry is preserved and drops its semaphore permit before the
recursive call so a small cap can't self-deadlock.

Catalog semaphore: every catalog request now acquires a permit from a
process-wide `Semaphore` (sized by `FLUREE_ICEBERG_CATALOG_CONCURRENCY`, default
8) before its round-trip, held across the backoff so a throttled request keeps
its slot while it waits. The permit is a shared `Arc` cloned into every client,
so all clients bound one pool; it is injectable per-client for tests. Because the
acquire lives in `request_with_retry`, the slice-1 prefetch's `buffered(8)`
fan-out and any crawl fan-out are transparently bounded by it — the slice-1 TODO
is retired. This is independent of the S3 data-scan concurrency (which is not
catalog traffic).

Behavioral gate (wiremock, all deterministic and fast):
  - retries_on_429_then_succeeds: 2×429 (Retry-After: 0) then 200 → Ok, 3 requests.
  - gives_up_after_max_retries_and_surfaces_the_error: persistent 429 → a Catalog
    error naming the status after 1 + DEFAULT_CATALOG_MAX_RETRIES requests.
  - retry_after_parses_delta_seconds… : header parse + backoff bounded by the cap.
  - semaphore_bounds_concurrent_catalog_requests: 6 delayed requests through a
    2-permit client serialize into ≥3 waves; a 6-permit client is faster.
The tests use an injected per-client semaphore (the global OnceLock would make a
bounded-pool test order-dependent) and a plain reqwest client (the production one
hardens against loopback for SSRF, which a loopback mock can't satisfy).

All switches default on; off ⇒ today's single-shot, unbounded behavior. See
11-pr8-cold-floor.md sect 9.
…sh-collision guard)

The disk catalog cache names entries by a 64-bit DefaultHasher of the key
(metadata_location / lt_key), so two distinct keys can collide onto one
filename and silently serve another table's payload — a strictly worse failure
than the content-addressed entries' wrong-snapshot, and #1503's pointer entry
keys by table. Store the FULL key in the Envelope and verify equality on read:
a mismatch is a clean miss, so hash quality is irrelevant to correctness. Bump
CACHE_FORMAT_VERSION 1->2 (old keyless entries miss cleanly via the existing
version check). Forced-collision hermetic test. Covers the metadata/scanfiles/
countstats entries; the #1503 pointer rides the same Envelope + version.
@aaj3f
aaj3f force-pushed the perf/r2rml-pr8-cold-floor branch from c4a9b79 to 2bcc258 Compare July 15, 2026 16:32
aaj3f added a commit that referenced this pull request Jul 15, 2026
…ilder symptom doc, F19 memo-key rationale, fmt

- lazy_storage.rs: |res| res.is_ok() -> Result::is_ok (workspace-denied
  redundant_closure_for_method_calls in test code).
- r2rml.rs: name send_parquet.rs's tokio::spawn as the site a future '&a narrowing
  refactor would fail at, so the '\''static owned-Arc capture reads as load-bearing.
- operator.rs: mirror the store-disambiguation rationale at R2rmlParentMemoKey so
  the F19 with_graph_ref memo-share (why graph_source_id can't be dropped) is
  documented where an editor would remove the component.
- cargo fmt (own-delta hunks).

C2 pointer hardening (full lt_key stored in the Envelope + verified on read) is
delivered by get/put_metadata_location adopting slice-2's (key, suffix) read/write
signatures during the restack onto the #1491 CACHE_FORMAT_VERSION=2 chain.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant