terrytlu opened a new issue, #17213:
URL: https://github.com/apache/iceberg/issues/17213

   ### Apache Iceberg version
   
   1.4.3
   
   ### Query engine
   
   Spark
   
   ### Please describe the bug 🐞
   
   ### Background / Motivation
   
   Apache Iceberg tables are often created with an explicit `LOCATION`, and it 
is easy for **two tables to point at the same underlying directory**:
   
   - **Accidental** — a `SHOW CREATE TABLE` DDL is copied to a test/temp table 
with only the table name changed, *forgetting to change the `LOCATION`*. (This 
is exactly how we hit the problem in production; see Real-world impact.)
   - **Intentional but hazardous** — in-place / zero-copy migration, where a 
new table (e.g. Iceberg) is registered over an existing data directory while 
the old table definition is still kept pointing at the same directory; or an 
ad-hoc temp table created with an explicit `LOCATION` that coincides with a 
production table.
   
   When orphan-file cleanup runs against one of those tables, it computes 
"reachable files" from that single table's metadata and deletes everything else 
it can list — including the **other table's data and metadata files**.
   
   The community has previously reported this class of problem — originally as 
#4265 ("DeleteOrphanFiles didn't check the owned table, may cause meta 
missed"), describing exactly the two-tables-share-a-location → metadata 
corruption scenario. #4265 was subsequently linked to #4346 and closed when 
#4346 was resolved by PR #4652 (the `prefixMismatchMode` work). However, as 
shown below, PR #4652 only resolves **scheme/authority prefix mismatches** 
during path normalization — a largely distinct problem from two independent 
tables sharing one location. The original #4265 scenario, where the two tables 
share the **same** scheme + authority + path, was therefore never actually 
protected, which is what this issue re-raises.
   
   ### Problem description
   
   For `DeleteOrphanFilesSparkAction`:
   
   - `validFileIdentDS()` collects reachable files **only from the target 
