This is an automated email from the ASF dual-hosted git repository.

vogievetsky pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git


The following commit(s) were added to refs/heads/master by this push:
     new 34049ff8389 fix: better query manager and allow setting cookie for dev 
forward (#19692)
34049ff8389 is described below

commit 34049ff83891ae93ca85090b369186347381d492
Author: Vadim Ogievetsky <[email protected]>
AuthorDate: Mon Jul 20 11:49:53 2026 -0700

    fix: better query manager and allow setting cookie for dev forward (#19692)
    
    * better query manager and allow setting cookie for dev forward
    
    * feedback fixes
---
 .../compaction-status/compaction-status.ts         |   2 +-
 .../supervisor-status/supervisor-status.ts         |  12 +
 .../src/utils/query-manager/query-manager.spec.ts  | 370 +++++++++++++++++++++
 .../src/utils/query-manager/query-manager.ts       |  22 +-
 .../views/supervisors-view/supervisors-view.tsx    |  19 +-
 web-console/webpack.config.mjs                     |  13 +-
 6 files changed, 426 insertions(+), 12 deletions(-)

diff --git 
a/web-console/src/druid-models/compaction-status/compaction-status.ts 
b/web-console/src/druid-models/compaction-status/compaction-status.ts
index 494918182d4..5712eb958b0 100644
--- a/web-console/src/druid-models/compaction-status/compaction-status.ts
+++ b/web-console/src/druid-models/compaction-status/compaction-status.ts
@@ -77,7 +77,7 @@ export function formatCompactionInfo(compaction: 
CompactionInfo) {
           return 'Fully compacted';
         }
       } else {
-        return capitalizeFirst(status.scheduleStatus);
+        return capitalizeFirst(status.scheduleStatus.replace(/_/g, ' '));
       }
     } else {
       return 'Awaiting first run';
diff --git 
a/web-console/src/druid-models/supervisor-status/supervisor-status.ts 
b/web-console/src/druid-models/supervisor-status/supervisor-status.ts
index 3004cf350e7..feb42a0a7f7 100644
--- a/web-console/src/druid-models/supervisor-status/supervisor-status.ts
+++ b/web-console/src/druid-models/supervisor-status/supervisor-status.ts
@@ -43,6 +43,18 @@ export interface SupervisorStatus {
     state: string;
     detailedState: string;
     recentErrors: SupervisorError[];
+
+    // autocompact
+    scheduleStatus: string;
+    bytesAwaitingCompaction: number;
+    bytesCompacted: number;
+    bytesSkipped: number;
+    segmentCountAwaitingCompaction: number;
+    segmentCountCompacted: number;
+    segmentCountSkipped: number;
+    intervalCountAwaitingCompaction: number;
+    intervalCountCompacted: number;
+    intervalCountSkipped: number;
   };
 }
 
