voonhous commented on code in PR #18359:
URL: https://github.com/apache/hudi/pull/18359#discussion_r3506242605


##########
rfc/rfc-100/rfc-100-blob-cleaner-design.md:
##########
@@ -0,0 +1,777 @@
+<!--
+  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-100 Part 2: External Blob Cleanup for Unstructured Data
+
+## Proposers
+
+- @voon
+
+## Approvers
+
+- @rahil-c
+- @vinothchandar
+- @yihua
+
+## Status
+
+Issue: <Link to GH feature issue>
+
+> Please keep the status updated in `rfc/README.md`.
+
+---
+
+## Abstract
+
+When Hudi cleans expired file slices, external out-of-line blob files they 
reference may become
+orphaned -- still consuming storage but unreachable by any query. This RFC 
extends the existing file
+slice cleaner to identify and delete these orphaned blob files safely and 
efficiently. The design
+uses a two-stage pipeline: (1) per-file-group set-difference to find 
locally-orphaned blobs, and
+(2) cross-file-group verification via MDT secondary index lookup. Targeted 
index lookups scale with
+the number of candidates, not the table size. Tables without blob columns pay 
zero cost.
+
+This design focuses on **external blobs** -- the Phase 1 use case of RFC-100 
where users have
+existing blob files in external storage (e.g., `s3://media-bucket/videos/`) 
and Hudi manages the
+*references* via the `BlobReference` schema, not the *storage layout*.
+
+---
+
+## Background
+
+### Why Blob Cleanup Is Needed
+
+RFC-100 introduces out-of-line blob storage for unstructured data (images, 
video, documents). A
+record's `BlobReference` field points to an external blob file by 
`reference.external_path`. When
+the cleaner expires old file slices, the blob files they reference may no 
longer be needed -- but the
+existing cleaner has no concept of transitive references. It deletes file 
slices without considering
+the blob files they point to. Without blob cleanup, orphaned blobs accumulate 
indefinitely.
+
+### External Blobs
+
+Users have existing blob files in external storage (e.g., 
`s3://media-bucket/videos/`). Records
+reference these blobs directly by path. Hudi manages the *references*, not the 
*storage layout*.
+Cross-file-group sharing is common -- multiple records across different file 
groups can point to the
+same blob. Key properties:
+
+| Property                  | External blobs                               |
+|---------------------------|----------------------------------------------|
+| Path uniqueness           | Not guaranteed (user controls)               |
+| Cross-FG sharing          | Common (multiple records, same blob)         |
+| Writer/cleaner race       | Can occur (external paths outside MVCC)      |
+| Per-FG cleanup sufficient | No -- cross-FG verification needed           |
+
+### Constraints and Requirements Reference
+
+Full descriptions and failure modes in [Problem 
Statement](rfc-100-blob-cleaner-problem.md).
+
+| ID  | Constraint                                          | Remarks          
                |
+|-----|-----------------------------------------------------|----------------------------------|
+| C1  | Blob immutability (append-once, read-many)          |                  
                |
+| C2  | Delete-and-re-add same path                         | Real concern for 
external blobs  |
+| C3  | Cross-file-group blob sharing                       | Common for 
external blobs        |
+| C4  | MOR log updates shadow base file blob refs          |                  
                |
+| C5  | Existing cleaner is per-file-group scoped           |                  
                |
+| C6  | OCC is per-file-group                               | No global 
contention allowed     |
+| C7  | Replace commits move blob refs between file groups  | Clustering, 
insert_overwrite     |
+| C8  | Savepoints freeze file slices and blob refs         |                  
                |
+| C9  | Rollback and restore can invalidate or resurrect    |                  
                |
+| C10 | Archival removes commit metadata                    |                  
                |
+| C11 | Cross-FG verification needed at scale               |                  
                |
