morningman commented on PR #66312:
URL: https://github.com/apache/doris/pull/66312#issuecomment-5162228826

   ## 1. [High] Replace the permanent terminal flag with bounded backoff, or 
drop `isProfileLoadFailed` entirely
   
   **Why this matters.**
   
   The `profileIOExecutor` pool is created with `BlockedPolicy(poolName, 60)` 
(`ThreadPoolManager.java:140-145`). When the pool saturates, `submit()` blocks 
for up to 60 seconds and then throws `RejectedExecutionException`. In 
`loadProfilesFromStorage()`, the submit call is not wrapped in a try/catch:
   
   ```java
   // ProfileManager.java:694-701
   for (String profileDirAbsPath : batch) {
       profileIOFutures.add(profileIOExecutor.submit(() -> {   // can throw 
RejectedExecutionException
           Profile profile = Profile.read(profileDirAbsPath);
           ...
       }));
   }
   ```
   
   So the exception escapes to the outer `catch (Exception)` in `loadTask`, 
`loadSucceeded` stays `false`, and `isProfileLoadFailed` is latched 
permanently. In other words, **the saturation scenario this PR sets out to fix 
is exactly the scenario that trips the "never retry" branch.**
   
   From that point on, for the lifetime of the FE process:
   
   - `isProfileLoaded` stays `false`;
   - `deleteBrokenProfiles()` (`ProfileManager.java:953`) and 
`deleteOutdatedProfilesFromStorage()` (`ProfileManager.java:857`) both return 
early — permanently no-ops;
   - but `writeProfileToStorage()` (called at `ProfileManager.java:559`) has 
**no** `isProfileLoaded` gate and keeps writing new profiles to disk every tick.
   
   The net effect is that `Config.max_spilled_profile_num` and 
`Config.spilled_profile_storage_limit_bytes` — enforced only in 
`getProfilesToBeRemoved()` (`ProfileManager.java:840-841`) — stop being 
enforced until the FE restarts, and the profile directory grows without bound. 
Before this PR the retry loop leaked threads, but it also meant a transient 
failure could self-heal.
   
   The key observation: **the `isProfileLoading` CAS guard already fixes the 
thread leak completely.** With it, at most one loader exists at any moment; 
even if every load fails, the result is one short-lived thread per tick that 
exits normally — no accumulation. `isProfileLoadFailed` adds nothing to the 
leak fix and contributes only the regression risk above.
   
   **Option A (simplest — worth evaluating first).** Drop `isProfileLoadFailed` 
and keep only the CAS guard. Failures retry once per tick, serially, with no 
leak, and the system self-heals. The cost is one ERROR log per second while 
failing, which a failure counter plus rate-limited logging solves.
   
   **Option B (conservative).** Bounded exponential backoff, preserving 
self-healing:
   
   ```java
   private final AtomicLong nextRetryTimeMs = new AtomicLong(0);
   private final AtomicInteger consecutiveFailures = new AtomicInteger(0);
   private static final int MAX_LOAD_RETRY = 10;
   
   // entry
   if (checkIfProfileLoaded() || consecutiveFailures.get() >= MAX_LOAD_RETRY
           || System.currentTimeMillis() < nextRetryTimeMs.get()) {
       return;
   }
   
   // failure branch in finally
   int failures = consecutiveFailures.incrementAndGet();
   // 1s, 2s, 4s ... capped at 5 minutes
   long backoffMs = Math.min(1000L << Math.min(failures, 8), 300_000L);
   nextRetryTimeMs.set(System.currentTimeMillis() + backoffMs);
   LOG.warn("Profile cold load failed, attempt={}, nextRetryInMs={}, 
loadedProfileCount={}",
           failures, backoffMs, queryIdToProfileMap.size());
   ```
   
   Either way, please spell out the blast radius in the PR description. It 
