morningman opened a new pull request, #69525:
URL: https://github.com/apache/doris/pull/69525
### What problem does this PR solve?
Issue Number: None
Related PR: #52971, #59990
Problem Summary:
**In short.** With batch edit log on (the default), a queued edit log entry
holds a reference to the live object, and the `EditLog-Flusher` thread
serializes it later without any lock. Since transaction persistence moved out
of the lock (#59990, `enable_txn_log_outside_lock`, also the default), other
threads keep changing the same `TransactionState` while its entry waits in the
queue. When such a change lands while the flusher iterates one of its maps, the
flusher gets a `ConcurrentModificationException`, treats it as a failed write
and exits the FE. This PR serializes every queued entry in the thread that
submits it, while that thread still holds the lock that guards the object,
makes the remaining lock-free writers of `TransactionState` take its monitor,
and stops the FE from serving for up to 300 seconds after such a failure.
**Background.**
- Batch edit log (#52971): `submitEdit` / `logEditWithQueue` put an
`EditLogItem(op, writable)` into a queue; `flushEditLog` drains it and calls
`JournalBatch.addJournal(op, writable)`, which is where the object is
serialized. The comment on `addJournal` says the data object is safe to change
once it returns, which holds for the thread that calls it, but for a queued
entry that thread is the flusher, at a time nobody controls.
- Outside-lock transaction persistence (#59990): commit, finish and abort
change the in-memory state and call `submitEdit` inside `synchronized
(transactionState)`, then release the monitor and `await()` the entry. The new
state (for example `COMMITTED`) is visible to other threads before the entry is
serialized.
- `PublishVersionDaemon` reads committed transactions without their monitor
(the code says so in `unprotectedCommitTransaction`) and writes
`tableIdToTabletDeltaRows` in `tryFinishOneTxn` (`HashMap.putAll`) every 10 ms
for every committed transaction, and clears it in `pruneAfterVisible`. The
version check of `finishTransaction` removes commit infos of dropped tables and
partitions outside the monitor. All of these maps are serialized fields of
`TransactionState`.
**The problem, and what it cost.**
- A P0 run of an unrelated PR (#69295, TeamCity build 1075936) lost its FE
at 17:40:26.930:
```
ERROR (EditLog-Flusher|77) [EditLog.flushEditLog():268] Fatal Error :
write stream Exception
java.util.ConcurrentModificationException
at java.util.HashMap$HashIterator.nextNode(HashMap.java:1597)
at java.util.HashMap$EntryIterator.next(HashMap.java:1630)
at
com.google.gson.internal.bind.MapTypeAdapterFactory$Adapter.write(MapTypeAdapterFactory.java:217)
...
at
org.apache.doris.transaction.TransactionState.write(TransactionState.java:781)
at org.apache.doris.journal.JournalEntity.write(JournalEntity.java:199)
at
org.apache.doris.journal.JournalBatch.addJournal(JournalBatch.java:64)
at org.apache.doris.persist.EditLog.flushEditLog(EditLog.java:231)
```
The iterated map is a direct `HashMap` field of `TransactionState`
(`idToTableCommitInfos`, `loadedTblIndexes` or `tableIdToTabletDeltaRows`). At
that moment two INSERT transactions (3956, 3957) still waited for their
`COMMITTED` entries (their threads were in `await()` in the jstack, and never
returned), yet `PublishVersionDaemon` had already sent their publish tasks
(26.907) and run `tryFinishOneTxn` on them, which starts with
`setTableIdToTabletDeltaRows` (26.918, 26.928).
- `System.exit(-1)` from the flusher runs the shutdown hook, and
`gracefulShutdown` waits up to 300 seconds for running queries. The FE kept
accepting connections for six minutes with a dead journal: every thread that
writes the edit log stayed blocked in `await()` with its catalog, database or
table locks held, so DDL failed with `Failed to acquire catalog lock`, loads
hung, and then 5180 cases failed with `Connection refused` (5187 failed, 986
passed).
- Even without an exception, the journal gets the state of the moment the
flusher reaches the object, not the one the producer committed: a `COMMITTED`
entry can carry publish-time delta rows or a later status.
- The race window is tiny, so the crash is rare; but it exists with the
default configuration, in production as well as in CI.
**How this PR fixes it.**
1. Serialize queued entries in the submitting thread.
`JournalBatch.serialize(op, data)` produces the entity bytes and
`JournalBatch.addEntity` adds them to a batch (`addJournal` is now the two in a
row). `submitEdit` and `logEditWithQueue` serialize before they queue the
entry, and `EditLogItem` carries the bytes instead of the object, so the
flusher never reads an object that may still change. The bytes written to BDBJE
do not change, so replay and compatibility are not affected. This covers every
queued operation, not only transactions; `MTMV` and table stream cleanup submit
entries the same way. A serialization failure exits the FE like any other write
failure, because the in-memory state has already changed.
2. Make the remaining lock-free writers of `TransactionState` take its
monitor: `setTableIdToTabletDeltaRows` and `pruneAfterVisible` are
`synchronized`, and `finishCheckPartitionVersion` /
`finishCheckPartitionVersionWithSubTxns` run their removals under `synchronized
(transactionState)` (the bodies moved unchanged into `unprotected...` methods).
With (1), commit and finish serialize under the same monitor, so these writes
cannot overlap the serialization.
3. Do not keep serving after the edit log fails. Every write failure path of
`EditLog` (flush, direct write, serialization, interrupted wait) goes through
`exitOnWriteFailure`, which records the failure before `System.exit(-1)`.
`DorisFE.gracefulShutdown` skips the wait for running queries when the edit log
can not be written.
4. Unit tests (below).
**Results.**
| Scenario | Before | After |
|---|---|---|
| Commit entry waits in the queue while the publish of the same transaction
updates its delta rows | the flusher may throw
`ConcurrentModificationException` and the FE exits | the entry was serialized
at submission; the flusher only writes bytes |
| What the journal records for a queued entry | the object as the flusher
finds it later (`testSubmitEditWritesStateAtSubmission` reads back `VISIBLE`
with delta rows on master) | the state at submission (`COMMITTED`, no delta
rows) |
| `setTableIdToTabletDeltaRows`, `pruneAfterVisible`, removal of dropped
tables in the version check | run without the monitor | wait for the monitor
(`testPublishUpdatesWaitForMonitor`,
`testFinishCheckPartitionVersionWaitsForMonitor`) |
| FE exits because the edit log can not be written, with queries running |
the shutdown hook waits up to 300 s while the FE keeps serving | the hook
returns at once (`testGracefulShutdownDoesNotWaitAfterEditLogWriteFailure`) |
**Classes, and how they call each other.**
- `JournalBatch`: `serialize` turns an operation into entity bytes;
`addEntity` adds those bytes to a batch.
- `EditLog`: `submitEdit` / `logEditWithQueue` serialize, then queue
`EditLogItem(op, entity)`; `flushEditLog` appends the entities and writes the
batch; `exitOnWriteFailure` / `isWriteFailed` record a write failure.
- `TransactionState`: the two publish-side writers take the monitor.
- `DatabaseTransactionMgr`: the version checks of finish take the monitor.
- `DorisFE`: `gracefulShutdown` returns at once after a write failure.
```
commit / finish / abort PublishVersionDaemon
synchronized (transactionState) { tryFinishOneTxn
change in-memory state
setTableIdToTabletDeltaRows (synchronized)
EditLog.submitEdit tryFinishTxnSync
JournalBatch.serialize <-- bytes taken pruneAfterVisible
(synchronized)
queue EditLogItem(op, entity)
}
item.await() EditLog-Flusher
flushEditLog
JournalBatch.addEntity(entity)
Journal.write(batch)
on failure:
exitOnWriteFailure -> System.exit
->
DorisFE.gracefulShutdown skips the wait
```
Not changed: the journal format, the order of entries,
`enable_batch_editlog` / `enable_txn_log_outside_lock`, and the replay paths.
### Release note
Fix a race in which the master FE could exit with `Fatal Error : write
stream Exception` (`ConcurrentModificationException`) while writing a
transaction to the edit log. After an edit log write failure, the FE now exits
without waiting for running queries.
### Check List (For Author)
- Test <!-- At least one of them must be included. -->
- [ ] Regression test
- [x] Unit Test
- [ ] Manual test (add detailed scripts or steps below)
- [ ] No need to test or manual test. Explain why:
- [ ] This is a refactor/code format and no logic has been changed.
- [ ] Previous test can cover this change.
- [ ] No code files have been changed.
- [ ] Other reason <!-- Add your reason? -->
- `run-fe-ut.sh` on this branch: 116 tests, 0 failures: `EditLogTest`
(8, two new), `TransactionStateTest` (5, two new), `DorisFETest` (1, new),
every test class in `org.apache.doris.transaction` (including
`DatabaseTransactionMgrTest` 35 and `GlobalTransactionMgrTest` 15),
`BDBJEJournalTest` (3, writes `JournalBatch` to a real BDBJE), and
`TableStreamManagerCleanupTest` (9) / `StreamingInsertJobSchemaPersistenceTest`
(14), which submit edits through `EditLogItem`. `mvn checkstyle:check -pl
fe-core`: 0 violations.
- The new tests fail without the fix. With the main code of this PR
reverted, both `EditLogTest` cases read back `VISIBLE` instead of `COMMITTED`,
and both `TransactionStateTest` cases report that the update did not wait for
the monitor. With only the new check in `gracefulShutdown` removed,
`DorisFETest` times out after 10 s.
- No regression test: the crash needs a write to land while the flusher
iterates a map, which a regression case can not arrange. The unit tests park
the flusher in a fake journal to make the ordering deterministic.
- Behavior changed:
- [ ] No.
- [x] Yes. Queued edit log entries are serialized in the submitting
thread instead of the flusher, so the journal records the state at submission.
After an edit log write failure the shutdown hook no longer waits for running
queries.
- Does this need documentation?
- [x] No.
- [ ] Yes. <!-- Add document PR link here. eg:
https://github.com/apache/doris-website/pull/1214 -->
### Check List (For Reviewer who merge this PR)
- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label <!-- Add branch pick label that this PR should
merge into -->
--
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]