+
+| ID  | Requirement                                                      |
+|-----|------------------------------------------------------------------|
+| R1  | No premature deletion (hard invariant)                           |
+| R2  | No permanent orphans (bounded cleanup)                           |
+| R3  | MOR correctness (over-retention acceptable, under-retention not) |
+| R4  | Concurrency safety (no global serialization)                     |
+| R5  | Scale proportional to work, not table size                       |
+| R6  | No cost for non-blob tables                                      |
+| R7  | All cleaning policies supported                                  |
+| R8  | Crash safety and idempotency                                     |
+| R9  | Observability (metrics for deleted, retained, reclaimed)         |
+
+---
+
+## Design Overview
+
+### Design Philosophy
+
+Blob cleanup extends the existing `CleanPlanner` / `CleanActionExecutor` 
pipeline -- same timeline
+instant, same plan-execute-complete lifecycle, same crash recovery and OCC 
integration. A
+`hasBlobColumns()` check gates all blob logic so non-blob tables pay near zero 
cost (schema scan 
+cost).
+
+External blobs require cross-file-group verification because the same blob can 
be referenced from
+multiple file groups (C3, C11). The design uses targeted MDT secondary index 
lookups that scale
+with the number of candidates, not the table size.
+
+### Two-Stage Pipeline
+
+| Stage       | Scope            | Purpose                                     
                         | When it runs                 |
+|-------------|------------------|----------------------------------------------------------------------|------------------------------|
+| **Stage 1** | Per-file-group   | Collect expired/retained blob refs, compute 
set difference           | Always (for blob tables)     |
+| **Stage 2** | Cross-file-group | Verify candidates against MDT secondary 
index or fallback scan       | When local orphans exist     |
+
+### Key Decisions
+
+| Decision            | Choice                                                 
 | Rationale                                                      |
+|---------------------|---------------------------------------------------------|----------------------------------------------------------------|
+| Blob identity       | `reference.external_path`                              
 | Path-based identity for external blobs                         |
+| Cleanup scope       | Per-FG candidate identification + cross-FG 
verification | Aligns with OCC (C6) and existing cleaner (C5); scales for C11 |
+| Cross-FG mechanism  | MDT secondary index on `reference.external_path`       
 | Short-circuits on first non-cleaned FG ref                     |
+| Blob delete storage | Sidecar Parquet file (`.hoodie/.aux/clean/`)           
  | Avoids plan bloat; durable artifact for writer conflict checks |
+| MOR strategy        | Over-retain (union of base + log refs)                 
 | Safe (C4, R3); cleaned after compaction                        |
