From cb09b572e620aac83ed02c8e3a1832dcf68771fb Mon Sep 17 00:00:00 2001 From: zachjsh Date: Thu, 8 Aug 2024 21:21:03 -0400 Subject: [PATCH 01/99] Fix Druid table schema resolution when table defined in catalog and has schema manager (#16869) * SQL syntax error should target USER persona * * revert change to queryHandler and related tests, based on review comments * * add test * Properly handle Druid schema blending with catalog definition and segment metadata * * add javadocs --- .../apache/druid/sql/calcite/schema/DruidSchema.java | 12 +++++++++--- .../druid/sql/calcite/schema/DruidSchemaManager.java | 11 ++++++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/sql/src/main/java/org/apache/druid/sql/calcite/schema/DruidSchema.java b/sql/src/main/java/org/apache/druid/sql/calcite/schema/DruidSchema.java index 4e8431cd5c6..5a8ae5ba9a8 100644 --- a/sql/src/main/java/org/apache/druid/sql/calcite/schema/DruidSchema.java +++ b/sql/src/main/java/org/apache/druid/sql/calcite/schema/DruidSchema.java @@ -22,6 +22,7 @@ package org.apache.druid.sql.calcite.schema; import org.apache.calcite.schema.Table; import org.apache.druid.sql.calcite.planner.CatalogResolver; import org.apache.druid.sql.calcite.table.DatasourceTable; +import org.apache.druid.sql.calcite.table.DruidTable; import javax.inject.Inject; import java.util.Set; @@ -56,11 +57,16 @@ public class DruidSchema extends AbstractTableSchema @Override public Table getTable(String name) { - if (druidSchemaManager != null) { - return druidSchemaManager.getTable(name); - } else { + DruidTable schemaMgrTable = null; + DruidTable catalogTable = catalogResolver.resolveDatasource(name, null); + if (catalogTable == null && druidSchemaManager != null) { + schemaMgrTable = druidSchemaManager.getTable(name, segmentMetadataCache); + } + if (schemaMgrTable == null) { DatasourceTable.PhysicalDatasourceMetadata dsMetadata = segmentMetadataCache.getDatasource(name); return catalogResolver.resolveDatasource(name, dsMetadata); + } else { + return schemaMgrTable; } } diff --git a/sql/src/main/java/org/apache/druid/sql/calcite/schema/DruidSchemaManager.java b/sql/src/main/java/org/apache/druid/sql/calcite/schema/DruidSchemaManager.java index c203ab18825..85808bb1af4 100644 --- a/sql/src/main/java/org/apache/druid/sql/calcite/schema/DruidSchemaManager.java +++ b/sql/src/main/java/org/apache/druid/sql/calcite/schema/DruidSchemaManager.java @@ -21,6 +21,7 @@ package org.apache.druid.sql.calcite.schema; import org.apache.druid.guice.annotations.ExtensionPoint; import org.apache.druid.guice.annotations.UnstableApi; +import org.apache.druid.sql.calcite.planner.CatalogResolver; import org.apache.druid.sql.calcite.table.DruidTable; import java.util.Map; @@ -29,7 +30,10 @@ import java.util.Set; /** * This interface provides a map of datasource names to {@link DruidTable} * objects, used by the {@link DruidSchema} class as the SQL planner's - * view of Druid datasource schemas. If a non-default implementation is + * view of Druid datasource schemas. If a mapping is found for the + * datasource name in {@link CatalogResolver}, then it is preferred during + * resolution time, to the entry found in this manager. See + * {@link DruidSchema#getTable(String)}. If a non-default implementation is * provided, the segment metadata polling-based view of the Druid tables * will not be built in DruidSchema. */ @@ -56,6 +60,11 @@ public interface DruidSchemaManager return getTables().get(name); } + default DruidTable getTable(String name, BrokerSegmentMetadataCache segmentMetadataCache) + { + return getTables().get(name); + } + default Set getTableNames() { return getTables().keySet(); From 3d6cedb25fd81f2415b4f46b025f3624e8501550 Mon Sep 17 00:00:00 2001 From: Akshat Jain Date: Fri, 9 Aug 2024 11:39:53 +0530 Subject: [PATCH 02/99] Fix IndexOutOfBoundsException for MSQ window function queries with empty RAC (#16865) * Fix IndexOutOfBoundsException for MSQ window function queries with empty RAC --- .../rowsandcols/ConcatRowsAndColumns.java | 9 +++ .../rowsandcols/ConcatRowsAndColumnsTest.java | 57 +++++++++++++++++++ .../sql/calcite/DrillWindowQueryTest.java | 7 +++ .../empty_over_clause/single_empty_over_3.e | 0 .../empty_over_clause/single_empty_over_3.q | 4 ++ 5 files changed, 77 insertions(+) create mode 100644 sql/src/test/resources/drill/window/queries/druid_queries/empty_over_clause/single_empty_over_3.e create mode 100644 sql/src/test/resources/drill/window/queries/druid_queries/empty_over_clause/single_empty_over_3.q diff --git a/processing/src/main/java/org/apache/druid/query/rowsandcols/ConcatRowsAndColumns.java b/processing/src/main/java/org/apache/druid/query/rowsandcols/ConcatRowsAndColumns.java index 523a982fbaf..c6ced60849d 100644 --- a/processing/src/main/java/org/apache/druid/query/rowsandcols/ConcatRowsAndColumns.java +++ b/processing/src/main/java/org/apache/druid/query/rowsandcols/ConcatRowsAndColumns.java @@ -19,6 +19,7 @@ package org.apache.druid.query.rowsandcols; +import com.google.common.base.Preconditions; import org.apache.druid.java.util.common.ISE; import org.apache.druid.query.rowsandcols.column.Column; import org.apache.druid.query.rowsandcols.column.ColumnAccessor; @@ -30,6 +31,7 @@ import javax.annotation.Nullable; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.Map; @@ -53,6 +55,7 @@ public class ConcatRowsAndColumns implements RowsAndColumns ArrayList racBuffer ) { + Preconditions.checkNotNull(racBuffer, "racBuffer cannot be null"); this.racBuffer = racBuffer; int numRows = 0; @@ -76,6 +79,9 @@ public class ConcatRowsAndColumns implements RowsAndColumns @Override public Collection getColumnNames() { + if (racBuffer.isEmpty()) { + return Collections.emptySet(); + } return racBuffer.get(0).getColumnNames(); } @@ -92,6 +98,9 @@ public class ConcatRowsAndColumns implements RowsAndColumns if (columnCache.containsKey(name)) { return columnCache.get(name); } else { + if (racBuffer.isEmpty()) { + return null; + } final Column firstCol = racBuffer.get(0).findColumn(name); if (firstCol == null) { for (int i = 1; i < racBuffer.size(); ++i) { diff --git a/processing/src/test/java/org/apache/druid/query/rowsandcols/ConcatRowsAndColumnsTest.java b/processing/src/test/java/org/apache/druid/query/rowsandcols/ConcatRowsAndColumnsTest.java index b95f750632d..0bf0450114b 100644 --- a/processing/src/test/java/org/apache/druid/query/rowsandcols/ConcatRowsAndColumnsTest.java +++ b/processing/src/test/java/org/apache/druid/query/rowsandcols/ConcatRowsAndColumnsTest.java @@ -19,7 +19,13 @@ package org.apache.druid.query.rowsandcols; +import com.google.common.collect.ImmutableMap; +import org.apache.druid.query.rowsandcols.column.IntArrayColumn; +import org.junit.Assert; +import org.junit.Test; + import java.util.ArrayList; +import java.util.Arrays; import java.util.function.Function; public class ConcatRowsAndColumnsTest extends RowsAndColumnsTestBase @@ -42,4 +48,55 @@ public class ConcatRowsAndColumnsTest extends RowsAndColumnsTestBase return new ConcatRowsAndColumns(theRac); }; + + @Test + public void testConstructorWithNullRacBuffer() + { + final NullPointerException e = Assert.assertThrows( + NullPointerException.class, + () -> new ConcatRowsAndColumns(null) + ); + Assert.assertEquals("racBuffer cannot be null", e.getMessage()); + } + + @Test + public void testFindColumn() + { + MapOfColumnsRowsAndColumns rac = MapOfColumnsRowsAndColumns.fromMap( + ImmutableMap.of( + "column1", new IntArrayColumn(new int[]{1, 2, 3, 4, 5, 6}), + "column2", new IntArrayColumn(new int[]{6, 5, 4, 3, 2, 1}) + ) + ); + ConcatRowsAndColumns apply = MAKER.apply(rac); + Assert.assertEquals(1, apply.findColumn("column1").toAccessor().getInt(0)); + Assert.assertEquals(6, apply.findColumn("column2").toAccessor().getInt(0)); + } + + @Test + public void testFindColumnWithEmptyRacBuffer() + { + ConcatRowsAndColumns concatRowsAndColumns = new ConcatRowsAndColumns(new ArrayList<>()); + Assert.assertNull(concatRowsAndColumns.findColumn("columnName")); + } + + @Test + public void testGetColumns() + { + MapOfColumnsRowsAndColumns rac = MapOfColumnsRowsAndColumns.fromMap( + ImmutableMap.of( + "column1", new IntArrayColumn(new int[]{0, 0, 0, 1, 1, 2, 4, 4, 4}), + "column2", new IntArrayColumn(new int[]{3, 54, 21, 1, 5, 54, 2, 3, 92}) + ) + ); + ConcatRowsAndColumns apply = MAKER.apply(rac); + Assert.assertEquals(Arrays.asList("column1", "column2"), new ArrayList<>(apply.getColumnNames())); + } + + @Test + public void testGetColumnsWithEmptyRacBuffer() + { + ConcatRowsAndColumns concatRowsAndColumns = new ConcatRowsAndColumns(new ArrayList<>()); + Assert.assertTrue(concatRowsAndColumns.getColumnNames().isEmpty()); + } } diff --git a/sql/src/test/java/org/apache/druid/sql/calcite/DrillWindowQueryTest.java b/sql/src/test/java/org/apache/druid/sql/calcite/DrillWindowQueryTest.java index 4a2f0945087..9bf56d97d38 100644 --- a/sql/src/test/java/org/apache/druid/sql/calcite/DrillWindowQueryTest.java +++ b/sql/src/test/java/org/apache/druid/sql/calcite/DrillWindowQueryTest.java @@ -7622,6 +7622,13 @@ public class DrillWindowQueryTest extends BaseCalciteQueryTest windowQueryTest(); } + @DrillTest("druid_queries/empty_over_clause/single_empty_over_3") + @Test + public void test_empty_over_single_empty_over_3() + { + windowQueryTest(); + } + @DrillTest("druid_queries/empty_over_clause/multiple_empty_over_1") @Test public void test_empty_over_multiple_empty_over_1() diff --git a/sql/src/test/resources/drill/window/queries/druid_queries/empty_over_clause/single_empty_over_3.e b/sql/src/test/resources/drill/window/queries/druid_queries/empty_over_clause/single_empty_over_3.e new file mode 100644 index 00000000000..e69de29bb2d diff --git a/sql/src/test/resources/drill/window/queries/druid_queries/empty_over_clause/single_empty_over_3.q b/sql/src/test/resources/drill/window/queries/druid_queries/empty_over_clause/single_empty_over_3.q new file mode 100644 index 00000000000..ac8c5e930d3 --- /dev/null +++ b/sql/src/test/resources/drill/window/queries/druid_queries/empty_over_clause/single_empty_over_3.q @@ -0,0 +1,4 @@ +select countryName, row_number() over () as c1 +from wikipedia +where countryName in ('non-existent-country') +group by countryName, cityName, channel From a7dd436a32a91473b6f3ce5d599a03b603b00f6c Mon Sep 17 00:00:00 2001 From: Adithya Chakilam <35785271+adithyachakilam@users.noreply.github.com> Date: Fri, 9 Aug 2024 04:12:48 -0500 Subject: [PATCH 03/99] Check if supervisor could be idle on startup (#16844) Fixes #13936 In cases where a supervisor is idle and the overlord is restarted for some reason, the supervisor would start spinning tasks again. In clusters where there are many low throughput streams, this would spike the task count unnecessarily. This commit compares the latest stream offset with the ones in metadata during the startup of supervisor and sets it to idle state if they match. --- .../kafka/supervisor/KafkaSupervisorTest.java | 3 - .../supervisor/SeekableStreamSupervisor.java | 15 ++- .../SeekableStreamSupervisorStateTest.java | 104 +++++++++++++++++- .../indexer/AbstractStreamIndexingTest.java | 20 ++++ .../supervisor/SupervisorStateManager.java | 5 +- 5 files changed, 136 insertions(+), 11 deletions(-) diff --git a/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisorTest.java b/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisorTest.java index b18c1749125..86275d10e31 100644 --- a/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisorTest.java +++ b/extensions-core/kafka-indexing-service/src/test/java/org/apache/druid/indexing/kafka/supervisor/KafkaSupervisorTest.java @@ -408,13 +408,10 @@ public class KafkaSupervisorTest extends EasyMockSupport autoscaler.start(); supervisor.runInternal(); Thread.sleep(1000); - supervisor.runInternal(); verifyAll(); int taskCountAfterScale = supervisor.getIoConfig().getTaskCount(); Assert.assertEquals(2, taskCountAfterScale); - Assert.assertEquals(SupervisorStateManager.BasicState.IDLE, supervisor.getState()); - KafkaIndexTask task = captured.getValue(); Assert.assertEquals(KafkaSupervisorTest.dataSchema, task.getDataSchema()); diff --git a/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/supervisor/SeekableStreamSupervisor.java b/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/supervisor/SeekableStreamSupervisor.java index a99c782557b..81d8871b8e8 100644 --- a/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/supervisor/SeekableStreamSupervisor.java +++ b/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/supervisor/SeekableStreamSupervisor.java @@ -3640,12 +3640,21 @@ public abstract class SeekableStreamSupervisor latestSequencesFromStream = getLatestSequencesFromStream(); final long nowTime = DateTimes.nowUtc().getMillis(); + // if it is the first run and there is no lag observed when compared to the offsets from metadata storage stay idle + if (!stateManager.isAtLeastOneSuccessfulRun()) { + // Set previous sequences to the current offsets in metadata store + previousSequencesFromStream.clear(); + previousSequencesFromStream.putAll(getOffsetsFromMetadataStorage()); + + // Force update partition lag since the reporting thread might not have run yet + updatePartitionLagFromStream(); + } + + Map latestSequencesFromStream = getLatestSequencesFromStream(); final boolean idle; final long idleTime; - if (lastActiveTimeMillis > 0 - && previousSequencesFromStream.equals(latestSequencesFromStream) + if (previousSequencesFromStream.equals(latestSequencesFromStream) && computeTotalLag() == 0) { idleTime = nowTime - lastActiveTimeMillis; idle = true; diff --git a/indexing-service/src/test/java/org/apache/druid/indexing/seekablestream/supervisor/SeekableStreamSupervisorStateTest.java b/indexing-service/src/test/java/org/apache/druid/indexing/seekablestream/supervisor/SeekableStreamSupervisorStateTest.java index cb395acf66b..daf85ac39c9 100644 --- a/indexing-service/src/test/java/org/apache/druid/indexing/seekablestream/supervisor/SeekableStreamSupervisorStateTest.java +++ b/indexing-service/src/test/java/org/apache/druid/indexing/seekablestream/supervisor/SeekableStreamSupervisorStateTest.java @@ -709,8 +709,8 @@ public class SeekableStreamSupervisorStateTest extends EasyMockSupport replayAll(); - SeekableStreamSupervisor supervisor = new TestSeekableStreamSupervisor(); - + TestSeekableStreamSupervisor supervisor = new TestSeekableStreamSupervisor(); + supervisor.setStreamOffsets(ImmutableMap.of("0", "10")); supervisor.start(); Assert.assertTrue(supervisor.stateManager.isHealthy()); @@ -757,6 +757,93 @@ public class SeekableStreamSupervisorStateTest extends EasyMockSupport verifyAll(); } + @Test + public void testIdleOnStartUpAndTurnsToRunningAfterLagUpdates() + { + Map initialOffsets = ImmutableMap.of("0", "10"); + Map laterOffsets = ImmutableMap.of("0", "20"); + + EasyMock.reset(indexerMetadataStorageCoordinator); + EasyMock.expect(indexerMetadataStorageCoordinator.retrieveDataSourceMetadata(DATASOURCE)).andReturn( + new TestSeekableStreamDataSourceMetadata( + new SeekableStreamEndSequenceNumbers<>( + STREAM, + initialOffsets + ) + ) + ).anyTimes(); + EasyMock.reset(spec); + EasyMock.expect(spec.isSuspended()).andReturn(false).anyTimes(); + EasyMock.expect(spec.getDataSchema()).andReturn(getDataSchema()).anyTimes(); + EasyMock.expect(spec.getContextValue("tags")).andReturn("").anyTimes(); + EasyMock.expect(spec.getIoConfig()).andReturn(new SeekableStreamSupervisorIOConfig( + "stream", + new JsonInputFormat(new JSONPathSpec(true, ImmutableList.of()), ImmutableMap.of(), false, false, false), + 1, + 1, + new Period("PT1H"), + new Period("PT1S"), + new Period("PT30S"), + false, + new Period("PT30M"), + null, + null, + null, + null, + new IdleConfig(true, 200L), + null + ) + { + }).anyTimes(); + EasyMock.expect(spec.getTuningConfig()).andReturn(getTuningConfig()).anyTimes(); + EasyMock.expect(spec.getEmitter()).andReturn(emitter).anyTimes(); + EasyMock.expect(spec.getMonitorSchedulerConfig()).andReturn(new DruidMonitorSchedulerConfig() + { + @Override + public Duration getEmissionDuration() + { + return new Period("PT1S").toStandardDuration(); + } + }).anyTimes(); + EasyMock.expect(spec.getType()).andReturn("test").anyTimes(); + EasyMock.expect(spec.getSupervisorStateManagerConfig()).andReturn(supervisorConfig).anyTimes(); + EasyMock.expect(recordSupplier.getPartitionIds(STREAM)).andReturn(ImmutableSet.of(SHARD_ID)).anyTimes(); + EasyMock.expect(recordSupplier.isOffsetAvailable(EasyMock.anyObject(), EasyMock.anyObject())) + .andReturn(true) + .anyTimes(); + EasyMock.expect(taskStorage.getActiveTasksByDatasource(DATASOURCE)).andReturn(ImmutableList.of()).anyTimes(); + EasyMock.expect(taskQueue.add(EasyMock.anyObject())).andReturn(true).anyTimes(); + replayAll(); + + TestSeekableStreamSupervisor supervisor = new TestSeekableStreamSupervisor(); + + supervisor.start(); + + Assert.assertTrue(supervisor.stateManager.isHealthy()); + Assert.assertEquals(BasicState.PENDING, supervisor.stateManager.getSupervisorState()); + Assert.assertEquals(BasicState.PENDING, supervisor.stateManager.getSupervisorState().getBasicState()); + Assert.assertTrue(supervisor.stateManager.getExceptionEvents().isEmpty()); + Assert.assertFalse(supervisor.stateManager.isAtLeastOneSuccessfulRun()); + + supervisor.setStreamOffsets(initialOffsets); + supervisor.runInternal(); + + Assert.assertTrue(supervisor.stateManager.isHealthy()); + Assert.assertEquals(BasicState.IDLE, supervisor.stateManager.getSupervisorState()); + Assert.assertEquals(BasicState.IDLE, supervisor.stateManager.getSupervisorState().getBasicState()); + Assert.assertTrue(supervisor.stateManager.getExceptionEvents().isEmpty()); + Assert.assertTrue(supervisor.stateManager.isAtLeastOneSuccessfulRun()); + + supervisor.setStreamOffsets(laterOffsets); + supervisor.runInternal(); + + Assert.assertTrue(supervisor.stateManager.isHealthy()); + Assert.assertEquals(BasicState.RUNNING, supervisor.stateManager.getSupervisorState()); + Assert.assertEquals(BasicState.RUNNING, supervisor.stateManager.getSupervisorState().getBasicState()); + Assert.assertTrue(supervisor.stateManager.getExceptionEvents().isEmpty()); + Assert.assertTrue(supervisor.stateManager.isAtLeastOneSuccessfulRun()); + } + @Test public void testCreatingTasksFailRecoveryFail() { @@ -2872,6 +2959,8 @@ public class SeekableStreamSupervisorStateTest extends EasyMockSupport private class TestSeekableStreamSupervisor extends BaseTestSeekableStreamSupervisor { + Map streamOffsets = new HashMap<>(); + @Override protected void scheduleReporting(ScheduledExecutorService reportingExec) { @@ -2883,6 +2972,17 @@ public class SeekableStreamSupervisorStateTest extends EasyMockSupport { return new LagStats(0, 0, 0); } + + @Override + protected Map getLatestSequencesFromStream() + { + return streamOffsets; + } + + public void setStreamOffsets(Map streamOffsets) + { + this.streamOffsets = streamOffsets; + } } private class TestEmittingTestSeekableStreamSupervisor extends BaseTestSeekableStreamSupervisor diff --git a/integration-tests/src/test/java/org/apache/druid/tests/indexer/AbstractStreamIndexingTest.java b/integration-tests/src/test/java/org/apache/druid/tests/indexer/AbstractStreamIndexingTest.java index e20c2ea2061..8cc8388ba47 100644 --- a/integration-tests/src/test/java/org/apache/druid/tests/indexer/AbstractStreamIndexingTest.java +++ b/integration-tests/src/test/java/org/apache/druid/tests/indexer/AbstractStreamIndexingTest.java @@ -561,6 +561,26 @@ public abstract class AbstractStreamIndexingTest extends AbstractIndexerTest "wait for no more creation of indexing tasks" ); + indexer.shutdownSupervisor(generatedTestConfig.getSupervisorId()); + indexer.submitSupervisor(taskSpec); + + ITRetryUtil.retryUntil( + () -> SupervisorStateManager.BasicState.IDLE.equals(indexer.getSupervisorStatus(generatedTestConfig.getSupervisorId())), + true, + 10000, + 30, + "Waiting for supervisor to be idle" + ); + ITRetryUtil.retryUntil( + () -> indexer.getRunningTasks() + .stream() + .noneMatch(taskResponseObject -> taskResponseObject.getId().contains(dataSource)), + true, + 1000, + 10, + "wait for no more creation of indexing tasks" + ); + // Start generating remainning half of the data numWritten += streamGenerator.run( generatedTestConfig.getStreamName(), diff --git a/server/src/main/java/org/apache/druid/indexing/overlord/supervisor/SupervisorStateManager.java b/server/src/main/java/org/apache/druid/indexing/overlord/supervisor/SupervisorStateManager.java index 1ea36229c38..88049f18b08 100644 --- a/server/src/main/java/org/apache/druid/indexing/overlord/supervisor/SupervisorStateManager.java +++ b/server/src/main/java/org/apache/druid/indexing/overlord/supervisor/SupervisorStateManager.java @@ -155,11 +155,10 @@ public class SupervisorStateManager return; } - // if we're trying to switch to a healthy steady state (i.e. RUNNING or SUSPENDED) or IDLE state but haven't had a successful run + // if we're trying to switch to a healthy steady state (i.e. RUNNING or SUSPENDED) but haven't had a successful run // yet, refuse to switch and prefer the more specific states used for first run (CONNECTING_TO_STREAM, // DISCOVERING_INITIAL_TASKS, CREATING_TASKS, etc.) - if ((healthySteadyState.equals(proposedState) || BasicState.IDLE.equals(proposedState)) - && !atLeastOneSuccessfulRun) { + if (healthySteadyState.equals(proposedState) && !atLeastOneSuccessfulRun) { return; } From 483a03f26c7221f10493d86715985a58e61af967 Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Fri, 9 Aug 2024 14:46:59 -0700 Subject: [PATCH 04/99] Web console: Server context defaults (#16868) * add server defaults * null is NULL * r to d * add test * typo --- licenses.yaml | 2 +- web-console/console-config.js | 3 +- web-console/package-lock.json | 14 +- web-console/package.json | 2 +- .../src/components/header-bar/header-bar.tsx | 3 +- web-console/src/console-application.tsx | 63 +++-- .../query-context/query-context.tsx | 251 +++--------------- web-console/src/entry.tsx | 14 +- .../helpers/execution/sql-task-execution.ts | 16 +- web-console/src/utils/general.tsx | 6 + web-console/src/utils/values-query.spec.tsx | 4 +- web-console/src/utils/values-query.tsx | 21 +- .../sql-data-loader-view.tsx | 12 +- .../max-tasks-button.spec.tsx | 8 +- .../max-tasks-button/max-tasks-button.tsx | 47 ++-- .../workbench-view/query-tab/query-tab.tsx | 32 ++- .../workbench-view/run-panel/run-panel.tsx | 210 +++++++++------ .../views/workbench-view/workbench-view.tsx | 46 +++- 18 files changed, 343 insertions(+), 411 deletions(-) diff --git a/licenses.yaml b/licenses.yaml index dcdac7bd187..0646c7131fd 100644 --- a/licenses.yaml +++ b/licenses.yaml @@ -5085,7 +5085,7 @@ license_category: binary module: web-console license_name: Apache License version 2.0 copyright: Imply Data -version: 0.22.20 +version: 0.22.21 --- diff --git a/web-console/console-config.js b/web-console/console-config.js index 10bdddb611a..25d99e7c650 100644 --- a/web-console/console-config.js +++ b/web-console/console-config.js @@ -17,6 +17,5 @@ */ window.consoleConfig = { - exampleManifestsUrl: 'https://druid.apache.org/data/example-manifests-v2.tsv', - /* future configs may go here */ + /* configs go here */ }; diff --git a/web-console/package-lock.json b/web-console/package-lock.json index 412f728d56d..e9319969b69 100644 --- a/web-console/package-lock.json +++ b/web-console/package-lock.json @@ -14,7 +14,7 @@ "@blueprintjs/datetime2": "^2.3.7", "@blueprintjs/icons": "^5.10.0", "@blueprintjs/select": "^5.2.1", - "@druid-toolkit/query": "^0.22.20", + "@druid-toolkit/query": "^0.22.21", "@druid-toolkit/visuals-core": "^0.3.3", "@druid-toolkit/visuals-react": "^0.3.3", "@fontsource/open-sans": "^5.0.28", @@ -989,9 +989,9 @@ } }, "node_modules/@druid-toolkit/query": { - "version": "0.22.20", - "resolved": "https://registry.npmjs.org/@druid-toolkit/query/-/query-0.22.20.tgz", - "integrity": "sha512-GmmSd27y7zLVTjgTBQy+XoGeSSGhSDNmwyiwWtSua7I5LX8XqHV7Chi8HIH25YQoVgTK1pLK4RS8eRXxthRAzg==", + "version": "0.22.21", + "resolved": "https://registry.npmjs.org/@druid-toolkit/query/-/query-0.22.21.tgz", + "integrity": "sha512-4k0NGO2Ay90naSO8nyivPPvvhz73D/OkCo6So3frmPDLFfw5CYKSvAhy4RadtnLMZPwsnlVREjAmqbvBsHqgjQ==", "dependencies": { "tslib": "^2.5.2" } @@ -19093,9 +19093,9 @@ "dev": true }, "@druid-toolkit/query": { - "version": "0.22.20", - "resolved": "https://registry.npmjs.org/@druid-toolkit/query/-/query-0.22.20.tgz", - "integrity": "sha512-GmmSd27y7zLVTjgTBQy+XoGeSSGhSDNmwyiwWtSua7I5LX8XqHV7Chi8HIH25YQoVgTK1pLK4RS8eRXxthRAzg==", + "version": "0.22.21", + "resolved": "https://registry.npmjs.org/@druid-toolkit/query/-/query-0.22.21.tgz", + "integrity": "sha512-4k0NGO2Ay90naSO8nyivPPvvhz73D/OkCo6So3frmPDLFfw5CYKSvAhy4RadtnLMZPwsnlVREjAmqbvBsHqgjQ==", "requires": { "tslib": "^2.5.2" } diff --git a/web-console/package.json b/web-console/package.json index 0c9370f8808..d55bb79d609 100644 --- a/web-console/package.json +++ b/web-console/package.json @@ -68,7 +68,7 @@ "@blueprintjs/datetime2": "^2.3.7", "@blueprintjs/icons": "^5.10.0", "@blueprintjs/select": "^5.2.1", - "@druid-toolkit/query": "^0.22.20", + "@druid-toolkit/query": "^0.22.21", "@druid-toolkit/visuals-core": "^0.3.3", "@druid-toolkit/visuals-react": "^0.3.3", "@fontsource/open-sans": "^5.0.28", diff --git a/web-console/src/components/header-bar/header-bar.tsx b/web-console/src/components/header-bar/header-bar.tsx index e1b97cf4e13..aed66798299 100644 --- a/web-console/src/components/header-bar/header-bar.tsx +++ b/web-console/src/components/header-bar/header-bar.tsx @@ -59,7 +59,6 @@ import './header-bar.scss'; const capabilitiesOverride = localStorageGetJson(LocalStorageKeys.CAPABILITIES_OVERRIDE); export type HeaderActiveTab = - | null | 'data-loader' | 'streaming-data-loader' | 'classic-batch-data-loader' @@ -93,7 +92,7 @@ const DruidLogo = React.memo(function DruidLogo() { }); export interface HeaderBarProps { - active: HeaderActiveTab; + active: HeaderActiveTab | null; capabilities: Capabilities; onUnrestrict(capabilities: Capabilities): void; } diff --git a/web-console/src/console-application.tsx b/web-console/src/console-application.tsx index 0d097729cbf..36a0b8aa392 100644 --- a/web-console/src/console-application.tsx +++ b/web-console/src/console-application.tsx @@ -28,7 +28,7 @@ import type { Filter } from 'react-table'; import type { HeaderActiveTab } from './components'; import { HeaderBar, Loader } from './components'; -import type { DruidEngine, QueryWithContext } from './druid-models'; +import type { DruidEngine, QueryContext, QueryWithContext } from './druid-models'; import { Capabilities, maybeGetClusterCapacity } from './helpers'; import { stringToTableFilters, tableFiltersToString } from './react-table'; import { AppToaster } from './singletons'; @@ -51,22 +51,32 @@ import './console-application.scss'; type FiltersRouteMatch = RouteComponentProps<{ filters?: string }>; -function changeHashWithFilter(slug: string, filters: Filter[]) { +function changeTabWithFilter(tab: HeaderActiveTab, filters: Filter[]) { const filterString = tableFiltersToString(filters); - location.hash = slug + (filterString ? `/${filterString}` : ''); + location.hash = tab + (filterString ? `/${filterString}` : ''); } -function viewFilterChange(slug: string) { - return (filters: Filter[]) => changeHashWithFilter(slug, filters); +function viewFilterChange(tab: HeaderActiveTab) { + return (filters: Filter[]) => changeTabWithFilter(tab, filters); } -function pathWithFilter(slug: string) { - return [`/${slug}/:filters`, `/${slug}`]; +function pathWithFilter(tab: HeaderActiveTab) { + return [`/${tab}/:filters`, `/${tab}`]; +} + +function switchTab(tab: HeaderActiveTab) { + location.hash = tab; +} + +function switchToWorkbenchTab(tabId: string) { + location.hash = `workbench/${tabId}`; } export interface ConsoleApplicationProps { - defaultQueryContext?: Record; - mandatoryQueryContext?: Record; + baseQueryContext?: QueryContext; + defaultQueryContext?: QueryContext; + mandatoryQueryContext?: QueryContext; + serverQueryContext?: QueryContext; } export interface ConsoleApplicationState { @@ -158,22 +168,22 @@ export class ConsoleApplication extends React.PureComponent< private readonly goToStreamingDataLoader = (supervisorId?: string) => { if (supervisorId) this.supervisorId = supervisorId; - location.hash = 'streaming-data-loader'; + switchTab('streaming-data-loader'); this.resetInitialsWithDelay(); }; private readonly goToClassicBatchDataLoader = (taskId?: string) => { if (taskId) this.taskId = taskId; - location.hash = 'classic-batch-data-loader'; + switchTab('classic-batch-data-loader'); this.resetInitialsWithDelay(); }; private readonly goToDatasources = (datasource: string) => { - changeHashWithFilter('datasources', [{ id: 'datasource', value: `=${datasource}` }]); + changeTabWithFilter('datasources', [{ id: 'datasource', value: `=${datasource}` }]); }; private readonly goToSegments = (datasource: string, onlyUnavailable = false) => { - changeHashWithFilter( + changeTabWithFilter( 'segments', compact([ { id: 'datasource', value: `=${datasource}` }, @@ -183,19 +193,19 @@ export class ConsoleApplication extends React.PureComponent< }; private readonly goToSupervisor = (supervisorId: string) => { - changeHashWithFilter('supervisors', [{ id: 'supervisor_id', value: `=${supervisorId}` }]); + changeTabWithFilter('supervisors', [{ id: 'supervisor_id', value: `=${supervisorId}` }]); }; private readonly goToTasksWithTaskId = (taskId: string) => { - changeHashWithFilter('tasks', [{ id: 'task_id', value: `=${taskId}` }]); + changeTabWithFilter('tasks', [{ id: 'task_id', value: `=${taskId}` }]); }; private readonly goToTasksWithTaskGroupId = (taskGroupId: string) => { - changeHashWithFilter('tasks', [{ id: 'group_id', value: `=${taskGroupId}` }]); + changeTabWithFilter('tasks', [{ id: 'group_id', value: `=${taskGroupId}` }]); }; private readonly goToTasksWithDatasource = (datasource: string, type?: string) => { - changeHashWithFilter( + changeTabWithFilter( 'tasks', compact([ { id: 'datasource', value: `=${datasource}` }, @@ -206,24 +216,24 @@ export class ConsoleApplication extends React.PureComponent< private readonly openSupervisorSubmit = () => { this.openSupervisorDialog = true; - location.hash = 'supervisors'; + switchTab('supervisors'); this.resetInitialsWithDelay(); }; private readonly openTaskSubmit = () => { this.openTaskDialog = true; - location.hash = 'tasks'; + switchTab('tasks'); this.resetInitialsWithDelay(); }; private readonly goToQuery = (queryWithContext: QueryWithContext) => { this.queryWithContext = queryWithContext; - location.hash = 'workbench'; + switchTab('workbench'); this.resetInitialsWithDelay(); }; private readonly wrapInViewContainer = ( - active: HeaderActiveTab, + active: HeaderActiveTab | null, el: JSX.Element, classType: 'normal' | 'narrow-pad' | 'thin' | 'thinner' = 'normal', ) => { @@ -293,7 +303,8 @@ export class ConsoleApplication extends React.PureComponent< }; private readonly wrappedWorkbenchView = (p: RouteComponentProps<{ tabId?: string }>) => { - const { defaultQueryContext, mandatoryQueryContext } = this.props; + const { defaultQueryContext, mandatoryQueryContext, baseQueryContext, serverQueryContext } = + this.props; const { capabilities } = this.state; const queryEngines: DruidEngine[] = ['native']; @@ -309,12 +320,12 @@ export class ConsoleApplication extends React.PureComponent< { - location.hash = `workbench/${newTabId}`; - }} + onTabChange={switchToWorkbenchTab} initQueryWithContext={this.queryWithContext} defaultQueryContext={defaultQueryContext} mandatoryQueryContext={mandatoryQueryContext} + baseQueryContext={baseQueryContext} + serverQueryContext={serverQueryContext} queryEngines={queryEngines} allowExplain goToTask={this.goToTasksWithTaskId} @@ -325,6 +336,7 @@ export class ConsoleApplication extends React.PureComponent< }; private readonly wrappedSqlDataLoaderView = () => { + const { serverQueryContext } = this.props; const { capabilities } = this.state; return this.wrapInViewContainer( 'sql-data-loader', @@ -334,6 +346,7 @@ export class ConsoleApplication extends React.PureComponent< goToTask={this.goToTasksWithTaskId} goToTaskGroup={this.goToTasksWithTaskGroupId} getClusterCapacity={maybeGetClusterCapacity} + serverQueryContext={serverQueryContext} />, ); }; diff --git a/web-console/src/druid-models/query-context/query-context.tsx b/web-console/src/druid-models/query-context/query-context.tsx index 17e8204e949..a25d268d845 100644 --- a/web-console/src/druid-models/query-context/query-context.tsx +++ b/web-console/src/druid-models/query-context/query-context.tsx @@ -16,9 +16,10 @@ * limitations under the License. */ -import { deepDelete, deepSet } from '../../utils'; - +export type SelectDestination = 'taskReport' | 'durableStorage'; export type ArrayIngestMode = 'array' | 'mvd'; +export type TaskAssignment = 'auto' | 'max'; +export type SqlJoinAlgorithm = 'broadcast' | 'sortMerge'; export interface QueryContext { useCache?: boolean; @@ -30,15 +31,38 @@ export interface QueryContext { // Multi-stage query maxNumTasks?: number; finalizeAggregations?: boolean; - selectDestination?: string; + selectDestination?: SelectDestination; durableShuffleStorage?: boolean; maxParseExceptions?: number; groupByEnableMultiValueUnnesting?: boolean; arrayIngestMode?: ArrayIngestMode; + taskAssignment?: TaskAssignment; + sqlJoinAlgorithm?: SqlJoinAlgorithm; + failOnEmptyInsert?: boolean; + waitUntilSegmentsLoad?: boolean; [key: string]: any; } +export const DEFAULT_SERVER_QUERY_CONTEXT: QueryContext = { + useCache: true, + populateCache: true, + useApproximateCountDistinct: true, + useApproximateTopN: true, + sqlTimeZone: 'Etc/UTC', + + // Multi-stage query + finalizeAggregations: true, + selectDestination: 'taskReport', + durableShuffleStorage: false, + maxParseExceptions: 0, + groupByEnableMultiValueUnnesting: true, + taskAssignment: 'max', + sqlJoinAlgorithm: 'broadcast', + failOnEmptyInsert: false, + waitUntilSegmentsLoad: false, +}; + export interface QueryWithContext { queryString: string; queryContext?: QueryContext; @@ -49,221 +73,10 @@ export function isEmptyContext(context: QueryContext | undefined): boolean { return !context || Object.keys(context).length === 0; } -// ----------------------------- - -export function getUseCache(context: QueryContext): boolean { - const { useCache } = context; - return typeof useCache === 'boolean' ? useCache : true; -} - -export function changeUseCache(context: QueryContext, useCache: boolean): QueryContext { - let newContext = context; - if (useCache) { - newContext = deepDelete(newContext, 'useCache'); - newContext = deepDelete(newContext, 'populateCache'); - } else { - newContext = deepSet(newContext, 'useCache', false); - newContext = deepSet(newContext, 'populateCache', false); - } - return newContext; -} - -// ----------------------------- - -export function getUseApproximateCountDistinct(context: QueryContext): boolean { - const { useApproximateCountDistinct } = context; - return typeof useApproximateCountDistinct === 'boolean' ? useApproximateCountDistinct : true; -} - -export function changeUseApproximateCountDistinct( +export function getQueryContextKey( + key: keyof QueryContext, context: QueryContext, - useApproximateCountDistinct: boolean, -): QueryContext { - if (useApproximateCountDistinct) { - return deepDelete(context, 'useApproximateCountDistinct'); - } else { - return deepSet(context, 'useApproximateCountDistinct', false); - } -} - -// ----------------------------- - -export function getUseApproximateTopN(context: QueryContext): boolean { - const { useApproximateTopN } = context; - return typeof useApproximateTopN === 'boolean' ? useApproximateTopN : true; -} - -export function changeUseApproximateTopN( - context: QueryContext, - useApproximateTopN: boolean, -): QueryContext { - if (useApproximateTopN) { - return deepDelete(context, 'useApproximateTopN'); - } else { - return deepSet(context, 'useApproximateTopN', false); - } -} - -// sqlTimeZone - -export function getTimezone(context: QueryContext): string | undefined { - return context.sqlTimeZone; -} - -export function changeTimezone(context: QueryContext, timezone: string | undefined): QueryContext { - if (timezone) { - return deepSet(context, 'sqlTimeZone', timezone); - } else { - return deepDelete(context, 'sqlTimeZone'); - } -} - -// maxNumTasks - -export function getMaxNumTasks(context: QueryContext): number | undefined { - return context.maxNumTasks; -} - -export function changeMaxNumTasks( - context: QueryContext, - maxNumTasks: number | undefined, -): QueryContext { - return typeof maxNumTasks === 'number' - ? deepSet(context, 'maxNumTasks', maxNumTasks) - : deepDelete(context, 'maxNumTasks'); -} - -// taskAssignment - -export function getTaskAssigment(context: QueryContext): string { - const { taskAssignment } = context; - return taskAssignment ?? 'max'; -} - -export function changeTaskAssigment( - context: QueryContext, - taskAssignment: string | undefined, -): QueryContext { - return typeof taskAssignment === 'string' - ? deepSet(context, 'taskAssignment', taskAssignment) - : deepDelete(context, 'taskAssignment'); -} - -// failOnEmptyInsert - -export function getFailOnEmptyInsert(context: QueryContext): boolean | undefined { - const { failOnEmptyInsert } = context; - return typeof failOnEmptyInsert === 'boolean' ? failOnEmptyInsert : undefined; -} - -export function changeFailOnEmptyInsert( - context: QueryContext, - failOnEmptyInsert: boolean | undefined, -): QueryContext { - return typeof failOnEmptyInsert === 'boolean' - ? deepSet(context, 'failOnEmptyInsert', failOnEmptyInsert) - : deepDelete(context, 'failOnEmptyInsert'); -} - -// finalizeAggregations - -export function getFinalizeAggregations(context: QueryContext): boolean | undefined { - const { finalizeAggregations } = context; - return typeof finalizeAggregations === 'boolean' ? finalizeAggregations : undefined; -} - -export function changeFinalizeAggregations( - context: QueryContext, - finalizeAggregations: boolean | undefined, -): QueryContext { - return typeof finalizeAggregations === 'boolean' - ? deepSet(context, 'finalizeAggregations', finalizeAggregations) - : deepDelete(context, 'finalizeAggregations'); -} - -// waitUntilSegmentsLoad - -export function getWaitUntilSegmentsLoad(context: QueryContext): boolean | undefined { - const { waitUntilSegmentsLoad } = context; - return typeof waitUntilSegmentsLoad === 'boolean' ? waitUntilSegmentsLoad : undefined; -} - -export function changeWaitUntilSegmentsLoad( - context: QueryContext, - waitUntilSegmentsLoad: boolean | undefined, -): QueryContext { - return typeof waitUntilSegmentsLoad === 'boolean' - ? deepSet(context, 'waitUntilSegmentsLoad', waitUntilSegmentsLoad) - : deepDelete(context, 'waitUntilSegmentsLoad'); -} - -// groupByEnableMultiValueUnnesting - -export function getGroupByEnableMultiValueUnnesting(context: QueryContext): boolean | undefined { - const { groupByEnableMultiValueUnnesting } = context; - return typeof groupByEnableMultiValueUnnesting === 'boolean' - ? groupByEnableMultiValueUnnesting - : undefined; -} - -export function changeGroupByEnableMultiValueUnnesting( - context: QueryContext, - groupByEnableMultiValueUnnesting: boolean | undefined, -): QueryContext { - return typeof groupByEnableMultiValueUnnesting === 'boolean' - ? deepSet(context, 'groupByEnableMultiValueUnnesting', groupByEnableMultiValueUnnesting) - : deepDelete(context, 'groupByEnableMultiValueUnnesting'); -} - -// durableShuffleStorage - -export function getDurableShuffleStorage(context: QueryContext): boolean { - const { durableShuffleStorage } = context; - return Boolean(durableShuffleStorage); -} - -export function changeDurableShuffleStorage( - context: QueryContext, - durableShuffleStorage: boolean, -): QueryContext { - if (durableShuffleStorage) { - return deepSet(context, 'durableShuffleStorage', true); - } else { - return deepDelete(context, 'durableShuffleStorage'); - } -} - -// maxParseExceptions - -export function getMaxParseExceptions(context: QueryContext): number { - const { maxParseExceptions } = context; - return Number(maxParseExceptions) || 0; -} - -export function changeMaxParseExceptions( - context: QueryContext, - maxParseExceptions: number, -): QueryContext { - if (maxParseExceptions !== 0) { - return deepSet(context, 'maxParseExceptions', maxParseExceptions); - } else { - return deepDelete(context, 'maxParseExceptions'); - } -} - -// arrayIngestMode - -export function getArrayIngestMode(context: QueryContext): ArrayIngestMode | undefined { - return context.arrayIngestMode; -} - -export function changeArrayIngestMode( - context: QueryContext, - arrayIngestMode: ArrayIngestMode | undefined, -): QueryContext { - if (arrayIngestMode) { - return deepSet(context, 'arrayIngestMode', arrayIngestMode); - } else { - return deepDelete(context, 'arrayIngestMode'); - } + defaultContext: QueryContext, +): any { + return typeof context[key] !== 'undefined' ? context[key] : defaultContext[key]; } diff --git a/web-console/src/entry.tsx b/web-console/src/entry.tsx index 25518ecdeb9..0e698a3f84b 100644 --- a/web-console/src/entry.tsx +++ b/web-console/src/entry.tsx @@ -28,6 +28,7 @@ import { createRoot } from 'react-dom/client'; import { bootstrapJsonParse } from './bootstrap/json-parser'; import { bootstrapReactTable } from './bootstrap/react-table-defaults'; import { ConsoleApplication } from './console-application'; +import type { QueryContext } from './druid-models'; import type { Links } from './links'; import { setLinkOverrides } from './links'; import { Api, UrlBaser } from './singletons'; @@ -55,11 +56,16 @@ interface ConsoleConfig { // A set of custom headers name/value to set on every AJAX request customHeaders?: Record; - // The query context to set if the user does not have one saved in local storage, defaults to {} - defaultQueryContext?: Record; + baseQueryContext?: QueryContext; + + // The query context to set one new query tabs + defaultQueryContext?: QueryContext; // Extra context properties that will be added to all query requests - mandatoryQueryContext?: Record; + mandatoryQueryContext?: QueryContext; + + // The default context that is set by the server + serverQueryContext?: QueryContext; // Allow for link overriding to different docs linkOverrides?: Links; @@ -104,8 +110,10 @@ QueryRunner.defaultQueryExecutor = (payload, isSql, cancelToken) => { createRoot(container).render( , ); diff --git a/web-console/src/helpers/execution/sql-task-execution.ts b/web-console/src/helpers/execution/sql-task-execution.ts index 0fa9b090959..f4dd45a2cb9 100644 --- a/web-console/src/helpers/execution/sql-task-execution.ts +++ b/web-console/src/helpers/execution/sql-task-execution.ts @@ -36,6 +36,7 @@ function ensureExecutionModeIsSet(context: QueryContext | undefined): QueryConte export interface SubmitTaskQueryOptions { query: string | Record; context?: QueryContext; + baseQueryContext?: QueryContext; prefixLines?: number; cancelToken?: CancelToken; preserveOnTermination?: boolean; @@ -45,7 +46,15 @@ export interface SubmitTaskQueryOptions { export async function submitTaskQuery( options: SubmitTaskQueryOptions, ): Promise> { - const { query, context, prefixLines, cancelToken, preserveOnTermination, onSubmitted } = options; + const { + query, + context, + baseQueryContext, + prefixLines, + cancelToken, + preserveOnTermination, + onSubmitted, + } = options; let sqlQuery: string; let jsonQuery: Record; @@ -53,7 +62,7 @@ export async function submitTaskQuery( sqlQuery = query; jsonQuery = { query: sqlQuery, - context: ensureExecutionModeIsSet(context), + context: ensureExecutionModeIsSet({ ...baseQueryContext, ...context }), resultFormat: 'array', header: true, typesHeader: true, @@ -65,6 +74,7 @@ export async function submitTaskQuery( jsonQuery = { ...query, context: ensureExecutionModeIsSet({ + ...baseQueryContext, ...query.context, ...context, }), @@ -96,7 +106,7 @@ export async function submitTaskQuery( ); } - const execution = Execution.fromAsyncStatus(sqlAsyncStatus, sqlQuery, context); + const execution = Execution.fromAsyncStatus(sqlAsyncStatus, sqlQuery, jsonQuery.context); if (onSubmitted) { onSubmitted(execution.id); diff --git a/web-console/src/utils/general.tsx b/web-console/src/utils/general.tsx index a3256c3ab11..7698d3c3af8 100644 --- a/web-console/src/utils/general.tsx +++ b/web-console/src/utils/general.tsx @@ -389,6 +389,12 @@ export function assemble(...xs: (T | undefined | false | null | '')[]): T[] { return compact(xs); } +export function removeUndefinedValues>(obj: T): Partial { + return Object.fromEntries( + Object.entries(obj).filter(([_, value]) => value !== undefined), + ) as Partial; +} + export function moveToEnd( xs: T[], predicate: (value: T, index: number, array: T[]) => unknown, diff --git a/web-console/src/utils/values-query.spec.tsx b/web-console/src/utils/values-query.spec.tsx index 99884f382c1..7bc093bc3e8 100644 --- a/web-console/src/utils/values-query.spec.tsx +++ b/web-console/src/utils/values-query.spec.tsx @@ -45,6 +45,7 @@ describe('queryResultToValuesQuery', () => { [2, 3], null, ], + [null, null, null, null, null, null, null], ], false, true, @@ -64,7 +65,8 @@ describe('queryResultToValuesQuery', () => { FROM ( VALUES ('2022-02-01T00:00:00.000Z', 'brokerA.internal', 'broker', '{"type":"sys","swap/free":1223334,"swap/max":3223334}', 'es<#>es-419', '1', NULL), - ('2022-02-01T00:00:00.000Z', 'brokerA.internal', 'broker', '{"type":"query","time":1223,"bytes":2434234}', 'en<#>es<#>es-419', '2<#>3', NULL) + ('2022-02-01T00:00:00.000Z', 'brokerA.internal', 'broker', '{"type":"query","time":1223,"bytes":2434234}', 'en<#>es<#>es-419', '2<#>3', NULL), + (NULL, NULL, NULL, NULL, NULL, NULL, NULL) ) AS "t" ("c1", "c2", "c3", "c4", "c5", "c6", "c7") `); }); diff --git a/web-console/src/utils/values-query.tsx b/web-console/src/utils/values-query.tsx index 2f1a5f699ca..1b5e62b44c2 100644 --- a/web-console/src/utils/values-query.tsx +++ b/web-console/src/utils/values-query.tsx @@ -65,28 +65,29 @@ export function queryResultToValuesQuery(sample: QueryResult): SqlQuery { expression: SqlValues.create( rows.map(row => SqlRecord.create( - row.map((r, i) => { + row.map((d, i) => { + if (d == null) return L.NULL; const column = header[i]; const { nativeType } = column; const sqlType = getEffectiveSqlType(column); if (nativeType === 'COMPLEX') { - return L(isJsonString(r) ? r : JSONBig.stringify(r)); + return L(isJsonString(d) ? d : JSONBig.stringify(d)); } else if (String(sqlType).endsWith(' ARRAY')) { - return L(r.join(SAMPLE_ARRAY_SEPARATOR)); + return L(d.join(SAMPLE_ARRAY_SEPARATOR)); } else if ( sqlType === 'OTHER' && String(nativeType).startsWith('COMPLEX<') && - typeof r === 'string' && - r.startsWith('"') && - r.endsWith('"') + typeof d === 'string' && + d.startsWith('"') && + d.endsWith('"') ) { - // r is a JSON encoded base64 string - return L(r.slice(1, -1)); - } else if (typeof r === 'object') { + // d is a JSON encoded base64 string + return L(d.slice(1, -1)); + } else if (typeof d === 'object') { // Cleanup array if it happens to get here, it shouldn't. return L.NULL; } else { - return L(r); + return L(d); } }), ), diff --git a/web-console/src/views/sql-data-loader-view/sql-data-loader-view.tsx b/web-console/src/views/sql-data-loader-view/sql-data-loader-view.tsx index 6cc00957b8d..2e10734a170 100644 --- a/web-console/src/views/sql-data-loader-view/sql-data-loader-view.tsx +++ b/web-console/src/views/sql-data-loader-view/sql-data-loader-view.tsx @@ -30,6 +30,7 @@ import type { QueryWithContext, } from '../../druid-models'; import { + DEFAULT_SERVER_QUERY_CONTEXT, Execution, externalConfigToIngestQueryPattern, ingestQueryPatternToQuery, @@ -65,12 +66,20 @@ export interface SqlDataLoaderViewProps { goToTask(taskId: string): void; goToTaskGroup(taskGroupId: string): void; getClusterCapacity: (() => Promise) | undefined; + serverQueryContext?: QueryContext; } export const SqlDataLoaderView = React.memo(function SqlDataLoaderView( props: SqlDataLoaderViewProps, ) { - const { capabilities, goToQuery, goToTask, goToTaskGroup, getClusterCapacity } = props; + const { + capabilities, + goToQuery, + goToTask, + goToTaskGroup, + getClusterCapacity, + serverQueryContext = DEFAULT_SERVER_QUERY_CONTEXT, + } = props; const [alertElement, setAlertElement] = useState(); const [externalConfigStep, setExternalConfigStep] = useState>({}); const [content, setContent] = useLocalStorageState( @@ -187,6 +196,7 @@ export const SqlDataLoaderView = React.memo(function SqlDataLoaderView( clusterCapacity={capabilities.getMaxTaskSlots()} queryContext={content.queryContext || {}} changeQueryContext={queryContext => setContent({ ...content, queryContext })} + defaultQueryContext={serverQueryContext} minimal /> } diff --git a/web-console/src/views/workbench-view/max-tasks-button/max-tasks-button.spec.tsx b/web-console/src/views/workbench-view/max-tasks-button/max-tasks-button.spec.tsx index 5954c1f3f9b..1ae864dee06 100644 --- a/web-console/src/views/workbench-view/max-tasks-button/max-tasks-button.spec.tsx +++ b/web-console/src/views/workbench-view/max-tasks-button/max-tasks-button.spec.tsx @@ -18,6 +18,7 @@ import React from 'react'; +import { DEFAULT_SERVER_QUERY_CONTEXT } from '../../../druid-models'; import { shallow } from '../../../utils/shallow-renderer'; import { MaxTasksButton } from './max-tasks-button'; @@ -25,7 +26,12 @@ import { MaxTasksButton } from './max-tasks-button'; describe('MaxTasksButton', () => { it('matches snapshot', () => { const comp = shallow( - {}} />, + {}} + defaultQueryContext={DEFAULT_SERVER_QUERY_CONTEXT} + />, ); expect(comp).toMatchSnapshot(); diff --git a/web-console/src/views/workbench-view/max-tasks-button/max-tasks-button.tsx b/web-console/src/views/workbench-view/max-tasks-button/max-tasks-button.tsx index ea239bc3a26..c84f9f00e37 100644 --- a/web-console/src/views/workbench-view/max-tasks-button/max-tasks-button.tsx +++ b/web-console/src/views/workbench-view/max-tasks-button/max-tasks-button.tsx @@ -19,37 +19,38 @@ import type { ButtonProps } from '@blueprintjs/core'; import { Button, Menu, MenuDivider, MenuItem, Popover, Position } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; +import type { JSX } from 'react'; import React, { useState } from 'react'; import { NumericInputDialog } from '../../../dialogs'; -import type { QueryContext } from '../../../druid-models'; -import { - changeMaxNumTasks, - changeTaskAssigment, - getMaxNumTasks, - getTaskAssigment, -} from '../../../druid-models'; -import { formatInteger, tickIcon } from '../../../utils'; +import type { QueryContext, TaskAssignment } from '../../../druid-models'; +import { getQueryContextKey } from '../../../druid-models'; +import { deleteKeys, formatInteger, tickIcon } from '../../../utils'; const MAX_NUM_TASK_OPTIONS = [2, 3, 4, 5, 7, 9, 11, 17, 33, 65, 129]; -const TASK_ASSIGNMENT_OPTIONS = ['max', 'auto']; +const TASK_ASSIGNMENT_OPTIONS: TaskAssignment[] = ['max', 'auto']; const TASK_ASSIGNMENT_DESCRIPTION: Record = { max: 'Use as many tasks as possible, up to the maximum.', auto: `Use as few tasks as possible without exceeding 512 MiB or 10,000 files per task, unless exceeding these limits is necessary to stay within 'maxNumTasks'. When calculating the size of files, the weighted size is used, which considers the file format and compression format used if any. When file sizes cannot be determined through directory listing (for example: http), behaves the same as 'max'.`, }; -const DEFAULT_MAX_NUM_LABEL_FN = (maxNum: number) => { +const DEFAULT_MAX_NUM_TASKS_LABEL_FN = (maxNum: number) => { if (maxNum === 2) return { text: formatInteger(maxNum), label: '(1 controller + 1 worker)' }; return { text: formatInteger(maxNum), label: `(1 controller + max ${maxNum - 1} workers)` }; }; +const DEFAULT_FULL_CLUSTER_CAPACITY_LABEL_FN = (clusterCapacity: number) => + `${formatInteger(clusterCapacity)} (full cluster capacity)`; + export interface MaxTasksButtonProps extends Omit { clusterCapacity: number | undefined; queryContext: QueryContext; changeQueryContext(queryContext: QueryContext): void; + defaultQueryContext: QueryContext; menuHeader?: JSX.Element; - maxNumLabelFn?: (maxNum: number) => { text: string; label?: string }; + maxTasksLabelFn?: (maxNum: number) => { text: string; label?: string }; + fullClusterCapacityLabelFn?: (clusterCapacity: number) => string; } export const MaxTasksButton = function MaxTasksButton(props: MaxTasksButtonProps) { @@ -57,19 +58,19 @@ export const MaxTasksButton = function MaxTasksButton(props: MaxTasksButtonProps clusterCapacity, queryContext, changeQueryContext, + defaultQueryContext, menuHeader, - maxNumLabelFn = DEFAULT_MAX_NUM_LABEL_FN, + maxTasksLabelFn = DEFAULT_MAX_NUM_TASKS_LABEL_FN, + fullClusterCapacityLabelFn = DEFAULT_FULL_CLUSTER_CAPACITY_LABEL_FN, ...rest } = props; const [customMaxNumTasksDialogOpen, setCustomMaxNumTasksDialogOpen] = useState(false); - const maxNumTasks = getMaxNumTasks(queryContext); - const taskAssigment = getTaskAssigment(queryContext); + const maxNumTasks = queryContext.maxNumTasks; + const taskAssigment = getQueryContextKey('taskAssignment', queryContext, defaultQueryContext); const fullClusterCapacity = - typeof clusterCapacity === 'number' - ? `${formatInteger(clusterCapacity)} (full cluster capacity)` - : undefined; + typeof clusterCapacity === 'number' ? fullClusterCapacityLabelFn(clusterCapacity) : undefined; const shownMaxNumTaskOptions = clusterCapacity ? MAX_NUM_TASK_OPTIONS.filter(_ => _ <= clusterCapacity) @@ -88,11 +89,11 @@ export const MaxTasksButton = function MaxTasksButton(props: MaxTasksButtonProps changeQueryContext(changeMaxNumTasks(queryContext, undefined))} + onClick={() => changeQueryContext(deleteKeys(queryContext, ['maxNumTasks']))} /> )} {shownMaxNumTaskOptions.map(m => { - const { text, label } = maxNumLabelFn(m); + const { text, label } = maxTasksLabelFn(m); return ( changeQueryContext(changeMaxNumTasks(queryContext, m))} + onClick={() => changeQueryContext({ ...queryContext, maxNumTasks: m })} /> ); })} @@ -124,7 +125,7 @@ export const MaxTasksButton = function MaxTasksButton(props: MaxTasksButtonProps } shouldDismissPopover={false} multiline - onClick={() => changeQueryContext(changeTaskAssigment(queryContext, t))} + onClick={() => changeQueryContext({ ...queryContext, taskAssignment: t })} /> ))} @@ -158,8 +159,8 @@ export const MaxTasksButton = function MaxTasksButton(props: MaxTasksButtonProps minValue={2} integer initValue={maxNumTasks || 2} - onSubmit={p => { - changeQueryContext(changeMaxNumTasks(queryContext, p)); + onSubmit={maxNumTasks => { + changeQueryContext({ ...queryContext, maxNumTasks }); }} onClose={() => setCustomMaxNumTasksDialogOpen(false)} /> diff --git a/web-console/src/views/workbench-view/query-tab/query-tab.tsx b/web-console/src/views/workbench-view/query-tab/query-tab.tsx index cf863a14387..acdfa67fad2 100644 --- a/web-console/src/views/workbench-view/query-tab/query-tab.tsx +++ b/web-console/src/views/workbench-view/query-tab/query-tab.tsx @@ -21,14 +21,14 @@ import { IconNames } from '@blueprintjs/icons'; import type { QueryResult } from '@druid-toolkit/query'; import { QueryRunner, SqlQuery } from '@druid-toolkit/query'; import axios from 'axios'; -import type { ComponentProps, JSX } from 'react'; +import type { JSX } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import SplitterLayout from 'react-splitter-layout'; import { useStore } from 'zustand'; import { Loader, QueryErrorPane } from '../../../components'; import type { CapacityInfo, DruidEngine, LastExecution, QueryContext } from '../../../druid-models'; -import { Execution, WorkbenchQuery } from '../../../druid-models'; +import { DEFAULT_SERVER_QUERY_CONTEXT, Execution, WorkbenchQuery } from '../../../druid-models'; import { executionBackgroundStatusCheck, reattachTaskExecution, @@ -60,6 +60,7 @@ import { FlexibleQueryInput } from '../flexible-query-input/flexible-query-input import { IngestSuccessPane } from '../ingest-success-pane/ingest-success-pane'; import { metadataStateStore } from '../metadata-state-store'; import { ResultTablePane } from '../result-table-pane/result-table-pane'; +import type { RunPanelProps } from '../run-panel/run-panel'; import { RunPanel } from '../run-panel/run-panel'; import { workStateStore } from '../work-state-store'; @@ -69,10 +70,16 @@ const queryRunner = new QueryRunner({ inflateDateStrategy: 'none', }); -export interface QueryTabProps { +export interface QueryTabProps + extends Pick< + RunPanelProps, + 'maxTasksMenuHeader' | 'enginesLabelFn' | 'maxTasksLabelFn' | 'fullClusterCapacityLabelFn' + > { query: WorkbenchQuery; id: string; mandatoryQueryContext: QueryContext | undefined; + baseQueryContext: QueryContext | undefined; + serverQueryContext: QueryContext; columnMetadata: readonly ColumnMetadata[] | undefined; onQueryChange(newQuery: WorkbenchQuery): void; onQueryTab(newQuery: WorkbenchQuery, tabName?: string): void; @@ -82,9 +89,6 @@ export interface QueryTabProps { clusterCapacity: number | undefined; goToTask(taskId: string): void; getClusterCapacity: (() => Promise) | undefined; - maxTaskMenuHeader?: JSX.Element; - enginesLabelFn?: ComponentProps['enginesLabelFn']; - maxTaskLabelFn?: ComponentProps['maxTaskLabelFn']; } export const QueryTab = React.memo(function QueryTab(props: QueryTabProps) { @@ -93,6 +97,8 @@ export const QueryTab = React.memo(function QueryTab(props: QueryTabProps) { id, columnMetadata, mandatoryQueryContext, + baseQueryContext, + serverQueryContext = DEFAULT_SERVER_QUERY_CONTEXT, onQueryChange, onQueryTab, onDetails, @@ -101,9 +107,10 @@ export const QueryTab = React.memo(function QueryTab(props: QueryTabProps) { clusterCapacity, goToTask, getClusterCapacity, - maxTaskMenuHeader, + maxTasksMenuHeader, enginesLabelFn, - maxTaskLabelFn, + maxTasksLabelFn, + fullClusterCapacityLabelFn, } = props; const [alertElement, setAlertElement] = useState(); @@ -196,6 +203,8 @@ export const QueryTab = React.memo(function QueryTab(props: QueryTabProps) { case 'sql-msq-task': return await submitTaskQuery({ query, + context: mandatoryQueryContext, + baseQueryContext, prefixLines, cancelToken, preserveOnTermination: true, @@ -227,6 +236,7 @@ export const QueryTab = React.memo(function QueryTab(props: QueryTabProps) { const resultPromise = queryRunner.runQuery({ query, extraQueryContext: mandatoryQueryContext, + defaultQueryContext: baseQueryContext, cancelToken: new axios.CancelToken(cancelFn => { nativeQueryCancelFnRef.current = cancelFn; }), @@ -404,10 +414,12 @@ export const QueryTab = React.memo(function QueryTab(props: QueryTabProps) { running={executionState.loading} queryEngines={queryEngines} clusterCapacity={clusterCapacity} + defaultQueryContext={{ ...serverQueryContext, ...baseQueryContext }} moreMenu={runMoreMenu} - maxTaskMenuHeader={maxTaskMenuHeader} + maxTasksMenuHeader={maxTasksMenuHeader} enginesLabelFn={enginesLabelFn} - maxTaskLabelFn={maxTaskLabelFn} + maxTasksLabelFn={maxTasksLabelFn} + fullClusterCapacityLabelFn={fullClusterCapacityLabelFn} /> {executionState.isLoading() && ( { }; }; -export interface RunPanelProps { +export interface RunPanelProps + extends Pick { query: WorkbenchQuery; onQueryChange(query: WorkbenchQuery): void; running: boolean; @@ -127,10 +107,10 @@ export interface RunPanelProps { onRun(preview: boolean): void | Promise; queryEngines: DruidEngine[]; clusterCapacity: number | undefined; + defaultQueryContext: QueryContext; moreMenu?: JSX.Element; - maxTaskMenuHeader?: JSX.Element; + maxTasksMenuHeader?: JSX.Element; enginesLabelFn?: (engine: DruidEngine | undefined) => { text: string; label?: string }; - maxTaskLabelFn?: ComponentProps['maxNumLabelFn']; } export const RunPanel = React.memo(function RunPanel(props: RunPanelProps) { @@ -143,9 +123,11 @@ export const RunPanel = React.memo(function RunPanel(props: RunPanelProps) { small, queryEngines, clusterCapacity, - maxTaskMenuHeader, - maxTaskLabelFn, + defaultQueryContext, + maxTasksMenuHeader, enginesLabelFn = DEFAULT_ENGINES_LABEL_FN, + maxTasksLabelFn, + fullClusterCapacityLabelFn, } = props; const [editContextDialogOpen, setEditContextDialogOpen] = useState(false); const [editParametersDialogOpen, setEditParametersDialogOpen] = useState(false); @@ -158,20 +140,52 @@ export const RunPanel = React.memo(function RunPanel(props: RunPanelProps) { const numContextKeys = Object.keys(queryContext).length; const queryParameters = query.queryParameters; - const arrayIngestMode = getArrayIngestMode(queryContext); - const maxParseExceptions = getMaxParseExceptions(queryContext); - const failOnEmptyInsert = getFailOnEmptyInsert(queryContext); - const finalizeAggregations = getFinalizeAggregations(queryContext); - const waitUntilSegmentsLoad = getWaitUntilSegmentsLoad(queryContext); - const groupByEnableMultiValueUnnesting = getGroupByEnableMultiValueUnnesting(queryContext); - const sqlJoinAlgorithm = queryContext.sqlJoinAlgorithm ?? 'broadcast'; - const selectDestination = queryContext.selectDestination ?? 'taskReport'; - const durableShuffleStorage = getDurableShuffleStorage(queryContext); + // Extract the context parts that have UI + const sqlTimeZone = queryContext.sqlTimeZone; + + const useCache = getQueryContextKey('useCache', queryContext, defaultQueryContext); + const useApproximateTopN = getQueryContextKey( + 'useApproximateTopN', + queryContext, + defaultQueryContext, + ); + const useApproximateCountDistinct = getQueryContextKey( + 'useApproximateCountDistinct', + queryContext, + defaultQueryContext, + ); + + const arrayIngestMode = queryContext.arrayIngestMode; + const maxParseExceptions = getQueryContextKey( + 'maxParseExceptions', + queryContext, + defaultQueryContext, + ); + const failOnEmptyInsert = getQueryContextKey( + 'failOnEmptyInsert', + queryContext, + defaultQueryContext, + ); + const finalizeAggregations = queryContext.finalizeAggregations; + const waitUntilSegmentsLoad = queryContext.waitUntilSegmentsLoad; + const groupByEnableMultiValueUnnesting = queryContext.groupByEnableMultiValueUnnesting; + const sqlJoinAlgorithm = getQueryContextKey( + 'sqlJoinAlgorithm', + queryContext, + defaultQueryContext, + ); + const selectDestination = getQueryContextKey( + 'selectDestination', + queryContext, + defaultQueryContext, + ); + const durableShuffleStorage = getQueryContextKey( + 'durableShuffleStorage', + queryContext, + defaultQueryContext, + ); + const indexSpec: IndexSpec | undefined = deepGet(queryContext, 'indexSpec'); - const useApproximateCountDistinct = getUseApproximateCountDistinct(queryContext); - const useApproximateTopN = getUseApproximateTopN(queryContext); - const useCache = getUseCache(queryContext); - const timezone = getTimezone(queryContext); const handleRun = useCallback(() => { if (!onRun) return; @@ -210,7 +224,7 @@ export const RunPanel = React.memo(function RunPanel(props: RunPanelProps) { const queryEngine = query.engine; function changeQueryContext(queryContext: QueryContext) { - onQueryChange(query.changeQueryContext(queryContext)); + onQueryChange(query.changeQueryContext(removeUndefinedValues(queryContext))); } function offsetOptions(): JSX.Element[] { @@ -221,10 +235,10 @@ export const RunPanel = React.memo(function RunPanel(props: RunPanelProps) { items.push( changeQueryContext(changeTimezone(queryContext, offset))} + onClick={() => changeQueryContext({ ...queryContext, sqlTimeZone: offset })} />, ); } @@ -315,29 +329,32 @@ export const RunPanel = React.memo(function RunPanel(props: RunPanelProps) { changeQueryContext(changeTimezone(queryContext, undefined))} + onClick={() => + changeQueryContext({ ...queryContext, sqlTimeZone: undefined }) + } /> - + {NAMED_TIMEZONES.map(namedTimezone => ( - changeQueryContext(changeTimezone(queryContext, namedTimezone)) + changeQueryContext({ ...queryContext, sqlTimeZone: namedTimezone }) } /> ))} - + {offsetOptions()} - changeQueryContext(changeMaxParseExceptions(queryContext, v)) + changeQueryContext({ ...queryContext, maxParseExceptions: v }) } shouldDismissPopover={false} /> @@ -371,8 +388,8 @@ export const RunPanel = React.memo(function RunPanel(props: RunPanelProps) { text="Fail on empty insert" value={failOnEmptyInsert} undefinedEffectiveValue={false} - onValueChange={v => - changeQueryContext(changeFailOnEmptyInsert(queryContext, v)) + onValueChange={failOnEmptyInsert => + changeQueryContext({ ...queryContext, failOnEmptyInsert }) } /> - changeQueryContext(changeFinalizeAggregations(queryContext, v)) + onValueChange={finalizeAggregations => + changeQueryContext({ ...queryContext, finalizeAggregations }) } /> - changeQueryContext(changeWaitUntilSegmentsLoad(queryContext, v)) + onValueChange={waitUntilSegmentsLoad => + changeQueryContext({ ...queryContext, waitUntilSegmentsLoad }) } /> - changeQueryContext(changeGroupByEnableMultiValueUnnesting(queryContext, v)) + onValueChange={groupByEnableMultiValueUnnesting => + changeQueryContext({ ...queryContext, groupByEnableMultiValueUnnesting }) } /> - {['broadcast', 'sortMerge'].map(o => ( + {(['broadcast', 'sortMerge'] as SqlJoinAlgorithm[]).map(o => ( - changeQueryContext(deepSet(queryContext, 'sqlJoinAlgorithm', o)) + changeQueryContext({ ...queryContext, sqlJoinAlgorithm: o }) } /> ))} @@ -425,14 +442,14 @@ export const RunPanel = React.memo(function RunPanel(props: RunPanelProps) { label={selectDestination} intent={intent} > - {['taskReport', 'durableStorage'].map(o => ( + {(['taskReport', 'durableStorage'] as SelectDestination[]).map(o => ( - changeQueryContext(deepSet(queryContext, 'selectDestination', o)) + changeQueryContext({ ...queryContext, selectDestination: o }) } /> ))} @@ -454,9 +471,10 @@ export const RunPanel = React.memo(function RunPanel(props: RunPanelProps) { checked={durableShuffleStorage} text="Durable shuffle storage" onChange={() => - changeQueryContext( - changeDurableShuffleStorage(queryContext, !durableShuffleStorage), - ) + changeQueryContext({ + ...queryContext, + durableShuffleStorage: !durableShuffleStorage, + }) } /> changeQueryContext(changeUseCache(queryContext, !useCache))} + onChange={() => + changeQueryContext({ + ...queryContext, + useCache: !useCache, + populateCache: !useCache, + }) + } /> - changeQueryContext( - changeUseApproximateTopN(queryContext, !useApproximateTopN), - ) + changeQueryContext({ + ...queryContext, + useApproximateTopN: !useApproximateTopN, + }) } /> @@ -492,12 +517,10 @@ export const RunPanel = React.memo(function RunPanel(props: RunPanelProps) { checked={useApproximateCountDistinct} text="Use approximate COUNT(DISTINCT)" onChange={() => - changeQueryContext( - changeUseApproximateCountDistinct( - queryContext, - !useApproximateCountDistinct, - ), - ) + changeQueryContext({ + ...queryContext, + useApproximateCountDistinct: !useApproximateCountDistinct, + }) } /> )} @@ -519,8 +542,9 @@ export const RunPanel = React.memo(function RunPanel(props: RunPanelProps) { >