morningman opened a new issue, #70540:
URL: https://github.com/apache/doris/issues/70540

   ### Search before asking
   
   - [x] I had searched in the 
[issues](https://github.com/apache/doris/issues?q=is%3Aissue) and found no 
similar issues.
   
   ### Version
   
   4.1.5, reported by a community user (Iceberg REST catalog served by 
Lakekeeper).
   
   master at 172120cbaa4 has the same code path. That is from reading the code; 
it was not reproduced on master.
   
   ### What's Wrong?
   
   A streaming insert job (continuous load) whose target is an Iceberg table 
never advances its offset. Every task reads the first batch of files again and 
commits it to Iceberg as a new append snapshot. No error is reported anywhere, 
so the job keeps duplicating data until someone pauses it.
   
   The job:
   
   ```sql
   CREATE JOB toy_events_v1_iceberg
   PROPERTIES (
       "s3.max_batch_files" = "1",
       "max_interval" = "10"
   )
   ON STREAMING DO
   INSERT INTO <iceberg_catalog>.<db>.toy_events_v1 (event_id, 
partition_timestamp)
   SELECT event_id, partition_timestamp
   FROM S3(
       "uri" = "s3://<bucket>/toy_events_v1/year=2026/month=11/day=12/hour=*/*",
       "s3.region" = "us-east-2",
       "s3.endpoint" = "https://s3.us-east-2.amazonaws.com";,
       "s3.credentials_provider_type" = "CONTAINER",
       "format" = "parquet"
   );
   ```
   
   Observed:
   
   - `CurrentOffset` in `jobs("type" = "insert")` stays empty.
   - Every task has the same `RunningOffset`: `startFileName = endFileName = 
toy_events-0.parquet`.
   - Every task ends `SUCCESS` with an empty `ErrorMsg`. A new task starts 
about every 1.5 s.
   - Each task commits a full append snapshot (a linear parent chain) with the 
same rows. With 5 files of 100 rows, the table had 1,400 rows and only 100 
distinct `event_id` when the job was paused by hand. In a larger test the table 
reached 12 times the first batch of 13,667 rows, still with 13,667 distinct ids.
   - A task cancelled by `PAUSE JOB` still committed its Iceberg snapshot.
   
   Ruled out by the reporter:
   
   - The same job (same files, same `SELECT`, same properties) into a Doris 
internal table advances through 5 consecutive files and ends with 500 rows and 
500 distinct `event_id`.
   - A plain `INSERT INTO <iceberg table> SELECT ... FROM S3(...)` works.
   - The job's current database does not matter: it loops after both `USE 
<iceberg_catalog>.<db>` and `USE internal.<db>`.
   - Lakekeeper logs show no commit conflicts or server errors.
   
   ### What You Expected?
   
   The job loads each batch once and advances `CurrentOffset`, as it does for 
an internal table. If Iceberg (or any external table) is not a supported 
target, `CREATE JOB ... ON STREAMING` should reject it instead of accepting a 
job that duplicates data.
   
   ### How to Reproduce?
   
   1. Put 5 parquet files `toy_events-0.parquet` … `toy_events-4.parquet` under 
one S3 prefix, 100 rows each, with `event_id` unique across files:
      ```
      message ToyEventsWriteModel {
        required binary event_id (STRING);
        required int64 partition_timestamp (TIMESTAMP(MICROS,true));
      }
      ```
   2. Create the target table in an Iceberg catalog:
      ```sql
      CREATE TABLE <iceberg_catalog>.<db>.toy_events_v1 (
          event_id STRING,
          partition_timestamp DATETIME(6)
      )
      PARTITION BY LIST (DAY(partition_timestamp)) ()
      PROPERTIES ("write.format.default" = "parquet", "compression_codec" = 
"zstd");
      ```
   3. Create the job above and watch:
      ```sql
      SELECT Status, CurrentOffset, EndOffset, ErrorMsg FROM jobs("type" = 
"insert") WHERE Name = 'toy_events_v1_iceberg';
      SELECT Status, RunningOffset FROM tasks("type" = "insert") WHERE JobId = 
'<job id>' ORDER BY CreateTime;
      SELECT count(*), count(DISTINCT event_id) FROM 
<iceberg_catalog>.<db>.toy_events_v1;
      ```
   
   ### Anything Else?
   
   #### Root cause
   
   A TVF streaming job moves its offset only inside the Doris load-transaction 
callbacks, and only the internal-table insert path attaches its transaction to 
the job. An external-table insert commits through the catalog's own transaction 
manager, so the callbacks never run. The insert still returns OK, so the task 
counts as successful and the next task starts from the unchanged, empty offset.
   
   Links point to 4.1.5-release (917b20be838) and master (172120cbaa4).
   
   1. `OlapInsertExecutor.setTxnCallbackId()` sets 
`TransactionState.callbackId` to the streaming job id 
([4.1.5](https://github.com/apache/doris/blob/917b20be83878e98f94e3ebf7efccc80346e3e50/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/OlapInsertExecutor.java#L259-L271),
 
[master](https://github.com/apache/doris/blob/172120cbaa46b0a2eff6c37d86fb438c3a307375/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/OlapInsertExecutor.java#L269-L281)).
 When `GlobalTransactionMgr` commits that transaction, it calls 
`StreamingInsertJob.beforeCommitted()` and `afterCommitted()`.
   2. `beforeCommitted()` refuses the commit if the task was cancelled, and 
puts the task's end offset into a `StreamingTaskTxnCommitAttachment` 
([4.1.5](https://github.com/apache/doris/blob/917b20be83878e98f94e3ebf7efccc80346e3e50/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java#L1331-L1376),
 
[master](https://github.com/apache/doris/blob/172120cbaa46b0a2eff6c37d86fb438c3a307375/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java#L1478-L1515)).
 `afterCommitted()` calls `updateJobStatisticAndOffset()`, which calls 
`offsetProvider.updateOffset(endOffset)` 
([4.1.5](https://github.com/apache/doris/blob/917b20be83878e98f94e3ebf7efccc80346e3e50/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java#L1383-L1388),
 
[master](https://github.com/apache/doris/blob/172120cbaa46b0a2eff6c37d86fb438c3a307375/fe/fe-core/src/main/java/org/apache/doris/job/extensions/
 insert/streaming/StreamingInsertJob.java#L1522-L1533)). The attachment is 
persisted with the transaction's edit log and replayed by 
`replayOnCommitted()`. While a TVF job runs, this is the only place its offset 
moves. The other `updateOffset()` calls handle the `offset` property of 
CREATE/ALTER JOB and its replay, cloud-mode replay, and the CDC `commitOffset` 
RPC.
   3. An Iceberg insert runs `IcebergInsertExecutor` in 4.1 and 
`PluginDrivenInsertExecutor` on master. Both commit in 
`BaseExternalTableInsertExecutor.onComplete()` by calling 
`transactionManager.commit(txnId)` on the catalog's transaction manager 
([4.1.5](https://github.com/apache/doris/blob/917b20be83878e98f94e3ebf7efccc80346e3e50/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/BaseExternalTableInsertExecutor.java#L92-L131),
 
[master](https://github.com/apache/doris/blob/172120cbaa46b0a2eff6c37d86fb438c3a307375/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/BaseExternalTableInsertExecutor.java#L100-L140)).
 Nothing on this path touches `GlobalTransactionMgr` or looks up the streaming 
task, so neither callback runs.
   4. The insert ends with `QueryState.OK`, so `StreamingInsertTask.run()` 
returns and `onSuccess()` calls `StreamingInsertJob.onStreamTaskSuccess()`, 
which creates the next task right away 
([4.1.5](https://github.com/apache/doris/blob/917b20be83878e98f94e3ebf7efccc80346e3e50/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java#L882-L910),
 
[master](https://github.com/apache/doris/blob/172120cbaa46b0a2eff6c37d86fb438c3a307375/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java#L1013-L1042)).
   5. The next task calls `S3SourceOffsetProvider.getNextOffset()`, which lists 
files after `currentOffset.endFile`. `currentOffset` is still null, so the 
listing starts from the beginning and returns the same first file 
([4.1.5](https://github.com/apache/doris/blob/917b20be83878e98f94e3ebf7efccc80346e3e50/fe/fe-core/src/main/java/org/apache/doris/job/offset/s3/S3SourceOffsetProvider.java#L62-L67),
 
[master](https://github.com/apache/doris/blob/172120cbaa46b0a2eff6c37d86fb438c3a307375/fe/fe-core/src/main/java/org/apache/doris/job/offset/s3/S3SourceOffsetProvider.java#L78-L82)).
 `hasMoreDataToConsume()` keeps returning true for the same reason, and 
`CurrentOffset` shows nothing because it is rendered from the same null field.
   
   Two more consequences of the missing callbacks:
   
   - `PAUSE JOB` cancels the running task, but the check that turns a cancel 
into a refused commit lives in `beforeCommitted()`. A task whose BE fragments 
have already finished therefore still commits its Iceberg snapshot, which is 
the last observation above.
   - `beforeCommitted()` reads `LoadStatistic` from the `InsertLoadJob` 
registered in `LoadManager`. Only `OlapInsertExecutor` registers one 
(`needRegister = jobId != -1`); the external-table executors do not. The job's 
statistics never move either, and routing an external commit through the 
existing callback unchanged would fail with `load job not found`.
   
   `CREATE JOB ... ON STREAMING` never checks the target table type. 
`CreateJobInfo.analyzeAndCreateStreamingInsertJob()` only checks that the 
statement is an `InsertIntoTableCommand` and runs `initPlan()` 
([4.1.5](https://github.com/apache/doris/blob/917b20be83878e98f94e3ebf7efccc80346e3e50/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateJobInfo.java#L325-L349),
 
[master](https://github.com/apache/doris/blob/172120cbaa46b0a2eff6c37d86fb438c3a307375/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateJobInfo.java#L328-L351)).
 By the same code path, Hive, Paimon, JDBC and MaxCompute targets in 4.1, and 
every connector target on master, should loop the same way. Only Iceberg has 
been observed. The design in #56191 says the offset "is committed only after 
data is visible and durable in Doris", so external targets were never part of 
it, and no regression case covers them.
   
   #### Possible fix
   
   1. Short term, and for 4.1: reject targets other than internal OLAP tables 
in `CREATE JOB ... ON STREAMING`, and in `ALTER JOB` when it changes the SQL. 
Check it again when a task starts, so a job created before the fix pauses with 
a clear error instead of looping.
   2. To support Iceberg as a target, the external commit path has to do what 
the two callbacks do: check the cancel fence before the remote commit, then 
advance and persist the offset. Persisting the offset after a separate remote 
commit only gives at-least-once, because an FE crash between the Iceberg commit 
and the edit log replays the batch. For exactly-once, the job id and end offset 
can be written into the Iceberg snapshot summary in the same commit, and 
recovery reads the last committed offset back from the table's snapshots. The 
Flink Iceberg sink does this with its checkpoint id.
   
   #### Workaround
   
   Do not use a streaming job with an Iceberg target on 4.1.x. Pause or drop 
the job. The duplicate snapshots can be removed with `ALTER TABLE 
<iceberg_catalog>.<db>.<tbl> EXECUTE rollback_to_snapshot("snapshot_id" = 
"<last good snapshot id>")`. That also drops any other write made after that 
snapshot.
   
   ### Are you willing to submit PR?
   
   - [ ] Yes I am willing to submit a PR!
   
   ### Code of Conduct
   
   - [x] I agree to follow this project's [Code of 
Conduct](https://www.apache.org/foundation/policies/conduct)
   


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