+
+```mermaid
+flowchart LR
+    subgraph Planning["CleanPlanActionExecutor.requestClean()"]
+        direction TB
+        Gate{"hasBlobColumns()?"}
+        Gate -- No --> Skip["Skip blob cleanup<br/>(zero cost)"]
+        Gate -- Yes --> CP
+
+        subgraph CP["CleanPlanner (per-partition, per-FG)"]
+            direction TB
+            Policy["Policy method<br/>→ FileGroupCleanResult<br/>(expired + 
retained slices)"]
+            S1["<b>Stage 1</b><br/>Per-FG blob ref<br/>set difference"]
+            Policy --> S1
+        end
+
+        S1 --> S2["<b>Stage 2</b><br/>Cross-FG verification<br/>(MDT secondary 
index)"]
+        S2 --> SC["Write sidecar 
Parquet<br/>.hoodie/.aux/clean/&lt;instant&gt;<br/>.blob_deletes.parquet"]
+    end
+
+    subgraph Plan["HoodieCleanerPlan"]
+        FP["filePathsToBeDeleted<br/>(existing)"]
+        EM["extraMetadata[blobDeletesPath]<br/>(pointer to sidecar)"]
+    end
+
+    SC --> EM
+    CP --> FP
+
+    subgraph Execution["CleanActionExecutor.runClean()"]
+        direction TB
+        RS["Read sidecar Parquet"]
+        DF["Delete file slices<br/>(existing, parallel)"]
+        DB["Delete blob files<br/>(new, parallel)"]
+        RS --> DB
+    end
+
+    FP --> DF
+    EM --> RS
+```
+
+---
+
+## Algorithm
+
+### Stage 1: Per-File-Group Local Cleanup
+
+Stage 1 runs after the existing policy logic determines which file slices are 
expired and retained
+for a given file group. It collects blob refs from both sets and computes 
locally-orphaned blobs by
+set difference. All local orphans proceed to Stage 2 for cross-FG verification.
+
+```
+Input:  A file group FG with expired_slices and retained_slices (from policy)
+Output: local_orphan_candidates -- external blobs needing cross-FG verification
+
+for each file_group being cleaned:
+
+    // Collect expired blob refs (base files + log files)
+    // Must read log files: blob refs introduced and superseded within the log
+    // chain before compaction would otherwise become permanent orphans.
+    expired_refs = Set<external_path>()
+    for slice in expired_slices:
+        for ref in extractBlobRefs(slice.baseFile):   // columnar projection
+            if ref.type == OUT_OF_LINE and ref.managed == true:
+                expired_refs.add(ref.external_path)
+        for ref in extractBlobRefs(slice.logFiles):   // full record read
+            if ref.type == OUT_OF_LINE and ref.managed == true:
+                expired_refs.add(ref.external_path)
+
+    if expired_refs is empty:
+        continue                                       // no blob work for 
this FG
+
+    // Collect retained blob refs (base files only)
+    // Cleaning is fenced on compaction: retained base files contain the merged
+    // state. Log reads are unnecessary -- any shadowed base ref causes safe
+    // over-retention, cleaned after the next compaction cycle.
+    retained_refs = Set<external_path>()
+    for slice in retained_slices:
+        for ref in extractBlobRefs(slice.baseFile):   // columnar projection 
only
+            if ref.type == OUT_OF_LINE and ref.managed == true:
+                retained_refs.add(ref.external_path)
+
+    // Compute local orphans by set difference
+    local_orphans = expired_refs - retained_refs
+
+    // All local orphans proceed to Stage 2 for cross-FG verification
+    all_local_orphans.addAll(local_orphans)

Review Comment:
   Agreed, real R1 bug -- and it violates the problem statement's own rule ("if 
any retained reference to the same `external_path` exists, regardless of the 
`managed` flag, the blob must not be deleted"). Stage 1 filtered 
`retained_refs` by `managed == true`, so an unmanaged retained reference was 
invisible and the path became a false orphan. Fixed.
   
   The fix separates the flag's two jobs, which the code was conflating:
   
   - **Eligibility** (which paths *can* be deleted): keep `managed == true` on 
`expired_refs` -- Hudi never deletes a blob it wasn't asked to manage.
   - **Liveness** (is it still referenced): managed-agnostic. `retained_refs` 
now collects every `OUT_OF_LINE` reference regardless of `managed`, so a 
managed->unmanaged transition (t1 `managed=true`, t2 update sets 
`managed=false` on the same path) keeps the path in `retained_refs`, out of 
`local_orphans`, and the blob survives.
   
   Your two explicit questions:
   
   1. **Does the SI index `managed=false` records?** Yes. The index keys on 
`reference.external_path` for every `OUT_OF_LINE` record with no `managed` 
predicate -- standard SI record generation indexes the field value per record 
without filtering. So Stage 2's lookup finds an unmanaged retained reference 
exactly as it finds a managed one.
   2. **How is managed->unmanaged handled?** By (1): the index and the fallback 
scan are both managed-agnostic on the liveness side, so the unmanaged retained 
reference is found and the blob is retained -- no separate enrichment pass 
needed. I also added an explicit guardrail in the doc: a future optimization 
must not add a `managed` filter to the index or the scan without breaking R1.
   
   Doc updated: Stage 1 pseudocode (drop the filter on `retained_refs`, clarify 
`expired_refs` is the eligibility gate) + a "managed flag -- eligibility vs 
liveness" correctness note, and a "Managed-agnostic indexing (R1)" note in 
Stage 2.
   



-- 
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]

Reply via email to