diff --git a/web-console/src/utils/query-manager/query-manager.spec.ts 
b/web-console/src/utils/query-manager/query-manager.spec.ts
new file mode 100644
index 00000000000..05568a9c1a0
--- /dev/null
+++ b/web-console/src/utils/query-manager/query-manager.spec.ts
@@ -0,0 +1,370 @@
+/*
+ * 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.
+ */
+
+import { IntermediateQueryState } from './intermediate-query-state';
+import type { QueryManagerOptions } from './query-manager';
+import { QueryManager } from './query-manager';
+import { QueryState } from './query-state';
+import { ResultWithAuxiliaryWork } from './result-with-auxiliary-work';
+
+// jsdom's AbortSignal (used as the global in the jest environment) predates 
`throwIfAborted`,
+// which the QueryManager relies on. Polyfill it so the tests exercise real 
browser behavior.
+beforeAll(() => {
+  if (typeof AbortSignal.prototype.throwIfAborted !== 'function') {
+    AbortSignal.prototype.throwIfAborted = function throwIfAborted(this: 
AbortSignal) {
+      if (this.aborted) throw (this as any).reason ?? new Error('Aborted');
+    };
+  }
+});
+
+// All debounces and background delays are set to 0 so that `run()` executes 
synchronously
+// (the constructor wires the runWhen* helpers directly to `run` when the 
debounce is 0, and
+// every `wait(delay)` is guarded by `if (delay)`). That makes the manager 
fully driven by the
+// microtask queue, which we flush with `Promise.resolve()`.
+const ZERO_TIMING = {
+  debounceInit: 0,
+  debounceIdle: 0,
+  debounceLoading: 0,
+  backgroundStatusCheckInitDelay: 0,
+  backgroundStatusCheckDelay: 0,
+} as const;
+
+// Promise-returning helpers keep the test callbacks free of unnecessary 
`async` (which the
+// require-await lint rule rejects) while still satisfying the 
Promise-returning signatures.
+const resolved = <T>(value: T): Promise<T> => Promise.resolve(value);
+const rejected = (error: Error): Promise<never> => Promise.reject(error);
+
+interface Harness<Q, R, I = never, E extends Error = Error> {
+  manager: QueryManager<Q, R, I, E>;
+  states: QueryState<R, E, I>[];
+}
+
+function makeManager<Q, R, I = never, E extends Error = Error>(
+  options: Omit<QueryManagerOptions<Q, R, I, E>, 'onStateChange'> &
+    Partial<Pick<QueryManagerOptions<Q, R, I, E>, 'onStateChange'>>,
+): Harness<Q, R, I, E> {
+  const states: QueryState<R, E, I>[] = [];
+  const manager = new QueryManager<Q, R, I, E>({
+    ...ZERO_TIMING,
+    ...options,
+    onStateChange: state => states.push(state),
+  });
+  return { manager, states };
+}
+
+// Flushes microtasks until the latest recorded state past `fromIndex` is 
settled (data without
+// auxiliaryLoading, or an error). Returns that final state.
+async function settleAfter<R, E extends Error, I>(
+  states: QueryState<R, E, I>[],
+  fromIndex: number,
+): Promise<QueryState<R, E, I>> {
+  for (let i = 0; i < 500; i++) {
+    await Promise.resolve();
+    const last = states[states.length - 1];
+    if (
+      states.length > fromIndex &&
+      last &&
+      (last.isError() || (last.state === 'data' && !last.auxiliaryLoading))
+    ) {
+      return last;
+    }
+  }
+  throw new Error('query manager did not settle');
+}
+
+describe('QueryManager', () => {
+  describe('basic query lifecycle', () => {
+    it('transitions from loading to data', async () => {
+      const { manager, states } = makeManager<string, string>({
+        processQuery: query => resolved(`result:${query}`),
+      });
+
+      manager.runQuery('hello');
+      const final = await settleAfter(states, 0);
+
+      expect(states[0].isLoading()).toEqual(true);
+      expect(final.state).toEqual('data');
+      expect(final.data).toEqual('result:hello');
+      expect(manager.getState().data).toEqual('result:hello');
+      expect(manager.getLastQuery()).toEqual('hello');
+    });
+
+    it('passes the query through to processQuery', async () => {
+      const seen: string[] = [];
+      const { manager, states } = makeManager<string, number>({
+        processQuery: query => {
+          seen.push(query);
+          return resolved(query.length);
+        },
+      });
+
+      manager.runQuery('abcd');
+      await settleAfter(states, 0);
+
+      expect(seen).toEqual(['abcd']);
+      expect(manager.getState().data).toEqual(4);
+    });
+
+    it('captures errors thrown by processQuery into an error state', async () 
=> {
+      const { manager, states } = makeManager<string, string>({
+        processQuery: () => rejected(new Error('boom')),
+      });
+
+      manager.runQuery('x');
+      const final = await settleAfter(states, 0);
+
+      expect(final.isError()).toEqual(true);
+      expect(final.getErrorMessage()).toEqual('boom');
+    });
+
+    it('keeps prior data as lastData on a subsequent run', async () => {
+      const { manager, states } = makeManager<string, string>({
+        processQuery: query => resolved(`result:${query}`),
+      });
+
+      manager.runQuery('one');
+      await settleAfter(states, 0);
+
+      const start = states.length;
+      manager.runQuery('two');
+      // While loading the second query, the previous data is exposed as 
lastData.
+      expect(states[start].isLoading()).toEqual(true);
+      expect(states[start].lastData).toEqual('result:one');
+
+      const final = await settleAfter(states, start);
+      expect(final.data).toEqual('result:two');
+    });
+  });
+
+  describe('query superseding', () => {
+    it('keeps only the newest query result when a second query is issued 
mid-flight', async () => {
+      let resolveFirst: ((value: string) => void) | undefined;
+      const { manager, states } = makeManager<string, string>({
+        processQuery: query =>
+          query === 'first'
+            ? new Promise<string>(resolve => {
+                resolveFirst = resolve;
+              })
+            : Promise.resolve(`result:${query}`),
+      });
+
+      manager.runQuery('first');
+      manager.runQuery('second');
+
+      const final = await settleAfter(states, 0);
+      expect(final.data).toEqual('result:second');
+
+      // Even if the stale first query resolves later, it must not overwrite 
the state.
+      resolveFirst?.('result:first');
+      await Promise.resolve();
+      await Promise.resolve();
+      expect(manager.getState().data).toEqual('result:second');
+    });
+  });
+
+  describe('intermediate query state (backgroundStatusCheck)', () => {
+    it('loops through intermediate states until a final result is produced', 
async () => {
+      // delay: 0 on the IntermediateQueryState skips the real setTimeout wait 
(the
+      // backgroundStatusCheck*Delay defaults can't be zeroed since `0 || 500` 
is 500).
+      const { manager, states } = makeManager<string, string, string>({
+        processQuery: () => resolved(new IntermediateQueryState('pending', 0)),
+        backgroundStatusCheck: state =>
+          resolved(state === 'pending' ? new IntermediateQueryState('almost', 
0) : 'done'),
+      });
+
+      manager.runQuery('go');
+      const final = await settleAfter(states, 0);
+
+      const intermediates = states
+        .filter(s => s.isLoading())
+        .map(s => s.intermediate)
+        .filter(Boolean);
+      expect(intermediates).toEqual(['pending', 'almost']);
+      expect(final.data).toEqual('done');
+    });
+
+    it('errors when an intermediate state is returned without a 
backgroundStatusCheck', async () => {
+      const { manager, states } = makeManager<string, string, string>({
+        processQuery: () => resolved(new IntermediateQueryState('pending')),
+      });
+
+      manager.runQuery('go');
+      const final = await settleAfter(states, 0);
+
+      expect(final.isError()).toEqual(true);
+      expect(final.getErrorMessage()).toContain('backgroundStatusCheck must be 
set');
+    });
+
+    it('swallows recoverable background errors and surfaces them as 
intermediateError', async () => {
+      let checks = 0;
+      const { manager, states } = makeManager<string, string, string>({
+        processQuery: () => resolved(new IntermediateQueryState('pending', 0)),
+        backgroundStatusCheck: () => {
+          checks++;
+          return checks === 1 ? rejected(new Error('transient')) : 
resolved('done');
+        },
+        swallowBackgroundError: e => e.message === 'transient',
+      });
+
+      manager.runQuery('go');
+      const final = await settleAfter(states, 0);
+
+      const withError = states.find(s => s.intermediateError);
+      expect(withError?.intermediateError?.message).toEqual('transient');
+      expect(final.data).toEqual('done');
+    });
+  });
+
+  describe('ResultWithAuxiliaryWork', () => {
+    interface Enriched {
+      base: number;
+      a?: number;
+      b?: number;
+    }
+
+    const makeAuxProcessQuery = () => (): 
Promise<ResultWithAuxiliaryWork<Enriched>> =>
+      resolved(
+        new ResultWithAuxiliaryWork<Enriched>({ base: 1 }, [
+          result => resolved({ ...result, a: 2 }),
+          result => resolved({ ...result, b: 3 }),
+        ]),
+      );
+
+    it('unwraps a result that has no auxiliary queries', async () => {
+      const { manager, states } = makeManager<string, Enriched>({
+        processQuery: () => resolved(new ResultWithAuxiliaryWork<Enriched>({ 
base: 1 }, [])),
+      });
+
+      manager.runQuery('go');
+      const final = await settleAfter(states, 0);
+
+      expect(final.data).toEqual({ base: 1 });
+      expect(states.some(s => s.auxiliaryLoading)).toEqual(false);
+    });
+
+    it('enriches incrementally on a foreground run', async () => {
+      const { manager, states } = makeManager<string, Enriched>({
+        processQuery: makeAuxProcessQuery(),
+      });
+
+      manager.runQuery('go');
+      const final = await settleAfter(states, 0);
+
+      // The base result and the first auxiliary result are published with 
auxiliaryLoading: true.
+      const auxStates = states.filter(s => s.auxiliaryLoading);
+      expect(auxStates.map(s => s.data)).toEqual([{ base: 1 }, { base: 1, a: 2 
}]);
+
+      // The final state is fully enriched and no longer auxiliary-loading.
+      expect(final.auxiliaryLoading).toBeFalsy();
+      expect(final.data).toEqual({ base: 1, a: 2, b: 3 });
+    });
+
+    it('does NOT enrich incrementally on a background run', async () => {
+      const { manager, states } = makeManager<string, Enriched>({
+        processQuery: makeAuxProcessQuery(),
+      });
+
+      // Initial foreground run so there is a lastQuery to rerun.
+      manager.runQuery('go');
+      await settleAfter(states, 0);
+
+      const start = states.length;
+      manager.rerunLastQuery(true);
+      const final = await settleAfter(states, start);
+
+      // The background run emits exactly one state: the final, fully 
enriched, non-loading result.
+      const backgroundStates = states.slice(start);
+      expect(backgroundStates).toHaveLength(1);
+      expect(backgroundStates[0].auxiliaryLoading).toBeFalsy();
+      expect(final.data).toEqual({ base: 1, a: 2, b: 3 });
+    });
+
+    it('enriches incrementally on a foreground rerun', async () => {
+      const { manager, states } = makeManager<string, Enriched>({
+        processQuery: makeAuxProcessQuery(),
+      });
+
+      manager.runQuery('go');
+      await settleAfter(states, 0);
+
+      const start = states.length;
+      manager.rerunLastQuery(); // foreground (default)
+      await settleAfter(states, start);
+
+      const auxStates = states.slice(start).filter(s => s.auxiliaryLoading);
+      expect(auxStates.map(s => s.data)).toEqual([{ base: 1 }, { base: 1, a: 2 
}]);
+    });
+  });
+
+  describe('intermediate query tracking', () => {
+    it('records the last intermediate query set via the extra callback', async 
() => {
+      const { manager, states } = makeManager<string, string>({
+        processQuery: (query, _signal, extra) => {
+          extra.setIntermediateQuery(`intermediate:${query}`);
+          return resolved(`result:${query}`);
+        },
+      });
+
+      manager.runQuery('go');
+      await settleAfter(states, 0);
+
+      expect(manager.getLastIntermediateQuery()).toEqual('intermediate:go');
+    });
+  });
+
+  describe('control methods', () => {
+    it('reset returns the manager to the init state', async () => {
+      const { manager, states } = makeManager<string, string>({
+        processQuery: query => resolved(`result:${query}`),
+      });
+
+      manager.runQuery('go');
+      await settleAfter(states, 0);
+
+      manager.reset();
+      expect(manager.getState().isInit()).toEqual(true);
+    });
+
+    it('terminate stops further state changes and ignores new queries', async 
() => {
+      const { manager, states } = makeManager<string, string>({
+        processQuery: query => resolved(`result:${query}`),
+      });
+
+      manager.runQuery('go');
+      await settleAfter(states, 0);
+
+      const countBefore = states.length;
+      manager.terminate();
+      expect(manager.isTerminated()).toEqual(true);
+
+      manager.runQuery('again');
+      await Promise.resolve();
+      await Promise.resolve();
+      expect(states.length).toEqual(countBefore);
+    });
+
+    it('initialises from a provided initState', () => {
+      const { manager } = makeManager<string, string>({
+        initState: new QueryState<string>({ data: 'seed' }),
+        processQuery: query => resolved(`result:${query}`),
+      });
+
+      expect(manager.getState().data).toEqual('seed');
+    });
+  });
+});
diff --git a/web-console/src/utils/query-manager/query-manager.ts 
b/web-console/src/utils/query-manager/query-manager.ts
index c802b8014ce..fc1dba726d1 100644
--- a/web-console/src/utils/query-manager/query-manager.ts
+++ b/web-console/src/utils/query-manager/query-manager.ts
@@ -78,6 +78,7 @@ export class QueryManager<Q, R, I = never, E extends Error = 
Error> {
   private readonly swallowBackgroundError?: (e: Error) => boolean;
 
   private terminated = false;
+  private runInBackground = false;
   private nextQuery: Q | undefined;
   private lastQuery: Q | undefined;
   private lastIntermediateQuery: any;
@@ -124,6 +125,7 @@ export class QueryManager<Q, R, I = never, E extends Error 
= Error> {
   private async run(): Promise<void> {
     this.lastQuery = this.nextQuery;
     if (typeof this.lastQuery === 'undefined') return;
+    const background = this.runInBackground;
     this.currentQueryId++;
     const myQueryId = this.currentQueryId;
 
@@ -266,13 +268,15 @@ export class QueryManager<Q, R, I = never, E extends 
Error = Error> {
       const numAuxiliaryQueries = auxiliaryQueries.length;
       data = data.result;
 
-      this.setState(
-        new QueryState<R, E>({
-          data,
-          auxiliaryLoading: true,
-          lastData,
-        }),
-      );
+      if (!background) {
+        this.setState(
+          new QueryState<R, E>({
+            data,
+            auxiliaryLoading: true,
+            lastData,
+          }),
+        );
+      }
 
       try {
         for (let i = 0; i < numAuxiliaryQueries; i++) {
@@ -282,7 +286,7 @@ export class QueryManager<Q, R, I = never, E extends Error 
= Error> {
           data = await auxiliaryQueries[i](data, signal);
 
           if (this.currentQueryId !== myQueryId) return;
-          if (i < numAuxiliaryQueries - 1) {
+          if (!background && i < numAuxiliaryQueries - 1) {
             // Update data in intermediate state
             this.setState(
               new QueryState<R, E>({
@@ -307,6 +311,7 @@ export class QueryManager<Q, R, I = never, E extends Error 
= Error> {
   }
 
   private trigger() {
+    this.runInBackground = false;
     if (this.currentRunCancelFn && !this.state.auxiliaryLoading) {
       // Currently loading main query
       void this.runWhenLoading();
@@ -337,6 +342,7 @@ export class QueryManager<Q, R, I = never, E extends Error 
= Error> {
     if (runInBackground && this.currentRunCancelFn) return;
     this.nextQuery = this.lastQuery;
     if (runInBackground) {
+      this.runInBackground = true;
       void this.runWhenIdle();
     } else {
       this.trigger();
diff --git a/web-console/src/views/supervisors-view/supervisors-view.tsx 
b/web-console/src/views/supervisors-view/supervisors-view.tsx
index 31a64f1bf0c..544b1b99f18 100644
--- a/web-console/src/views/supervisors-view/supervisors-view.tsx
+++ b/web-console/src/views/supervisors-view/supervisors-view.tsx
@@ -48,6 +48,7 @@ import {
   TaskGroupHandoffDialog,
 } from '../../dialogs';
 import type {
+  CompactionConfig,
   ConsoleViewId,
   IngestionSpec,
   QueryWithContext,
@@ -56,7 +57,11 @@ import type {
   SupervisorStatus,
   SupervisorStatusTask,
 } from '../../druid-models';
-import { getConsoleViewIcon, getTotalSupervisorStats } from 
'../../druid-models';
+import {
+  formatCompactionInfo,
+  getConsoleViewIcon,
+  getTotalSupervisorStats,
+} from '../../druid-models';
 import type { Capabilities } from '../../helpers';
 import {
   SMALL_TABLE_PAGE_SIZE,
@@ -357,7 +362,10 @@ export class SupervisorsView extends React.PureComponent<
               return {
                 supervisor_id: deepGet(sup, 'id'),
                 datasource: deepGet(sup, 'dataSource'),
-                type: deepGet(sup, 'spec.tuningConfig.type'),
+                type:
+                  deepGet(sup, 'spec.type') ||
+                  deepGet(sup, 'spec.ioConfig.type') ||
+                  deepGet(sup, 'spec.tuningConfig.type'),
                 source:
                   deepGet(sup, 'spec.ioConfig.topic') ||
                   deepGet(sup, 'spec.ioConfig.stream') ||
@@ -1060,6 +1068,13 @@ export class SupervisorsView extends React.PureComponent<
                   aggregateLag,
                 )}`}</span>
               ) : null;
+            } else if (original.type === 'autocompact') {
+              const compactionConfig: CompactionConfig | undefined = 
original.spec?.spec;
+              if (!supervisorStatusPayload || !compactionConfig) return null;
+              return formatCompactionInfo({
+                status: supervisorStatusPayload,
+                config: compactionConfig,
+              });
             } else {
               return null;
             }
diff --git a/web-console/webpack.config.mjs b/web-console/webpack.config.mjs
index a1108f807bb..17cfb57a64a 100644
--- a/web-console/webpack.config.mjs
+++ b/web-console/webpack.config.mjs
@@ -43,10 +43,13 @@ export default env => {
   if (!druidUrl.startsWith('http')) {
     druidUrl = (druidUrl.endsWith(':9088') ? 'https://' : 'http://') + 
druidUrl;
   }
-  if (!/:\d+$/.test(druidUrl)) {
+  if (druidUrl.endsWith('localhost')) {
     druidUrl += druidUrl.startsWith('https://') ? ':9088' : ':8888';
   }
 
+  // Optional cookie to add to all proxied requests, e.g. 
druid_cookie='name=value'
+  const druidCookie = (env || {}).druid_cookie || process.env.druid_cookie;
+
   const mode = process.env.NODE_ENV === 'production' ? 'production' : 
'development';
 
   // eslint-disable-next-line no-undef
@@ -111,6 +114,14 @@ export default env => {
           context: ['/status', '/druid', '/proxy'],
           target: druidUrl,
           secure: false,
+          changeOrigin: true,
+          onProxyReq: (proxyReq, _req) => {
+            if (druidCookie) {
+              proxyReq.setHeader('Cookie', druidCookie);
+            }
+            // To debug use:
+            // console.log(`[proxy] ${req.method} ${req.url} -> 
${proxyReq.path}`);
+          },
         },
       ],
     },


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

Reply via email to