currently says only "will not be retried until the FE restarts" and does not 
mention that this also disables disk quota enforcement.
   
   ## 2. [Medium] Bound the wait in `waitForProfileLoadFinish()`
   
   ```java
   // ProfileManager.java:729-739
   private void waitForProfileLoadFinish() {
       while (isProfileLoading.get() && !checkIfProfileLoaded()) {
           Thread.sleep(100);
           ...
       }
   }
   ```
   
   There is no upper bound. If the in-flight loader is stuck in `submit()` (up 
to 60 s per call under `BlockedPolicy`) or in `future.get()` (unbounded), a 
synchronous caller blocks indefinitely. The old `loadThread.join()` was equally 
unbounded, so this is not a regression — but it is precisely the scenario this 
PR targets, so a bound belongs here.
   
   Note that `sync == true` currently has no production caller (a repo-wide 
grep finds only `ProfileManager.java:558` passing `false`; every other call 
site is in the test file), so there is no live impact today. The method is 
`protected` though, and the synchronous contract is part of the API surface.
   
   ```java
   private void waitForProfileLoadFinish(long timeoutMs) {
       long deadline = System.currentTimeMillis() + timeoutMs;
       while (isProfileLoading.get() && !checkIfProfileLoaded()) {
           if (System.currentTimeMillis() >= deadline) {
               LOG.warn("Timed out waiting for in-flight profile load, 
timeoutMs={}", timeoutMs);
               return;
           }
           try {
               Thread.sleep(100);
           } catch (InterruptedException e) {
               Thread.currentThread().interrupt();
               LOG.warn("Interrupted while waiting for profile loading to 
finish", e);
               return;
           }
       }
   }
   ```
   
   ## 3. [Medium] Collapse the three flags into a single state machine
   
   `isProfileLoaded` (a `volatile boolean` additionally guarded by a read-write 
lock), `isProfileLoading`, and `isProfileLoadFailed` together encode one state 
machine using two different synchronization mechanisms, leaving the reader to 
work out which flag combinations are legal. Collapsing them into an enum 
reduces the entry's three-step "pre-check + CAS + recheck" dance to a single 
CAS:
   
   ```java
   private enum LoadState { NOT_LOADED, LOADING, LOADED, FAILED }
   private final AtomicReference<LoadState> loadState = new 
AtomicReference<>(LoadState.NOT_LOADED);
   
   protected void loadProfilesFromStorageIfFirstTime(boolean sync) {
       if (!loadState.compareAndSet(LoadState.NOT_LOADED, LoadState.LOADING)) {
           if (sync && loadState.get() == LoadState.LOADING) {
               waitForProfileLoadFinish(WAIT_TIMEOUT_MS);
           }
           return;
       }
       ...
   }
   ```
   
   A single CAS needs no recheck, so the explanatory comment at 
`ProfileManager.java:620-623` disappears along with the code it justifies, and 
illegal states are ruled out at the type level.
   
   This also lets you remove `isProfileLoadedLock`: guarding a single `volatile 
boolean` with a `ReentrantReadWriteLock` is redundant (a pre-existing issue), 
and the comment at `ProfileManager.java:126-127` — "no further write operation, 
so no data race" — is now stale given the new `LOADING`/`FAILED` states.
   
   One caveat: `isProfileLoaded` is package-private and read/written directly 
by tests, so the refactor needs matching test updates.
   
   ## 4. [Medium] Close the gap in "a partial index must not enable destructive 
cleanup"
   
   The intent of the second commit is right, but `Profile.read()` swallows 
every exception:
   
   ```java
   // Profile.java, read(String path)
   } catch (Exception exception) {
       LOG.error("read profile failed", exception);
       return null;
   }
   ```
   
   A transient read failure — EIO, a permission problem, a file caught 
half-written by a concurrent `writeProfileToStorage()` — therefore yields 
`null`, the profile is silently skipped, `future.get()` does not throw, and 
`loadSucceeded` remains `true`. `markProfileLoaded()` runs, and on the next 
tick `deleteBrokenProfiles()` deletes those still-valid files.
   
   So the invariant the comments promise holds only for exceptions that escape 
to the outer `try`. Because both `Profile.read()` and 
`getOnStorageProfileInfos()` have their own catch-all handlers, that escape 
path is narrow — in practice only a throwing `pushProfile()` and a rejected 
`submit()`, and the new test has to override `pushProfile` to reach it at all.
   
   The minimal improvement is to count read failures and feed them into the 
decision:
   
   ```java
   int readFailures = 0;
   for (Future<?> future : profileIOFutures) {
       try {
           future.get();
       } catch (Exception e) {
           readFailures++;
           LOG.warn("Failed to read profile from storage", e);
       }
   }
   ```
   
   The deeper problem is that `Profile.read()` collapses "the file is genuinely 
malformed" (should be deleted) and "a transient IO error occurred" (must not be 
deleted) into the same `null`. The proper fix is for `Profile.read()` to throw 
on IO-class exceptions and return `null` only for format errors, so callers can 
distinguish them. That is beyond this PR's scope and could be a follow-up issue 
— but please at least document the current boundary in a comment, so future 
readers do not assume `isProfileLoaded == true` implies a complete index.
   
   ## 5. [Medium] Make the failure test actually verify "no retry"
   
   `testLoadProfilesFromStorageFailure` currently asserts, on the second 
invocation:
   
   ```java
   pm.loadProfilesFromStorageIfFirstTime(true);
   Assertions.assertFalse(pm.isProfileLoaded);
   ```
   
   This assertion holds even if `isProfileLoadFailed` is deleted entirely — the 