table** (`contentFileDS(table)`, `manifestDS(table)`, `manifestListDS(table)`, 
and `otherMetadataFileDS(table)` which is `recursive=false` → only the current 
metadata.json's `previousFiles()`).
   - `actualFileIdentDS()` **recursively lists the whole `table.location()`** 
directory.
   - `findOrphanFiles()` does a left-outer join and deletes anything 
listed-but-not-valid.
   
   When a second table shares the same location, all of that second table's 
files are "listed but not valid" → deleted. **Both data files and metadata 
files are removed**, corrupting the second table.
   
   Note on where the logic lives: orphan-file deletion is **not** implemented 
in `iceberg-core`. The `DeleteOrphanFiles` **interface** 
(`org.apache.iceberg.actions`, in `iceberg-api`) and its base 
`BaseDeleteOrphanFiles` (in `iceberg-core`) only define the action *contract*; 
the actual list-and-delete implementation in this repo is 
`DeleteOrphanFilesSparkAction` (`iceberg-spark`). What *is* shared in core are 
the **reachability primitives** — `TableMetadata`, `ReachableFileUtil`, 
`MetadataTableUtils` — used to compute which files a table references. Every 
implementation that reuses those primitives and then deletes the 
listed-but-unreferenced files (the Spark action, and downstream systems such as 
Amoro's `IcebergTableMaintainer.cleanOrphanFiles`) computes reachability from a 
**single** table's metadata, so all of them inherit the same vulnerability.
   
   ### Reproduction
   
   ```sql
   -- Create test db
   CREATE DATABASE IF NOT EXISTS spark_catalog.repro_db_v2;
   -- Table A at a fixed location
   CREATE TABLE spark_catalog.repro_db_v2.a (id BIGINT) USING iceberg LOCATION 
'hdfs://HDFS78000003/tmp/iceberg_shared_repro_v2';
   INSERT INTO spark_catalog.repro_db_v2.a VALUES (1L), (2L);
   
   -- Table B accidentally (or temporarily) points at the SAME location
   CREATE TABLE spark_catalog.repro_db_v2.b (id BIGINT) USING iceberg LOCATION 
'hdfs://HDFS78000003/tmp/iceberg_shared_repro_v2';
   INSERT INTO spark_catalog.repro_db_v2.b VALUES (3L);
   
   -- Run orphan cleanup on A
   CALL spark_catalog.system.remove_orphan_files(
     table      => 'repro_db_v2.a',
     older_than =>  TIMESTAMP '2099-09-10 12:00:00.000');
   
   -- Result: B's metadata.json / manifest / data files under the shared
   -- directory are now deleted -> B is corrupted / unloadable.
   SELECT * from spark_catalog.repro_db_v2.b;
   ```
   
   
   ### Why existing mitigations don't help
   
   **`gc.enabled`** (`TableProperties.GC_ENABLED`, default `true`): this is a 
*declarative* switch — if a table sets `gc.enabled=false`, its own GC is 
disabled. It only protects the table that set it. If Table A keeps the default 
`true` while Table B sets `false`, A's cleanup still deletes B's files (A looks 
only at A's own reachability). To be safe you must disable GC on **both** 
tables — exactly what users forget when they spin up a temp table on a shared 
path. `SnapshotTableSparkAction` sets `gc.enabled=false` on the *target* 
automatically, but that convention is not applied to arbitrary co-located 
tables.
   
   
   ### Real-world impact
   
   This is not a theoretical edge case. We hit exactly this failure mode in our 
production environment:
   
   - An analyst created a **test table by copying the `SHOW CREATE TABLE` DDL 
of a production table** and only renamed the table, **forgetting to change the 
`LOCATION`** clause. The test table therefore pointed at the production table's 
directory.
   - Much later, an orphan-file cleanup was run against the **test** table. 
Because the test table's reachability set does not include the production 
table's files, the cleanup deleted the production table's **data and metadata 
files** — silent, irreversible data loss on the production table.
   - After the incident we scanned the **entire environment** and found **100+ 
tables** that share a `LOCATION` with another table in the same way. The 
proportion is small, but the pattern occurs **unconsciously** (a copy-paste of 
DDL) and leads to severe, unrecoverable consequences.
   
   
   ### Proposed solution: automatic location-conflict detection via `table-uuid`
   Unlike the `gc.enabled` approach, we propose a **default-on, automatic 
location-conflict guard** rather than a convention that users must remember to 
follow.
   Iceberg already assigns each table an immutable `table-uuid` at creation 
time (stored in every `metadata.json`, copied verbatim on each commit; see 
`TableMetadata`). If a `metadata/` directory contains a `metadata.json` whose 
`table-uuid` differs from the table currently being cleaned, it is strong 
evidence that **another table shares this location**, and cleanup should be 
aborted (or at least skipped) for safety.
   
   **Design**
   
   Before performing deletions, any `DeleteOrphanFiles` implementation (e.g. 
`DeleteOrphanFilesSparkAction`) should:
   
   1. List `metadata/*.metadata.json` (+ `.metadata.json.gz`) in 
`table.location()/metadata`.
   2. For each file not belonging to the current table's own `previousFiles()` 
set, read its `table-uuid`:
      - Same `table-uuid` → older version of the current table (truncated by 
`previous-versions-max`); ignore.
      - Different `table-uuid` → another table shares the location → abort 
cleanup (throw a clear exception / skip).
      - `table-uuid` unreadable (legacy / compressed / corrupt) → treat as 
suspicious → abort/skip (fail-safe).
   3. Only proceed when no foreign `table-uuid` is found.
   
   This is automatic (zero config), direction-agnostic (protects both tables 
regardless of which one triggers cleanup), and engine-agnostic if placed in the 
shared `DeleteOrphanFiles` action contract (e.g. as a guarded step in 
`BaseDeleteOrphanFiles`, or a reusable utility in `iceberg-core` that any 
engine / downstream system can call before deleting orphans).
   
   **Sketch (Spark action / core)**
   
   ```java
   private boolean hasOtherTableInLocation(Table table) {
     String metadataDir = table.location() + "/metadata/";
     String myUuid = ((HasTableOperations) table).operations().current().uuid();
   
     // Files this table already "knows about"
     Set<String> myMetadataFiles = new HashSet<>();
     TableMetadata current = ((HasTableOperations) 
table).operations().current();
     myMetadataFiles.add(fileName(current.metadataFileLocation()));
     for (MetadataLogEntry e : current.previousFiles()) {
       myMetadataFiles.add(fileName(e.file()));
     }
   
     PathFilter filter = p -> {
       String n = p.getName();
       return n.endsWith(".metadata.json") || n.endsWith(".metadata.json.gz");
     };
     try {
       for (FileStatus f : fs.listStatus(new Path(metadataDir), filter)) {
         if (myMetadataFiles.contains(f.getPath().getName())) continue;
         String otherUuid = readTableUuid(f.getPath()); // gzip-aware; null if 
unreadable
         if (otherUuid == null) {
           LOG.warn("Cannot read table-uuid from {}; treat as suspicious, skip 
orphan clean", f.getPath());
           return true;
         }
         if (!otherUuid.equals(myUuid)) {
           LOG.warn("Another table (uuid={}) shares location {}; skip orphan 
clean for {}",
                    otherUuid, metadataDir, myUuid);
           return true;
         }
       }
     } catch (IOException e) {
       LOG.warn("Failed to list metadata dir {}; skip orphan clean", 
metadataDir, e);
       return true;
     }
     return false;
   }
   ```
   
   `readTableUuid` must be gzip-aware: detect the gzip magic bytes (`0x1f 
0x8b`) and decompress before scanning for `"table-uuid"`.
   
   **Edge cases handled**
   
   - `previous-versions-max` truncation → older versions of the *same* table 
share the same `table-uuid`, correctly ignored (only a few extra reads).
   - `.metadata.json.gz` → handled by gzip-aware read + matching file filter.
   - Legacy files without `table-uuid` → `TableMetadataParser` reads it via 
`getStringOrNull`; treated as suspicious (fail-safe skip), never silently 
allowed.
   - Performance → on the normal (no-conflict) path it is a single `listStatus` 
(~10ms) plus zero file reads (all listed files belong to the current table).
   
   ### Alternatives considered
   
   - Rely on `gc.enabled=false` on both tables — manual, easy to forget; 
doesn't scale to temp/ad-hoc tables.
   - Rely on `prefixMismatchMode` — only catches scheme/authority differences, 
misses the dominant shared-`hdfs`/`s3` case.
   - Require a unique location per table (convention) — can't be enforced by 
the engine and breaks legitimate snapshot-table patterns.
   
   ### Ask
   
   We'd like to propose adding this location-conflict guard to the 
`DeleteOrphanFiles` action contract. Note that the only in-repo implementation 
today is `DeleteOrphanFilesSparkAction`; the same pattern must also protect any 
engine / downstream system that lists-and-deletes orphans from a single table's 
metadata (e.g. Amoro's `IcebergTableMaintainer.cleanOrphanFiles`). Happy to 
contribute a PR. 
   
   
   ### Willingness to contribute
   
   - [x] I can contribute a fix for this bug independently
   - [ ] I would be willing to contribute a fix for this bug with guidance from 
the Iceberg community
   - [ ] I cannot contribute a fix for this bug at this time


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


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to