hudi-agent commented on code in PR #19046: URL: https://github.com/apache/hudi/pull/19046#discussion_r3724329999
########## rfc/rfc-107/rfc-107.md: ########## @@ -0,0 +1,320 @@ + <!-- + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +# RFC-107: Support data partition aware RocksDB RecordIndexBackend + +## Proposers + +- @zhenqiu-huang + +## Approvers + - TBD + +## Status + - In Progress + +## Abstract + +[RFC-106](../rfc-106/rfc-106.md) introduces Record Level Index (RLI) support for Flink streaming upsert writes, including a simple in-memory cache for index lookups in the `BucketAssigner` operator. While the in-memory cache works well for small to moderate workloads, it faces scalability challenges for large tables with billions of records: the cache either consumes excessive JVM heap memory or suffers from high eviction rates that degrade lookup performance. +In modern CloudLake systems that rely on object storage platforms such as GCS, OCI Object Storage, and Amazon S3, data is typically transitioned to lower storage tiers over time to optimize storage costs. However, using an in-memory cache to accelerate index lookups may result in increased data processing overhead. + +Before this proposal, RocksDBIndexBackend, GlobalRecordLevelIndexBackend and PartitionedIndexBackend have been supported for different Flink upsert scenarios. +- **RocksDBIndexBackend** implements GlobalIndexBackend using an embedded RocksDB instance (RocksDBDAO) as local persistent storage, global data needs to loaded into RocksDB before job starting to processing DB events +- **GlobalRecordLevelIndexBackend** implements MinibatchIndexBackend, backing global record-level-index (RLI) lookups by Hudi's metadata table (HoodieBackedTableMetadata). Keyed only by record key (global, non-partitioned lookups). +- **RecordLevelIndexBackend** maintains one BucketCache per data partition in a LinkedHashMap, with Lazy bootstrap and optimized memory management. + +This RFC proposes a **Support data partition aware RecordLevelIndexBackend** backed by RocksDB that serves as a local materialized replica of the MDT RLI. The provides: + +- **O(1) local lookups** for record location resolution during streaming writes, eliminating per-record MDT I/O +- **Partition-aware storage** using RocksDB column families, enabling efficient TTL-based eviction of stale partitions +- **Bounded resource consumption** by caching only the partitions actively written to, keeping and memory and storage proportional to the working set rather than total table size +- **Incremental maintenance** through in-line index updates during the write path, with MDT as the authoritative source of truth for bootstrap and cross-engine compatibility + +## Background + +### The Index Lookup Bottleneck + +In Hudi's Flink upsert pipeline, the `BucketAssigner` operator must determine whether each incoming record is an insert or an update by looking up its record key in the index. RFC-106 introduces an in-memory cache to accelerate these lookups, but for large-scale streaming workloads, this approach has fundamental limitations: + +1. **Unbounded Cost**: Each RLI entry requires approximately 50–70 bytes of memory. For a table containing 1 billion records, caching the entire index would consume 50–70 GB of JVM heap. In addition, a record buffer is required to improve RLI lookup efficiency. For CDC workloads with high event throughput (QPS) and large record sizes, maintaining a two-minute buffer can further increase memory consumption significantly. As a result, the compute cost of upsert ingestion workloads can rise substantially. +2. **Cache thrashing**: With bounded memory, the cache must evict entries aggressively. For workloads that access records across many partitions, this leads to frequent cache misses and fallback to MDT queries (10+ ms per record), severely degrading throughput. +3. **Cold start latency**: On job restart or task failover, the in-memory cache starts empty. Warming the cache through individual MDT lookups creates a prolonged period of degraded performance. + +**Relationship to MDT's native file-level cache**: MDT's HFile reader already maintains its own local block cache per index shard (see RFC-106), so it is fair to ask why another local cache is needed on top of it. That cache is effective for *batch* lookups — a `BucketAssigner` task issuing one MDT read per micro-batch of keys amortizes the read path (deserialization, block decompression, seek) across many keys. The streaming upsert path, however, needs a location decision per *individual* incoming record as it flows through the operator; even with a warm block cache, each such lookup still pays the cost of the MDT reader stack (HFile seek, Avro/record deserialization, shard routing) once per record. The Dynamic Partitioned Cache avoids that per-record path entirely by keeping a decoded, ready-to-read `record key → location` mapping in-process, so point lookups are a direct RocksDB `Get()` rather than an MDT read. The two caches are complementary and operate at different layers: MDT's file-level cache speeds up the reads that populate and refresh this cache; this cache is what removes the need to go through MDT for the common-case per-record lookup at all. + +### Why RocksDB + +RocksDB record index is already supported. It is a proven embedded key-value store that addresses these limitations: + +- **Off-heap storage**: RocksDB stores data in SST files on local disk with a configurable block cache in off-heap memory, avoiding GC pressure on the JVM heap. +- **Column families**: RocksDB supports column families, which provide logical separation of data within a single database instance. Each partition's index entries can be stored in a dedicated column family, enabling efficient bulk operations (e.g., dropping an entire partition's cache) without affecting other partitions. +- **Compression**: RocksDB applies block-level compression (Snappy by default), keeping the on-disk footprint manageable. +- **Mature Flink integration**: Flink already uses RocksDB as its primary state backend, so operational expertise and deployment patterns are well-established. + +### Alternatives Considered + +**Flink-managed keyed state (MapState with TTL)**: Since Flink already runs RocksDB as its state backend, an alternative is to store the partitioned index directly in Flink `MapState`, scoped per key group, with state TTL for eviction. This gets incremental checkpointing and automatic recovery "for free" and avoids operating a second, unmanaged RocksDB instance alongside Flink's own state backend. + +This RFC uses a standalone RocksDB instance instead, for three reasons: + +- **Partition-level bulk operations**: Flink's state TTL evicts at the *key* granularity (lazily on access, or via a background scan of all keys). It has no notion of dropping an entire partition's entries in O(1); the column-family-per-partition design in this RFC depends on that operation for both TTL eviction and on-demand loading of older partitions. +- **Cross-operator sharing**: the design shares one RocksDB instance between `RLIBootstrapOperator` and `BucketAssigner` (see [Incremental Cache Maintenance](#incremental-cache-maintenance)). Flink's keyed state is private to the operator/task that owns it and cannot be read directly by a sibling operator without routing every lookup through the network, which reintroduces the per-record I/O this RFC is trying to eliminate. +- **Independent lifecycle from checkpointing**: because the cache is disposable and always rebuildable from MDT, it does not need to participate in Flink's checkpoint/restore protocol at all — bootstrapping directly from MDT on failover is simpler and faster than replaying a large keyed-state snapshot, and avoids inflating checkpoint size with data that already lives durably in MDT. + +The tradeoff is that this RFC's cache must implement its own lifecycle management (open/close/bootstrap/discard) rather than reusing Flink's, and does not benefit from incremental checkpointing of the cache contents. Given the cache is fully derivable from MDT (see [Consistency and Failure Handling](#consistency-and-failure-handling)), this is considered an acceptable tradeoff. + +## High Level Design + +The Dynamic Partitioned Cache introduces a RocksDB-based local index replica as a scalable substitute for the *committed* portion of RFC-106's in-memory cache. RFC-106's uncommitted-records buffer — which tracks records written within the current open checkpoint but not yet committed to MDT — is retained unchanged and remains the first tier consulted on every lookup. The Dynamic Partitioned Cache sits between that buffer and MDT, giving a three-tier lookup order: + +1. **Uncommitted-records buffer (in-memory, RFC-106)** — records inserted/updated within the current, not-yet-committed checkpoint. This tier is authoritative for in-flight state and is always consulted first, which is what prevents an update to a key inserted earlier in the same checkpoint from being misclassified as a new insert. +2. **RocksDB cache** — materialized replica of *committed* index state, i.e. record keys that have already landed in an MDT commit. +3. **MDT RLI** — authoritative remote index, consulted only on a miss in both of the above (e.g. cold partition, or first bootstrap). + +Because the uncommitted buffer is checkpoint-scoped and small (bounded by the records written since the last commit), it keeps the memory-bound properties RFC-106 already relies on; the Dynamic Partitioned Cache only takes over the much larger *committed* working set that previously forced the RFC-106 cache to grow unbounded or thrash. + +The RocksDB cache is partitioned by Hudi data partition path using column families. This partition-aware design enables: + +- Bootstrapping only the partitions that the writer actively touches +- Evicting cold partitions via TTL without scanning individual keys +- Bounding cache size to `O(active_partitions)` rather than `O(total_table_size)` + +A fundamental design is needed to handle cache misses by reading from the MDT RLI and storing the loaded index in RocksDB for future lookups. It will be discussed in following sections. + +### Detailed Design + +### Cache Structure and Partitioning + +The RocksDB instance is organized using **column families**, one per Hudi data partition path. Each column family stores key-value pairs where: + +- **Key**: Record key (byte-serialized) +- **Value**: Record location (file group ID + file slice info) and ordering value + +Using column families provides two critical advantages over a single flat keyspace: + +1. **Efficient partition eviction**: Dropping a column family is an O(1) metadata operation in RocksDB, compared to O(N) individual deletes. +2. **Partition-level TTL**: Each column family can have its own TTL configuration, enabling automatic eviction of partitions that haven't been written to recently. + +``` +RocksDB Instance +├── CF: "default" (metadata: partition registry, timestamps) +├── CF: "dt=2025-01-15" (RLI entries for partition dt=2025-01-15) +├── CF: "dt=2025-01-16" (RLI entries for partition dt=2025-01-16) +└── CF: "dt=2025-01-17" (RLI entries for partition dt=2025-01-17) +``` + +**Partition completeness invariant**: a partition's column family is only visible to lookups once it has been *fully* loaded — there is no partially-cached partition state. Bootstrap and on-demand loading (see [Bootstrap Strategy](#bootstrap-strategy)) build the column family off to the side (or via bulk-load into a not-yet-registered CF) and only register it in the `default` CF's partition registry after the load completes. A cache miss (column family absent from the registry) is therefore unambiguous: it always means "load this partition's entries from MDT," never "some entries for this partition may already be cached, check anyway." This is what lets bootstrap and eviction operate at whole-partition granularity without needing to reconcile partial per-key state. + +### Bootstrap Strategy + +On job start or task failover, the RocksDB cache must be populated from the MDT RLI. The bootstrap strategy differs based on the index scope: + +#### Global RLI Bootstrap + +For global RLI (cross-partition upsert), the cache must contain all record keys across the entire table: + +1. Close and discard any existing RocksDB state (container-local storage is ephemeral). +2. Scan the full MDT RLI partition assigned to this task (based on `hash(record_key) % num_index_shards` assignment from RFC-106). +3. Bulk-load entries into RocksDB using `SSTFileWriter` for optimal ingestion performance. +4. Open the RocksDB instance for read-write access. + +**Bootstrap latency**: For a table with 1 billion records and ~50–70 bytes per entry, scanning and loading the full index requires approximately 50–70 GB of data transfer. At 500 MB/s network throughput, this takes ~2 minutes. This cost is incurred on every job restart. + +#### Partitioned RLI Bootstrap (Recommended) + +This strategy leverages Hudi's [Partitioned Record Index](https://hudi.apache.org/docs/indexes/) (introduced in 1.1.0), which organizes the MDT record-level index by partition path. Unlike the Global Record Index, the Partitioned Record Index guarantees uniqueness within each `(partition_path, record_key)` pair and supports partition-scoped lookups, making it possible to load only a subset of the index during bootstrap. + +**Time-bounded bootstrap**: On job start or task failover, the cache loads only the most recent **X days** of partitioned record index entries (configurable via `hoodie.index.cache.rocksdb.bootstrap.days`). This bounds bootstrap time and resource consumption to a predictable window: + +1. Close and discard any existing RocksDB state. +2. Determine the bootstrap window: compute the set of partitions whose partition path falls within the last X days (e.g., `dt=2025-01-15` through `dt=2025-01-21` for a 7-day window). +3. For each partition in the bootstrap window, scan only its corresponding MDT Partitioned Record Index entries and bulk-load them into a dedicated RocksDB column family. +4. Record the bootstrap boundary timestamp (the oldest partition loaded) in the `default` column family metadata. + +**Non-temporal partition schemes**: the "last X days" window assumes partition paths are date-based (e.g. `dt=2025-01-15`) and can be ordered chronologically. For tables partitioned by a non-temporal key (e.g. `region=us-west`, `category=electronics`) or by a composite key without a leading date component, there is no meaningful notion of "the most recent X days of partitions." For these tables, time-bounded bootstrap falls back to **access-recency bootstrap**: the cache starts empty (equivalent to `bootstrap.days = 0`), and every partition is loaded on demand on first access via the on-demand loading path described below. TTL-based eviction still applies per column family based on last-access time regardless of partition naming scheme. This RFC recommends the time-bounded bootstrap optimization only for date-partitioned tables initially; supporting user-supplied partition-recency hints for non-temporal schemes is left as future work. + +**On-demand loading for older partitions**: During ingestion, when the `BucketAssigner` encounters an incoming record whose partition path is **older than the bootstrap window** (i.e., not yet cached in RocksDB): + +1. Detect the cache miss: the target partition's column family does not exist in RocksDB. +2. Trigger an **on-demand partition load**: read the Partitioned Record Index entries for that specific partition from MDT and materialize them into a new RocksDB column family. +3. Once loaded, perform the record lookup against the newly populated column family and continue normal processing. +4. The on-demand loaded partition is subject to the same TTL-based eviction as bootstrapped partitions (see [TTL-Based Partition Eviction](#ttl-based-partition-eviction)). + +**Handling bursty cold-partition access (backfills/corrections)**: a workload that touches many cold partitions in a short window (e.g. a backfill correcting records across 30 old partitions) would otherwise trigger 30 sequential synchronous loads on the `BucketAssigner`/`RLIBootstrapOperator` thread, each taking on the order of seconds, causing pipeline backpressure. To bound this in a future iteration: + +- On-demand loads for distinct partitions are dispatched to a small bounded thread pool rather than the task's main processing thread, so independent partitions can load in parallel instead of serially. +- Records whose partition is currently being loaded are buffered (bounded by a configurable in-flight record limit) rather than blocking the operator thread; once the column family finishes loading, buffered records are replayed against it. +- If the in-flight buffer limit is exceeded, the operator applies backpressure to upstream by not requesting more input, rather than growing the buffer unboundedly. + +This is scoped as follow-up work beyond the initial synchronous implementation (see [Implementation Plan](#implementation-plan)); the first phase accepts synchronous on-demand loading and measures actual stall impact before investing in the async path. + +This two-tier approach — time-bounded bootstrap plus on-demand loading — ensures that: + +- **Bootstrap is fast and predictable**: loading X days of index data is bounded and proportional to recent write volume, not total table size. For a daily-partitioned table with 10M records/day at ~60 bytes/entry, a 7-day bootstrap loads ~4.2 GB — completing in seconds rather than minutes. +- **Late-arriving data is handled correctly**: updates to partitions older than X days (e.g., backfills, corrections, late-arriving events) trigger on-demand loading of only the affected partition, avoiding a full re-bootstrap. +- **Cache growth remains bounded**: combined with TTL eviction, the cache holds at most `bootstrap_days + on-demand loaded` partitions, with cold partitions automatically evicted. + +``` +Bootstrap Timeline (X = 7 days) + +◄──── Older partitions ────┤◄──── Bootstrap window (7 days) ────►│ Today + │ │ + dt=2025-01-10 dt=2025-01-13 dt=2025-01-15 ... dt=2025-01-21 dt=2025-01-22 + │ │ │ │ + Not loaded Not loaded Bootstrapped at Bootstrapped + (load on (load on job start at job start + demand if demand if + needed) needed) + +When a record arrives for dt=2025-01-10: + 1. Column family "dt=2025-01-10" not found in RocksDB + 2. On-demand load: read Partitioned Record Index for dt=2025-01-10 from MDT + 3. Create column family, bulk-load entries + 4. Perform lookup and continue +``` + +### Incremental Cache Maintenance + +To achieve the on demand RLI load for an older partition, RLIBootstrapOperator needs to be revised to access the shared RocksDB instance with BucketAssign operator. After bootstrap of RLIBootstrapOperator, the RocksDB cache is maintained incrementally during normal write operations: + +**Operator co-location**: `RLIBootstrapOperator` and `BucketAssigner` must run in the same task slot for shared, in-process RocksDB access to be possible at all — routing lookups across the network would defeat the purpose of the cache. This is guaranteed by chaining the two operators (same parallelism, no keyBy/shuffle between them, chained via Flink's operator chaining), so they execute in the same TaskManager JVM and can share a direct reference to the RocksDB instance. + +**Concurrency model**: within a chained pair, Flink's runtime already guarantees that only one operator's `processElement` executes at a time per subtask (chained operators share a single task thread), so `RLIBootstrapOperator` and `BucketAssigner` never call into RocksDB concurrently for the same record. The only cross-thread access is the background TTL eviction thread (see [TTL-Based Partition Eviction](#ttl-based-partition-eviction)) dropping a column family while the task thread might be reading from it; this is serialized with a per-column-family read-write lock so an in-flight lookup completes (or cleanly misses) before the column family is dropped. + +#### On Record Processing + +``` +for each incoming record r: + 1. In RLIBootstrapOperator, look up through shared RocksDB for r.partitionPath + → If column family does not exist: + a. Load Partitioned Record Index for r.partitionPath from MDT (on-demand) + b. Create column family and bulk-load entries + → If found: forward to next operator BucketAssign + 2. In BucketAssign operator, check RocksDB cache for r.key in column family r.partitionPath + → If found in RocksDB: use cached location (committed record) + → If not found: this is an INSERT, assign new file group + 3. Update RocksDB cache with r.key → assigned location +``` + +In this process above, `RLIBootstrapOperator` is responsible for checking whether a record is from an older partition that has not been bootstrapped. If so, it triggers the on-demand load from MDT and forwards the `HoodieFlinkInternalRow` to the `BucketAssigner` operator. + +#### On Index Write + +In the `IndexWrite` operator (from RFC-106), index records are written to MDT. The RocksDB cache in the `BucketAssigner` is updated in-line as records flow through the pipeline, ensuring the cache stays ahead of MDT commits. + +### TTL-Based Partition Eviction + +For partitioned RLI, the cache implements automatic eviction of cold partitions: + +- Each column family tracks the **last access timestamp** (last time a record was written to or looked up in that partition). +- A background thread periodically scans column family metadata and drops column families whose last access exceeds the configured TTL. +- The TTL should be set based on the workload's partition access pattern. For daily-partitioned event data, a TTL of 3–7 days is typical. + +Configuration: + +| Property | Default | Description | +|---|---|---| +| `hoodie.index.cache.rocksdb.enabled` | `false` | Enable RocksDB-based partitioned cache | +| `hoodie.index.cache.rocksdb.base.path` | `/tmp/hudi-index-cache` | Local directory for RocksDB data | +| `hoodie.index.cache.rocksdb.bootstrap.days` | `7` | Number of days of Partitioned Record Index to load during bootstrap. Only partitions within this window are pre-loaded; older partitions are loaded on demand when updates are observed. | +| `hoodie.index.cache.rocksdb.partition.ttl.hours` | `168` (7 days) | TTL for partition column families | +| `hoodie.index.cache.rocksdb.block.cache.mb` | `256` | RocksDB block cache size (off-heap) | +| `hoodie.index.cache.rocksdb.compaction.style` | `LEVEL` | RocksDB compaction style | + +### Storage Overhead + +RocksDB occupies approximately **2x the storage** compared to native HFile format in MDT, due to: + +1. **Compression codec difference**: RocksDB uses Snappy compression by default, while Hudi MDT uses gzip, which achieves higher compression ratios. +2. **Uncompacted SST files**: During active writes, RocksDB maintains multiple levels of SST files before compaction merges them. +3. **WAL disabled**: Write-ahead log is disabled since the cache can be rebuilt from MDT on failure, reducing write amplification. + +For a partition with 10 million records at ~60 bytes per entry, the RocksDB footprint is approximately 1.2 GB on disk. + +### Consistency and Failure Handling + +The RocksDB cache is a **derived, disposable replica** — MDT remains the single source of truth. This simplifies consistency handling: + +#### Task Failover + +1. The RocksDB cache on the failed task's container is discarded (ephemeral local storage). +2. The recovered task bootstraps a fresh RocksDB instance from MDT. +4. The coordinator recommits any pending Hudi instants (as described in RFC-106). + +#### Job Restart + +1. All RocksDB state is discarded. +2. Full bootstrap from MDT is triggered. +3. The coordinator handles recommitting of pending instants per RFC-106's recovery protocol. + +#### Interaction with Table Services (Compaction) + +The primary deployment target for this RFC is a **single Flink ingestion writer** per table, with table services (compaction) running as separate, periodic jobs — the same assumption RFC-106 makes for its in-memory cache. + +### Integration with RFC-106 Pipeline + +RFC-106 introduces the following operator chain for Flink streaming upserts: `BucketAssigner → StreamWriteFunction → ... → IndexWrite → Coordinator`. This RFC modifies that pipeline as follows: + +1. **New operator**: `RLIBootstrapOperator` is inserted immediately before `BucketAssigner`, chained to it (same parallelism, no shuffle in between — see [Operator co-location](#incremental-cache-maintenance)). It owns the RocksDB instance's lifecycle (open at job start, bootstrap per [Bootstrap Strategy](#bootstrap-strategy), close on job shutdown) and ensures the column family for an incoming record's partition exists before the record reaches `BucketAssigner`, triggering an on-demand load when it does not. Review Comment: 🤖 RLIBootstrapOperator owns open/close of a RocksDB instance that BucketAssigner also references and a background TTL thread mutates. On shutdown or failover, if the DB is closed while the eviction thread or an in-flight BucketAssigner lookup still touches it, that's a native (JNI) use-after-free / hard JVM crash, not a catchable Java exception. Could you spell out the close ordering — stopping and joining the eviction thread and quiescing lookups before the owning operator disposes the DB? @danny0405 does Flink's chained-operator close() ordering guarantee BucketAssigner drains before RLIBootstrapOperator releases the shared handle? <sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag quality.</i></sub> ########## rfc/rfc-107/rfc-107.md: ########## @@ -0,0 +1,320 @@ + <!-- + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +# RFC-107: Support data partition aware RocksDB RecordIndexBackend + +## Proposers + +- @zhenqiu-huang + +## Approvers + - TBD + +## Status + - In Progress + +## Abstract + +[RFC-106](../rfc-106/rfc-106.md) introduces Record Level Index (RLI) support for Flink streaming upsert writes, including a simple in-memory cache for index lookups in the `BucketAssigner` operator. While the in-memory cache works well for small to moderate workloads, it faces scalability challenges for large tables with billions of records: the cache either consumes excessive JVM heap memory or suffers from high eviction rates that degrade lookup performance. +In modern CloudLake systems that rely on object storage platforms such as GCS, OCI Object Storage, and Amazon S3, data is typically transitioned to lower storage tiers over time to optimize storage costs. However, using an in-memory cache to accelerate index lookups may result in increased data processing overhead. + +Before this proposal, RocksDBIndexBackend, GlobalRecordLevelIndexBackend and PartitionedIndexBackend have been supported for different Flink upsert scenarios. +- **RocksDBIndexBackend** implements GlobalIndexBackend using an embedded RocksDB instance (RocksDBDAO) as local persistent storage, global data needs to loaded into RocksDB before job starting to processing DB events +- **GlobalRecordLevelIndexBackend** implements MinibatchIndexBackend, backing global record-level-index (RLI) lookups by Hudi's metadata table (HoodieBackedTableMetadata). Keyed only by record key (global, non-partitioned lookups). +- **RecordLevelIndexBackend** maintains one BucketCache per data partition in a LinkedHashMap, with Lazy bootstrap and optimized memory management. + +This RFC proposes a **Support data partition aware RecordLevelIndexBackend** backed by RocksDB that serves as a local materialized replica of the MDT RLI. The provides: + +- **O(1) local lookups** for record location resolution during streaming writes, eliminating per-record MDT I/O +- **Partition-aware storage** using RocksDB column families, enabling efficient TTL-based eviction of stale partitions +- **Bounded resource consumption** by caching only the partitions actively written to, keeping and memory and storage proportional to the working set rather than total table size +- **Incremental maintenance** through in-line index updates during the write path, with MDT as the authoritative source of truth for bootstrap and cross-engine compatibility + +## Background + +### The Index Lookup Bottleneck + +In Hudi's Flink upsert pipeline, the `BucketAssigner` operator must determine whether each incoming record is an insert or an update by looking up its record key in the index. RFC-106 introduces an in-memory cache to accelerate these lookups, but for large-scale streaming workloads, this approach has fundamental limitations: + +1. **Unbounded Cost**: Each RLI entry requires approximately 50–70 bytes of memory. For a table containing 1 billion records, caching the entire index would consume 50–70 GB of JVM heap. In addition, a record buffer is required to improve RLI lookup efficiency. For CDC workloads with high event throughput (QPS) and large record sizes, maintaining a two-minute buffer can further increase memory consumption significantly. As a result, the compute cost of upsert ingestion workloads can rise substantially. +2. **Cache thrashing**: With bounded memory, the cache must evict entries aggressively. For workloads that access records across many partitions, this leads to frequent cache misses and fallback to MDT queries (10+ ms per record), severely degrading throughput. +3. **Cold start latency**: On job restart or task failover, the in-memory cache starts empty. Warming the cache through individual MDT lookups creates a prolonged period of degraded performance. + +**Relationship to MDT's native file-level cache**: MDT's HFile reader already maintains its own local block cache per index shard (see RFC-106), so it is fair to ask why another local cache is needed on top of it. That cache is effective for *batch* lookups — a `BucketAssigner` task issuing one MDT read per micro-batch of keys amortizes the read path (deserialization, block decompression, seek) across many keys. The streaming upsert path, however, needs a location decision per *individual* incoming record as it flows through the operator; even with a warm block cache, each such lookup still pays the cost of the MDT reader stack (HFile seek, Avro/record deserialization, shard routing) once per record. The Dynamic Partitioned Cache avoids that per-record path entirely by keeping a decoded, ready-to-read `record key → location` mapping in-process, so point lookups are a direct RocksDB `Get()` rather than an MDT read. The two caches are complementary and operate at different layers: MDT's file-level cache speeds up the reads that populate and refresh this cache; this cache is what removes the need to go through MDT for the common-case per-record lookup at all. + +### Why RocksDB + +RocksDB record index is already supported. It is a proven embedded key-value store that addresses these limitations: + +- **Off-heap storage**: RocksDB stores data in SST files on local disk with a configurable block cache in off-heap memory, avoiding GC pressure on the JVM heap. +- **Column families**: RocksDB supports column families, which provide logical separation of data within a single database instance. Each partition's index entries can be stored in a dedicated column family, enabling efficient bulk operations (e.g., dropping an entire partition's cache) without affecting other partitions. +- **Compression**: RocksDB applies block-level compression (Snappy by default), keeping the on-disk footprint manageable. +- **Mature Flink integration**: Flink already uses RocksDB as its primary state backend, so operational expertise and deployment patterns are well-established. + +### Alternatives Considered + +**Flink-managed keyed state (MapState with TTL)**: Since Flink already runs RocksDB as its state backend, an alternative is to store the partitioned index directly in Flink `MapState`, scoped per key group, with state TTL for eviction. This gets incremental checkpointing and automatic recovery "for free" and avoids operating a second, unmanaged RocksDB instance alongside Flink's own state backend. + +This RFC uses a standalone RocksDB instance instead, for three reasons: + +- **Partition-level bulk operations**: Flink's state TTL evicts at the *key* granularity (lazily on access, or via a background scan of all keys). It has no notion of dropping an entire partition's entries in O(1); the column-family-per-partition design in this RFC depends on that operation for both TTL eviction and on-demand loading of older partitions. +- **Cross-operator sharing**: the design shares one RocksDB instance between `RLIBootstrapOperator` and `BucketAssigner` (see [Incremental Cache Maintenance](#incremental-cache-maintenance)). Flink's keyed state is private to the operator/task that owns it and cannot be read directly by a sibling operator without routing every lookup through the network, which reintroduces the per-record I/O this RFC is trying to eliminate. +- **Independent lifecycle from checkpointing**: because the cache is disposable and always rebuildable from MDT, it does not need to participate in Flink's checkpoint/restore protocol at all — bootstrapping directly from MDT on failover is simpler and faster than replaying a large keyed-state snapshot, and avoids inflating checkpoint size with data that already lives durably in MDT. + +The tradeoff is that this RFC's cache must implement its own lifecycle management (open/close/bootstrap/discard) rather than reusing Flink's, and does not benefit from incremental checkpointing of the cache contents. Given the cache is fully derivable from MDT (see [Consistency and Failure Handling](#consistency-and-failure-handling)), this is considered an acceptable tradeoff. + +## High Level Design + +The Dynamic Partitioned Cache introduces a RocksDB-based local index replica as a scalable substitute for the *committed* portion of RFC-106's in-memory cache. RFC-106's uncommitted-records buffer — which tracks records written within the current open checkpoint but not yet committed to MDT — is retained unchanged and remains the first tier consulted on every lookup. The Dynamic Partitioned Cache sits between that buffer and MDT, giving a three-tier lookup order: + +1. **Uncommitted-records buffer (in-memory, RFC-106)** — records inserted/updated within the current, not-yet-committed checkpoint. This tier is authoritative for in-flight state and is always consulted first, which is what prevents an update to a key inserted earlier in the same checkpoint from being misclassified as a new insert. +2. **RocksDB cache** — materialized replica of *committed* index state, i.e. record keys that have already landed in an MDT commit. +3. **MDT RLI** — authoritative remote index, consulted only on a miss in both of the above (e.g. cold partition, or first bootstrap). + +Because the uncommitted buffer is checkpoint-scoped and small (bounded by the records written since the last commit), it keeps the memory-bound properties RFC-106 already relies on; the Dynamic Partitioned Cache only takes over the much larger *committed* working set that previously forced the RFC-106 cache to grow unbounded or thrash. + +The RocksDB cache is partitioned by Hudi data partition path using column families. This partition-aware design enables: + +- Bootstrapping only the partitions that the writer actively touches +- Evicting cold partitions via TTL without scanning individual keys +- Bounding cache size to `O(active_partitions)` rather than `O(total_table_size)` + +A fundamental design is needed to handle cache misses by reading from the MDT RLI and storing the loaded index in RocksDB for future lookups. It will be discussed in following sections. + +### Detailed Design + +### Cache Structure and Partitioning + +The RocksDB instance is organized using **column families**, one per Hudi data partition path. Each column family stores key-value pairs where: + +- **Key**: Record key (byte-serialized) +- **Value**: Record location (file group ID + file slice info) and ordering value + +Using column families provides two critical advantages over a single flat keyspace: + +1. **Efficient partition eviction**: Dropping a column family is an O(1) metadata operation in RocksDB, compared to O(N) individual deletes. +2. **Partition-level TTL**: Each column family can have its own TTL configuration, enabling automatic eviction of partitions that haven't been written to recently. + +``` +RocksDB Instance +├── CF: "default" (metadata: partition registry, timestamps) +├── CF: "dt=2025-01-15" (RLI entries for partition dt=2025-01-15) +├── CF: "dt=2025-01-16" (RLI entries for partition dt=2025-01-16) +└── CF: "dt=2025-01-17" (RLI entries for partition dt=2025-01-17) +``` + +**Partition completeness invariant**: a partition's column family is only visible to lookups once it has been *fully* loaded — there is no partially-cached partition state. Bootstrap and on-demand loading (see [Bootstrap Strategy](#bootstrap-strategy)) build the column family off to the side (or via bulk-load into a not-yet-registered CF) and only register it in the `default` CF's partition registry after the load completes. A cache miss (column family absent from the registry) is therefore unambiguous: it always means "load this partition's entries from MDT," never "some entries for this partition may already be cached, check anyway." This is what lets bootstrap and eviction operate at whole-partition granularity without needing to reconcile partial per-key state. + +### Bootstrap Strategy + +On job start or task failover, the RocksDB cache must be populated from the MDT RLI. The bootstrap strategy differs based on the index scope: + +#### Global RLI Bootstrap + +For global RLI (cross-partition upsert), the cache must contain all record keys across the entire table: + +1. Close and discard any existing RocksDB state (container-local storage is ephemeral). +2. Scan the full MDT RLI partition assigned to this task (based on `hash(record_key) % num_index_shards` assignment from RFC-106). +3. Bulk-load entries into RocksDB using `SSTFileWriter` for optimal ingestion performance. +4. Open the RocksDB instance for read-write access. + +**Bootstrap latency**: For a table with 1 billion records and ~50–70 bytes per entry, scanning and loading the full index requires approximately 50–70 GB of data transfer. At 500 MB/s network throughput, this takes ~2 minutes. This cost is incurred on every job restart. + +#### Partitioned RLI Bootstrap (Recommended) + +This strategy leverages Hudi's [Partitioned Record Index](https://hudi.apache.org/docs/indexes/) (introduced in 1.1.0), which organizes the MDT record-level index by partition path. Unlike the Global Record Index, the Partitioned Record Index guarantees uniqueness within each `(partition_path, record_key)` pair and supports partition-scoped lookups, making it possible to load only a subset of the index during bootstrap. + +**Time-bounded bootstrap**: On job start or task failover, the cache loads only the most recent **X days** of partitioned record index entries (configurable via `hoodie.index.cache.rocksdb.bootstrap.days`). This bounds bootstrap time and resource consumption to a predictable window: + +1. Close and discard any existing RocksDB state. +2. Determine the bootstrap window: compute the set of partitions whose partition path falls within the last X days (e.g., `dt=2025-01-15` through `dt=2025-01-21` for a 7-day window). +3. For each partition in the bootstrap window, scan only its corresponding MDT Partitioned Record Index entries and bulk-load them into a dedicated RocksDB column family. +4. Record the bootstrap boundary timestamp (the oldest partition loaded) in the `default` column family metadata. + +**Non-temporal partition schemes**: the "last X days" window assumes partition paths are date-based (e.g. `dt=2025-01-15`) and can be ordered chronologically. For tables partitioned by a non-temporal key (e.g. `region=us-west`, `category=electronics`) or by a composite key without a leading date component, there is no meaningful notion of "the most recent X days of partitions." For these tables, time-bounded bootstrap falls back to **access-recency bootstrap**: the cache starts empty (equivalent to `bootstrap.days = 0`), and every partition is loaded on demand on first access via the on-demand loading path described below. TTL-based eviction still applies per column family based on last-access time regardless of partition naming scheme. This RFC recommends the time-bounded bootstrap optimization only for date-partitioned tables initially; supporting user-supplied partition-recency hints for non-temporal schemes is left as future work. + +**On-demand loading for older partitions**: During ingestion, when the `BucketAssigner` encounters an incoming record whose partition path is **older than the bootstrap window** (i.e., not yet cached in RocksDB): + +1. Detect the cache miss: the target partition's column family does not exist in RocksDB. +2. Trigger an **on-demand partition load**: read the Partitioned Record Index entries for that specific partition from MDT and materialize them into a new RocksDB column family. +3. Once loaded, perform the record lookup against the newly populated column family and continue normal processing. +4. The on-demand loaded partition is subject to the same TTL-based eviction as bootstrapped partitions (see [TTL-Based Partition Eviction](#ttl-based-partition-eviction)). + +**Handling bursty cold-partition access (backfills/corrections)**: a workload that touches many cold partitions in a short window (e.g. a backfill correcting records across 30 old partitions) would otherwise trigger 30 sequential synchronous loads on the `BucketAssigner`/`RLIBootstrapOperator` thread, each taking on the order of seconds, causing pipeline backpressure. To bound this in a future iteration: + +- On-demand loads for distinct partitions are dispatched to a small bounded thread pool rather than the task's main processing thread, so independent partitions can load in parallel instead of serially. +- Records whose partition is currently being loaded are buffered (bounded by a configurable in-flight record limit) rather than blocking the operator thread; once the column family finishes loading, buffered records are replayed against it. +- If the in-flight buffer limit is exceeded, the operator applies backpressure to upstream by not requesting more input, rather than growing the buffer unboundedly. + +This is scoped as follow-up work beyond the initial synchronous implementation (see [Implementation Plan](#implementation-plan)); the first phase accepts synchronous on-demand loading and measures actual stall impact before investing in the async path. + +This two-tier approach — time-bounded bootstrap plus on-demand loading — ensures that: + +- **Bootstrap is fast and predictable**: loading X days of index data is bounded and proportional to recent write volume, not total table size. For a daily-partitioned table with 10M records/day at ~60 bytes/entry, a 7-day bootstrap loads ~4.2 GB — completing in seconds rather than minutes. +- **Late-arriving data is handled correctly**: updates to partitions older than X days (e.g., backfills, corrections, late-arriving events) trigger on-demand loading of only the affected partition, avoiding a full re-bootstrap. +- **Cache growth remains bounded**: combined with TTL eviction, the cache holds at most `bootstrap_days + on-demand loaded` partitions, with cold partitions automatically evicted. + +``` +Bootstrap Timeline (X = 7 days) + +◄──── Older partitions ────┤◄──── Bootstrap window (7 days) ────►│ Today + │ │ + dt=2025-01-10 dt=2025-01-13 dt=2025-01-15 ... dt=2025-01-21 dt=2025-01-22 + │ │ │ │ + Not loaded Not loaded Bootstrapped at Bootstrapped + (load on (load on job start at job start + demand if demand if + needed) needed) + +When a record arrives for dt=2025-01-10: + 1. Column family "dt=2025-01-10" not found in RocksDB + 2. On-demand load: read Partitioned Record Index for dt=2025-01-10 from MDT + 3. Create column family, bulk-load entries + 4. Perform lookup and continue +``` + +### Incremental Cache Maintenance + +To achieve the on demand RLI load for an older partition, RLIBootstrapOperator needs to be revised to access the shared RocksDB instance with BucketAssign operator. After bootstrap of RLIBootstrapOperator, the RocksDB cache is maintained incrementally during normal write operations: + +**Operator co-location**: `RLIBootstrapOperator` and `BucketAssigner` must run in the same task slot for shared, in-process RocksDB access to be possible at all — routing lookups across the network would defeat the purpose of the cache. This is guaranteed by chaining the two operators (same parallelism, no keyBy/shuffle between them, chained via Flink's operator chaining), so they execute in the same TaskManager JVM and can share a direct reference to the RocksDB instance. + +**Concurrency model**: within a chained pair, Flink's runtime already guarantees that only one operator's `processElement` executes at a time per subtask (chained operators share a single task thread), so `RLIBootstrapOperator` and `BucketAssigner` never call into RocksDB concurrently for the same record. The only cross-thread access is the background TTL eviction thread (see [TTL-Based Partition Eviction](#ttl-based-partition-eviction)) dropping a column family while the task thread might be reading from it; this is serialized with a per-column-family read-write lock so an in-flight lookup completes (or cleanly misses) before the column family is dropped. + +#### On Record Processing + +``` +for each incoming record r: + 1. In RLIBootstrapOperator, look up through shared RocksDB for r.partitionPath + → If column family does not exist: + a. Load Partitioned Record Index for r.partitionPath from MDT (on-demand) + b. Create column family and bulk-load entries + → If found: forward to next operator BucketAssign + 2. In BucketAssign operator, check RocksDB cache for r.key in column family r.partitionPath + → If found in RocksDB: use cached location (committed record) + → If not found: this is an INSERT, assign new file group + 3. Update RocksDB cache with r.key → assigned location +``` + +In this process above, `RLIBootstrapOperator` is responsible for checking whether a record is from an older partition that has not been bootstrapped. If so, it triggers the on-demand load from MDT and forwards the `HoodieFlinkInternalRow` to the `BucketAssigner` operator. + +#### On Index Write + +In the `IndexWrite` operator (from RFC-106), index records are written to MDT. The RocksDB cache in the `BucketAssigner` is updated in-line as records flow through the pipeline, ensuring the cache stays ahead of MDT commits. + +### TTL-Based Partition Eviction + +For partitioned RLI, the cache implements automatic eviction of cold partitions: + +- Each column family tracks the **last access timestamp** (last time a record was written to or looked up in that partition). +- A background thread periodically scans column family metadata and drops column families whose last access exceeds the configured TTL. +- The TTL should be set based on the workload's partition access pattern. For daily-partitioned event data, a TTL of 3–7 days is typical. + +Configuration: + +| Property | Default | Description | +|---|---|---| +| `hoodie.index.cache.rocksdb.enabled` | `false` | Enable RocksDB-based partitioned cache | +| `hoodie.index.cache.rocksdb.base.path` | `/tmp/hudi-index-cache` | Local directory for RocksDB data | +| `hoodie.index.cache.rocksdb.bootstrap.days` | `7` | Number of days of Partitioned Record Index to load during bootstrap. Only partitions within this window are pre-loaded; older partitions are loaded on demand when updates are observed. | +| `hoodie.index.cache.rocksdb.partition.ttl.hours` | `168` (7 days) | TTL for partition column families | +| `hoodie.index.cache.rocksdb.block.cache.mb` | `256` | RocksDB block cache size (off-heap) | +| `hoodie.index.cache.rocksdb.compaction.style` | `LEVEL` | RocksDB compaction style | + +### Storage Overhead + +RocksDB occupies approximately **2x the storage** compared to native HFile format in MDT, due to: + +1. **Compression codec difference**: RocksDB uses Snappy compression by default, while Hudi MDT uses gzip, which achieves higher compression ratios. +2. **Uncompacted SST files**: During active writes, RocksDB maintains multiple levels of SST files before compaction merges them. +3. **WAL disabled**: Write-ahead log is disabled since the cache can be rebuilt from MDT on failure, reducing write amplification. + +For a partition with 10 million records at ~60 bytes per entry, the RocksDB footprint is approximately 1.2 GB on disk. + +### Consistency and Failure Handling + +The RocksDB cache is a **derived, disposable replica** — MDT remains the single source of truth. This simplifies consistency handling: + +#### Task Failover + +1. The RocksDB cache on the failed task's container is discarded (ephemeral local storage). +2. The recovered task bootstraps a fresh RocksDB instance from MDT. Review Comment: 🤖 Step 2 bootstraps RocksDB from MDT, but step 4 recommits pending instants afterward — so keys landed by that recommit are absent from the freshly-loaded cache, and with a miss treated as INSERT (line 208), a later update to one of those keys could be misclassified as a new insert and duplicated. Could bootstrap be sequenced strictly after recommit, or the affected partitions invalidated/reloaded once recommit completes? @nsivabalan does this ordering hold under RFC-106's recovery protocol? <sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag quality.</i></sub> -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