second load would simply fail again and leave the flag `false`. The test does 
not exercise the property it is meant to protect. Suggested rewrite:
   
   ```java
   // A second trigger must not enter the load body at all
   pushProfileEntered.set(false);
   pm.loadProfilesFromStorageIfFirstTime(true);
   Assertions.assertFalse(pushProfileEntered.get(),
           "a terminated load must not be restarted by a later trigger");
   Assertions.assertFalse(pm.isProfileLoaded);
   ```
   
   Please also assert the invariant the second commit really cares about — that 
stored files survive a failed load:
   
   ```java
   File[] filesBefore = tempDir.listFiles();
   Assertions.assertEquals(1, filesBefore.length);
   pm.deleteBrokenProfiles();
   pm.deleteOutdatedProfilesFromStorage();
   Assertions.assertEquals(1, tempDir.listFiles().length,
           "a failed load must not enable destructive cleanup of valid stored 
profiles");
   ```
   
   ## 6. [Low] Add observability for the terminal-failure state
   
   The WARN at `ProfileManager.java:649` fires exactly once. An operator who 
misses that single line has no way to discover that profile disk cleanup is 
permanently disabled on that FE. Consider exposing a gauge metric (e.g. 
`doris_fe_profile_load_state`), or re-emitting the warning at a low rate (say 
every 10 minutes) from `runAfterCatalogReady()`.
   
   If suggestion 1 is adopted, this drops in priority — backoff retries log 
periodically by construction.
   
   ## 7. [Low] Two small logging adjustments
   
   The repo's `AGENTS.md` has no Logging Standards section, so these follow 
general conventions and the surrounding code:
   
   - `ProfileManager.java:649`: `Loaded profile count in memory: {}` does not 
follow the `key=value` style used elsewhere. Prefer `loadedProfileCount={}`.
   - `ProfileManager.java:638` (ERROR) and `:649` (WARN) describe the same 
event at two different levels. Merging them into one record — exception plus 
the "will not retry" conclusion together — makes alert rules easier to write.
   
   Separately, `ProfileManager.java:716` (`"Processed batch {} - {} of {} 
profiles"`) fires once per 10 profiles, since `BATCH_SIZE = 10` 
(`ProfileManager.java:77`). With 5000 stored profiles, that is 500 INFO lines 
on every FE start. This is pre-existing, but since the method was extracted 
anyway, demoting it to DEBUG or replacing it with a single summary after the 
load completes is a cheap win.
   
   ## 8. [Low] `ProfileManager` instances and thread pools in tests
   
   The five new `new ProfileManager()` instances (the file had one before this 
PR) each construct a `fetch-realtime-profile-pool` (10 threads) and a 
`profile-io-thread-pool` (≥20 threads) that are never shut down — roughly 150 
extra live threads in the FE unit-test JVM. `ThreadPoolManager`'s class javadoc 
also states that "the thread pool name in fe must be unique", and duplicate 
instances overwrite each other's entries in `nameToThreadPoolMap`. Harmless in 
tests, but worth tightening. Consider adding a `@VisibleForTesting shutdown()` 
to `ProfileManager` and calling it from `@AfterEach`.
   
   ## 9. [Low] Clean up the remaining `new Thread(...)` + `submit()` 
anti-pattern
   
   This PR already fixed this pattern on the cold-load path (constructing a 
`Thread` object purely to use it as a `Runnable`), but two instances remain in 
the same file:
   
   - `deleteBrokenProfiles()`, `ProfileManager.java:961`
   - `writeProfileToStorage()`, inside the `for (ProfileElement profileElement 
: profilesToBeStored)` loop
   
   Switching them to plain lambdas is a zero-risk consistency cleanup, in line 
with the `AGENTS.md` guidance to follow existing similar code in similar 
contexts. Whether to fold this into the current PR or a follow-up is the 
author's call.
   
   ---
   
   ## Additional Test Gaps
   
   Beyond suggestion 5, the following are uncovered:
   
   - The `loadThread.start()` failure path (`ProfileManager.java:664-673`). 
Genuinely hard to construct — acceptable to skip.
   - The recheck-after-CAS path (`ProfileManager.java:624-627`).
   - `testWaitForProfileLoadInterrupted` does not assert that the interrupt 
flag was preserved, even though the code restores it via 
`Thread.currentThread().interrupt()`.
   - No test confirms the loader thread is actually a daemon.
   
   ---
   
   ## Summary
   
   The direction is right: the CAS guard genuinely fixes the thread leak 
described in issue #66311, and both the code organization and the test quality 
are above average.
   
   **Suggestion 1 is worth resolving before merge.** The permanent 
`isProfileLoadFailed` latch is triggered by exactly the thread-pool-saturation 
scenario this PR targets, and once latched, profile disk quotas stop being 
enforced until the FE restarts — trading a thread leak for a disk leak. Since 
the flag contributes nothing to the thread-leak fix, removing it or converting 
it to bounded backoff is a small change.
   
   Suggestions 2 through 5 are worth handling in this PR; the rest are optional.


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