diff --git a/ci/confs/authenticated/server.conf b/ci/confs/authenticated/server.conf index d30f878..a1a19b1 100644 --- a/ci/confs/authenticated/server.conf +++ b/ci/confs/authenticated/server.conf @@ -40,7 +40,7 @@ config.validation.strict=true ################ HTTP settings ################## # enable HTTP server -http.enabled=false +http.enabled=true # IP address and port of HTTP server #http.net.bind.to=0.0.0.0:9000 diff --git a/ci/confs/default/server.conf b/ci/confs/default/server.conf index f96a35e..dfe5419 100644 --- a/ci/confs/default/server.conf +++ b/ci/confs/default/server.conf @@ -40,7 +40,7 @@ config.validation.strict=true ################ HTTP settings ################## # enable HTTP server -http.enabled=false +http.enabled=true # IP address and port of HTTP server #http.net.bind.to=0.0.0.0:9000 diff --git a/ci/questdb_stop.yaml b/ci/questdb_stop.yaml index 3b6c0d9..5b56935 100644 --- a/ci/questdb_stop.yaml +++ b/ci/questdb_stop.yaml @@ -5,7 +5,7 @@ steps: rm -f questdb.pid rm -rf questdb-data displayName: "Stop QuestDB server (non-Windows)" - condition: ne(variables['Agent.OS'], 'Windows_NT') + condition: and(always(), ne(variables['Agent.OS'], 'Windows_NT')) - pwsh: | Write-Host "Stopping QuestDB server..." if (Test-Path "questdb.pid") { @@ -36,4 +36,4 @@ steps: } } displayName: "Stop QuestDB server (Windows)" - condition: eq(variables['Agent.OS'], 'Windows_NT') + condition: and(always(), eq(variables['Agent.OS'], 'Windows_NT')) diff --git a/ci/run_oss_tests.yaml b/ci/run_oss_tests.yaml index 91cde3a..7e8feed 100644 --- a/ci/run_oss_tests.yaml +++ b/ci/run_oss_tests.yaml @@ -1,7 +1,7 @@ # Run the tests from OSS using the current client version steps: - task: Maven@3 - displayName: "Run OSS line tests" + displayName: "Run server line tests" inputs: mavenPOMFile: "questdb/pom.xml" jdkVersionOption: "default" diff --git a/ci/run_tests_pipeline.yaml b/ci/run_tests_pipeline.yaml index cc1d618..00c0f8c 100644 --- a/ci/run_tests_pipeline.yaml +++ b/ci/run_tests_pipeline.yaml @@ -30,9 +30,9 @@ stages: displayName: "Checkout PR source branch" condition: eq(variables['Build.Reason'], 'PullRequest') - task: JavaToolInstaller@0 - displayName: "Install Java 11" + displayName: "Install Java 17" inputs: - versionSpec: "11" + versionSpec: "17" jdkArchitectureOption: "x64" jdkSourceOption: "PreInstalled" - bash: mvn -f core/pom.xml javadoc:javadoc -Pjavadoc --batch-mode @@ -72,7 +72,18 @@ stages: lfs: false submodules: false - template: setup.yaml - - script: git clone --depth 1 https://github.com/questdb/questdb.git ./questdb + - bash: echo "##vso[task.setvariable variable=HOME]$USERPROFILE" + displayName: "Set HOME on Windows" + condition: eq(variables['Agent.OS'], 'Windows_NT') + - task: Cache@2 + inputs: + key: 'maven | "$(Agent.OS)" | **/pom.xml' + restoreKeys: | + maven | "$(Agent.OS)" + path: $(HOME)/.m2/repository + displayName: "Cache Maven repository" + # TODO: remove branch once jh_experiment_new_ilp is merged + - script: git clone --depth 1 -b jh_experiment_new_ilp https://github.com/questdb/questdb.git ./questdb displayName: git clone questdb - task: Maven@3 displayName: "Update client version" @@ -88,27 +99,11 @@ stages: jdkVersionOption: "default" goals: "install" options: "-DskipTests --batch-mode" - - task: Maven@3 - displayName: "Update QuestDB client version: core" - inputs: - mavenPOMFile: "questdb/core/pom.xml" - jdkVersionOption: "default" - goals: "versions:use-dep-version" - options: "-Dincludes=org.questdb:client -DdepVersion=9.9.9 -DforceVersion=true --batch-mode" - - task: Maven@3 - displayName: "Update QuestDB client version: benchmarks" - inputs: - mavenPOMFile: "questdb/benchmarks/pom.xml" - jdkVersionOption: "default" - goals: "versions:use-dep-version" - options: "-Dincludes=org.questdb:client -DdepVersion=9.9.9 -DforceVersion=true --batch-mode" - - task: Maven@3 - displayName: "Update QuestDB client version: utils" - inputs: - mavenPOMFile: "questdb/utils/pom.xml" - jdkVersionOption: "default" - goals: "versions:use-dep-version" - options: "-Dincludes=org.questdb:client -DdepVersion=9.9.9 -DforceVersion=true --batch-mode" + - bash: | + sed -i.bak 's|.*|9.9.9|' \ + questdb/core/pom.xml questdb/benchmarks/pom.xml questdb/utils/pom.xml + rm -f questdb/core/pom.xml.bak questdb/benchmarks/pom.xml.bak questdb/utils/pom.xml.bak + displayName: "Update QuestDB client version to 9.9.9" - task: Maven@3 displayName: "Compile QuestDB" inputs: diff --git a/core/pom.xml b/core/pom.xml index 1ccaa98..a117725 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -32,7 +32,7 @@ false target none - 11 + 17 -ea -Dfile.encoding=UTF-8 -XX:+UseParallelGC None %regex[.*[^o].class] @@ -198,7 +198,7 @@ none - 11 + 17 false ${compilerArg1} @@ -296,7 +296,7 @@ none - 11 + 17 false ${compilerArg1} @@ -384,20 +384,20 @@ - java11+ + java17+ - 11 - 11 + 17 + 17 questdb --add-exports java.base/jdk.internal.math=io.questdb.client - nothing-to-exclude-dummy-value-include-all-java11plus - nothing-to-exclude-dummy-value-include-all-java11plus + nothing-to-exclude-dummy-value-include-all-java17plus + nothing-to-exclude-dummy-value-include-all-java17plus ${javac.target} ${javac.target} - (11,) + [17,) diff --git a/core/src/main/java/io/questdb/client/BuildInformationHolder.java b/core/src/main/java/io/questdb/client/BuildInformationHolder.java index e18f961..825a101 100644 --- a/core/src/main/java/io/questdb/client/BuildInformationHolder.java +++ b/core/src/main/java/io/questdb/client/BuildInformationHolder.java @@ -41,7 +41,7 @@ public BuildInformationHolder(Class clazz) { String swVersion; try { final Attributes manifestAttributes = getManifestAttributes(clazz); - swVersion = getAttr(manifestAttributes, "QuestDB-Client-Version", "[DEVELOPMENT]"); + swVersion = getAttr(manifestAttributes, "[DEVELOPMENT]"); } catch (IOException e) { swVersion = UNKNOWN; } @@ -57,8 +57,8 @@ public String getSwVersion() { return swVersion; } - private static String getAttr(final Attributes manifestAttributes, String attributeName, String defaultValue) { - final String value = manifestAttributes.getValue(attributeName); + private static String getAttr(final Attributes manifestAttributes, String defaultValue) { + final String value = manifestAttributes.getValue("QuestDB-Client-Version"); return value != null ? value : defaultValue; } diff --git a/core/src/main/java/io/questdb/client/HttpClientConfiguration.java b/core/src/main/java/io/questdb/client/HttpClientConfiguration.java index c2f644e..b02d485 100644 --- a/core/src/main/java/io/questdb/client/HttpClientConfiguration.java +++ b/core/src/main/java/io/questdb/client/HttpClientConfiguration.java @@ -54,6 +54,10 @@ default int getMaximumRequestBufferSize() { return Integer.MAX_VALUE; } + default int getMaximumResponseBufferSize() { + return Integer.MAX_VALUE; + } + default NetworkFacade getNetworkFacade() { return NetworkFacadeImpl.INSTANCE; } diff --git a/core/src/main/java/io/questdb/client/ParanoiaState.java b/core/src/main/java/io/questdb/client/ParanoiaState.java deleted file mode 100644 index 6141660..0000000 --- a/core/src/main/java/io/questdb/client/ParanoiaState.java +++ /dev/null @@ -1,84 +0,0 @@ -/******************************************************************************* - * ___ _ ____ ____ - * / _ \ _ _ ___ ___| |_| _ \| __ ) - * | | | | | | |/ _ \/ __| __| | | | _ \ - * | |_| | |_| | __/\__ \ |_| |_| | |_) | - * \__\_\\__,_|\___||___/\__|____/|____/ - * - * Copyright (c) 2014-2019 Appsicle - * Copyright (c) 2019-2026 QuestDB - * - * Licensed 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. - * - ******************************************************************************/ - -package io.questdb.client; - -// Constants that enable various diagnostics to catch leaks, double closes, etc. -public class ParanoiaState { - /** - *
-     * BASIC -> validates UTF-8 in log records (throws a LogError if invalid),
-     *          throws a LogError on abandoned log records (missing .$() at the end of log statement),
-     *          detects closed stdout in LogConsoleWriter.
-     *          This introduces a low overhead to logging.
-     * AGGRESSIVE -> BASIC + holds recent history of log lines to help diagnose closed stdout,
-     *               holds the stack trace of abandoned log record.
-     *               This introduces a significant overhead to logging.
-     *
-     * When running inside JUnit/Surefire, BASIC log paranoia mode gets activated automatically.
-     * You can manually edit the code in the static { } block below to activate AGGRESSIVE instead.
-     *
-     * Logs may go silent when Maven Surefire plugin closes stdout due to broken text encoding.
-     * In BASIC mode, the log writer will detect this and print errors through System.out, which
-     * under Surefire uses an alternate channel and not stdout.
-     * In AGGRESSIVE mode, it will additionally remember the most recent log lines and print them.
-     * This will help you find the offending log line with broken encoding.
-     *
-     * The logging framework detects a common coding error where you forget to end a log statement
-     * with .$(), causing the statement not to be logged. This problem can only be detected after
-     * the fact, when you start a new log record and the previous one wasn't completed.
-     *
-     * With Log Paranoia off (LOG_PARANOIA_MODE_NONE), we only detect this problem and print an
-     * error message.
-     * In BASIC mode, we throw a LogError without a stack trace.
-     * In AGGRESSIVE mode, we capture the stack trace at every start of a log statement, so when
-     * we throw the LogError, it points to the code that created and then abandoned the log record.
-     * 
- */ - public static final int LOG_PARANOIA_MODE; - public static final int LOG_PARANOIA_MODE_AGGRESSIVE = 2; - public static final int LOG_PARANOIA_MODE_BASIC = 1; - public static final int LOG_PARANOIA_MODE_NONE = 0; - // Set to true to enable Thread Local path instances created/closed stack trace logs. - public static final boolean THREAD_LOCAL_PATH_PARANOIA_MODE = false; - // Set to true to enable stricter boundary checks on Vm memories implementations. - public static final boolean VM_PARANOIA_MODE = false; - // Set to true to enable stricter File Descriptor double close checks, trace closed usages. - public static boolean FD_PARANOIA_MODE = false; - - public static boolean isInsideJUnitTest() { - StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace(); - for (StackTraceElement element : stackTrace) { - String className = element.getClassName(); - if (className.startsWith("org.apache.maven.surefire") || className.startsWith("org.junit.")) { - return true; - } - } - return false; - } - - static { - LOG_PARANOIA_MODE = isInsideJUnitTest() ? LOG_PARANOIA_MODE_BASIC : LOG_PARANOIA_MODE_NONE; - } -} \ No newline at end of file diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index c63609f..c815bf7 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -34,6 +34,7 @@ import io.questdb.client.cutlass.line.http.AbstractLineHttpSender; import io.questdb.client.cutlass.line.tcp.DelegatingTlsChannel; import io.questdb.client.cutlass.line.tcp.PlainTcpLineChannel; +import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; import io.questdb.client.impl.ConfStringParser; import io.questdb.client.network.NetworkFacade; import io.questdb.client.network.NetworkFacadeImpl; @@ -95,7 +96,7 @@ * 2. Call {@link #reset()} to clear the internal buffers and start building a new row *
* Note: If the underlying error is permanent, retrying {@link #flush()} will fail again. - * Use {@link #reset()} to discard the problematic data and continue with new data. See {@link LineSenderException#isRetryable()} + * Use {@link #reset()} to discard the problematic data and continue with new data. * */ public interface Sender extends Closeable, ArraySender { @@ -108,7 +109,7 @@ public interface Sender extends Closeable, ArraySender { /** * Create a Sender builder instance from a configuration string. *
- * This allows to use the configuration string as a template for creating a Sender builder instance and then + * This allows using the configuration string as a template for creating a Sender builder instance and then * tune options which are not available in the configuration string. Configurations options specified in the * configuration string cannot be overridden via the builder methods. *

@@ -144,7 +145,12 @@ static LineSenderBuilder builder(CharSequence configurationString) { * @return Builder object to create a new Sender instance. */ static LineSenderBuilder builder(Transport transport) { - return new LineSenderBuilder(transport == Transport.HTTP ? LineSenderBuilder.PROTOCOL_HTTP : LineSenderBuilder.PROTOCOL_TCP); + int protocol = switch (transport) { + case HTTP -> LineSenderBuilder.PROTOCOL_HTTP; + case TCP -> LineSenderBuilder.PROTOCOL_TCP; + case WEBSOCKET -> LineSenderBuilder.PROTOCOL_WEBSOCKET; + }; + return new LineSenderBuilder(protocol); } /** @@ -461,7 +467,15 @@ enum Transport { * and for use-cases where HTTP transport is not suitable, when communicating with a QuestDB server over a high-latency * network */ - TCP + TCP, + + /** + * Use WebSocket transport to communicate with a QuestDB server. + *

+ * WebSocket transport uses the ILP v4 binary protocol for efficient data ingestion. + * It supports both synchronous and asynchronous modes with flow control. + */ + WEBSOCKET } /** @@ -508,12 +522,17 @@ final class LineSenderBuilder { private static final int DEFAULT_BUFFER_CAPACITY = 64 * 1024; private static final int DEFAULT_HTTP_PORT = 9000; private static final int DEFAULT_HTTP_TIMEOUT = 30_000; + private static final int DEFAULT_IN_FLIGHT_WINDOW_SIZE = 8; private static final int DEFAULT_MAXIMUM_BUFFER_CAPACITY = 100 * 1024 * 1024; private static final int DEFAULT_MAX_BACKOFF_MILLIS = 1_000; private static final int DEFAULT_MAX_NAME_LEN = 127; private static final long DEFAULT_MAX_RETRY_NANOS = TimeUnit.SECONDS.toNanos(10); // keep sync with the contract of the configuration method private static final long DEFAULT_MIN_REQUEST_THROUGHPUT = 100 * 1024; // 100KB/s, keep in sync with the contract of the configuration method private static final int DEFAULT_TCP_PORT = 9009; + private static final int DEFAULT_WEBSOCKET_PORT = 9000; + private static final int DEFAULT_WS_AUTO_FLUSH_BYTES = 1024 * 1024; // 1MB + private static final long DEFAULT_WS_AUTO_FLUSH_INTERVAL_NANOS = 100_000_000L; // 100ms + private static final int DEFAULT_WS_AUTO_FLUSH_ROWS = 500; private static final int MIN_BUFFER_SIZE = AuthUtils.CHALLENGE_LEN + 1; // challenge size + 1; // The PARAMETER_NOT_SET_EXPLICITLY constant is used to detect if a parameter was set explicitly in configuration parameters // where it matters. This is needed to detect invalid combinations of parameters. Why? @@ -522,8 +541,11 @@ final class LineSenderBuilder { private static final int PARAMETER_NOT_SET_EXPLICITLY = -1; private static final int PROTOCOL_HTTP = 1; private static final int PROTOCOL_TCP = 0; + private static final int PROTOCOL_WEBSOCKET = 2; private final ObjList hosts = new ObjList<>(); private final IntList ports = new IntList(); + private boolean asyncMode = false; + private int autoFlushBytes = PARAMETER_NOT_SET_EXPLICITLY; private int autoFlushIntervalMillis = PARAMETER_NOT_SET_EXPLICITLY; private int autoFlushRows = PARAMETER_NOT_SET_EXPLICITLY; private int bufferCapacity = PARAMETER_NOT_SET_EXPLICITLY; @@ -531,6 +553,7 @@ final class LineSenderBuilder { private String httpSettingsPath; private int httpTimeout = PARAMETER_NOT_SET_EXPLICITLY; private String httpToken; + private int inFlightWindowSize = PARAMETER_NOT_SET_EXPLICITLY; private String keyId; private int maxBackoffMillis = PARAMETER_NOT_SET_EXPLICITLY; private int maxNameLength = PARAMETER_NOT_SET_EXPLICITLY; @@ -658,6 +681,47 @@ public AdvancedTlsSettings advancedTls() { return new AdvancedTlsSettings(); } + /** + * Enable asynchronous mode for WebSocket transport. + *
+ * In async mode, rows are batched and sent asynchronously with flow control. + * This provides higher throughput at the cost of more complex error handling. + *
+ * This is only used when communicating over WebSocket transport. + *
+ * Default is synchronous mode (false). + * + * @param enabled whether to enable async mode + * @return this instance for method chaining + */ + public LineSenderBuilder asyncMode(boolean enabled) { + this.asyncMode = enabled; + return this; + } + + /** + * Set the maximum number of bytes per batch before auto-flushing. + *
+ * This is only used when communicating over WebSocket transport. + *
+ * Default value is 1MB. + * + * @param bytes maximum bytes per batch + * @return this instance for method chaining + */ + public LineSenderBuilder autoFlushBytes(int bytes) { + if (this.autoFlushBytes != PARAMETER_NOT_SET_EXPLICITLY) { + throw new LineSenderException("auto flush bytes was already configured") + .put("[bytes=").put(this.autoFlushBytes).put("]"); + } + if (bytes < 0) { + throw new LineSenderException("auto flush bytes cannot be negative") + .put("[bytes=").put(bytes).put("]"); + } + this.autoFlushBytes = bytes; + return this; + } + /** * Set the interval in milliseconds at which the Sender automatically flushes its buffer. *
@@ -791,6 +855,40 @@ public Sender build() { username, password, maxNameLength, actualMaxRetriesNanos, maxBackoffMillis, actualMinRequestThroughput, actualAutoFlushIntervalMillis, protocolVersion); } + if (protocol == PROTOCOL_WEBSOCKET) { + if (hosts.size() != 1 || ports.size() != 1) { + throw new LineSenderException("only a single address (host:port) is supported for WebSocket transport"); + } + + int actualAutoFlushRows = autoFlushRows == PARAMETER_NOT_SET_EXPLICITLY ? DEFAULT_WS_AUTO_FLUSH_ROWS : autoFlushRows; + int actualAutoFlushBytes = autoFlushBytes == PARAMETER_NOT_SET_EXPLICITLY ? DEFAULT_WS_AUTO_FLUSH_BYTES : autoFlushBytes; + long actualAutoFlushIntervalNanos = autoFlushIntervalMillis == PARAMETER_NOT_SET_EXPLICITLY + ? DEFAULT_WS_AUTO_FLUSH_INTERVAL_NANOS + : TimeUnit.MILLISECONDS.toNanos(autoFlushIntervalMillis); + int actualInFlightWindowSize = inFlightWindowSize == PARAMETER_NOT_SET_EXPLICITLY ? DEFAULT_IN_FLIGHT_WINDOW_SIZE : inFlightWindowSize; + + if (asyncMode) { + return QwpWebSocketSender.connectAsync( + hosts.getQuick(0), + ports.getQuick(0), + tlsEnabled, + actualAutoFlushRows, + actualAutoFlushBytes, + actualAutoFlushIntervalNanos, + actualInFlightWindowSize + ); + } else { + return QwpWebSocketSender.connect( + hosts.getQuick(0), + ports.getQuick(0), + tlsEnabled, + actualAutoFlushRows, + actualAutoFlushBytes, + actualAutoFlushIntervalNanos + ); + } + } + assert protocol == PROTOCOL_TCP; if (hosts.size() != 1 || ports.size() != 1) { @@ -1048,6 +1146,29 @@ public LineSenderBuilder httpUsernamePassword(String username, String password) return this; } + /** + * Set the maximum number of batches that can be in-flight awaiting server acknowledgment. + *
+ * This is only used when communicating over WebSocket transport with async mode enabled. + *
+ * Default value is 8. + * + * @param size maximum number of in-flight batches + * @return this instance for method chaining + */ + public LineSenderBuilder inFlightWindowSize(int size) { + if (this.inFlightWindowSize != PARAMETER_NOT_SET_EXPLICITLY) { + throw new LineSenderException("in-flight window size was already configured") + .put("[size=").put(this.inFlightWindowSize).put("]"); + } + if (size < 1) { + throw new LineSenderException("in-flight window size must be positive") + .put("[size=").put(size).put("]"); + } + this.inFlightWindowSize = size; + return this; + } + /** * Configures the maximum backoff time between retry attempts when the Sender encounters recoverable errors. *
@@ -1275,7 +1396,13 @@ private void configureDefaults() { maximumBufferCapacity = protocol == PROTOCOL_HTTP ? DEFAULT_MAXIMUM_BUFFER_CAPACITY : bufferCapacity; } if (ports.size() == 0) { - ports.add(protocol == PROTOCOL_HTTP ? DEFAULT_HTTP_PORT : DEFAULT_TCP_PORT); + if (protocol == PROTOCOL_HTTP) { + ports.add(DEFAULT_HTTP_PORT); + } else if (protocol == PROTOCOL_WEBSOCKET) { + ports.add(DEFAULT_WEBSOCKET_PORT); + } else { + ports.add(DEFAULT_TCP_PORT); + } } if (tlsValidationMode == null) { tlsValidationMode = TlsValidationMode.DEFAULT; @@ -1287,7 +1414,7 @@ private void configureDefaults() { if (maxNameLength == PARAMETER_NOT_SET_EXPLICITLY) { maxNameLength = DEFAULT_MAX_NAME_LEN; } - if (maxBackoffMillis == PARAMETER_NOT_SET_EXPLICITLY) { + if (maxBackoffMillis == PARAMETER_NOT_SET_EXPLICITLY && protocol == PROTOCOL_HTTP) { maxBackoffMillis = DEFAULT_MAX_BACKOFF_MILLIS; } } @@ -1334,8 +1461,16 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) { } else if (Chars.equals("tcps", sink)) { tcp(); tlsEnabled = true; + } else if (Chars.equals("ws", sink)) { + if (tlsEnabled) { + throw new LineSenderException("cannot use ws protocol when TLS is enabled. use wss instead"); + } + websocket(); + } else if (Chars.equals("wss", sink)) { + websocket(); + tlsEnabled = true; } else { - throw new LineSenderException("invalid schema [schema=").put(sink).put(", supported-schemas=[http, https, tcp, tcps]]"); + throw new LineSenderException("invalid schema [schema=").put(sink).put(", supported-schemas=[http, https, tcp, tcps, ws, wss]]"); } String tcpToken = null; @@ -1357,7 +1492,9 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) { address(sink); if (ports.size() == hosts.size() - 1) { // not set - port(protocol == PROTOCOL_TCP ? DEFAULT_TCP_PORT : DEFAULT_HTTP_PORT); + port(protocol == PROTOCOL_TCP ? DEFAULT_TCP_PORT + : protocol == PROTOCOL_WEBSOCKET ? DEFAULT_WEBSOCKET_PORT + : DEFAULT_HTTP_PORT); } } else if (Chars.equals("user", sink)) { // deprecated key: user, new key: username @@ -1414,7 +1551,7 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) { } else if (protocol == PROTOCOL_HTTP) { httpToken(sink.toString()); } else { - throw new AssertionError(); + throw new LineSenderException("token is not supported for WebSocket protocol"); } } else if (Chars.equals("retry_timeout", sink)) { pos = getValue(configurationString, pos, sink, "retry_timeout"); @@ -1617,12 +1754,52 @@ private void validateParameters() { if (autoFlushIntervalMillis != PARAMETER_NOT_SET_EXPLICITLY) { throw new LineSenderException("auto flush interval is not supported for TCP protocol"); } + } else if (protocol == PROTOCOL_WEBSOCKET) { + if (privateKey != null) { + throw new LineSenderException("TCP authentication is not supported for WebSocket protocol"); + } + if (httpToken != null || username != null || password != null) { + // TODO: WebSocket auth not yet implemented + throw new LineSenderException("Authentication is not yet supported for WebSocket protocol"); + } + if (inFlightWindowSize != PARAMETER_NOT_SET_EXPLICITLY && !asyncMode) { + throw new LineSenderException("in-flight window size requires async mode"); + } + if (httpPath != null) { + throw new LineSenderException("HTTP path is not supported for WebSocket protocol"); + } + if (httpTimeout != PARAMETER_NOT_SET_EXPLICITLY) { + throw new LineSenderException("HTTP timeout is not supported for WebSocket protocol"); + } + if (retryTimeoutMillis != PARAMETER_NOT_SET_EXPLICITLY) { + throw new LineSenderException("retry timeout is not supported for WebSocket protocol"); + } + if (minRequestThroughput != PARAMETER_NOT_SET_EXPLICITLY) { + throw new LineSenderException("minimum request throughput is not supported for WebSocket protocol"); + } + if (maxBackoffMillis != PARAMETER_NOT_SET_EXPLICITLY) { + throw new LineSenderException("max backoff is not supported for WebSocket protocol"); + } + if (protocolVersion != PARAMETER_NOT_SET_EXPLICITLY) { + throw new LineSenderException("protocol version is not supported for WebSocket protocol"); + } + if (autoFlushIntervalMillis == Integer.MAX_VALUE) { + throw new LineSenderException("disabling auto-flush is not supported for WebSocket protocol"); + } } else { throw new LineSenderException("unsupported protocol ") .put("[protocol=").put(protocol).put("]"); } } + private void websocket() { + if (protocol != PARAMETER_NOT_SET_EXPLICITLY) { + throw new LineSenderException("protocol was already configured ") + .put("[protocol=").put(protocol).put("]"); + } + protocol = PROTOCOL_WEBSOCKET; + } + public class AdvancedTlsSettings { /** * Configure a custom truststore. This is only needed when using {@link #enableTls()} when your default diff --git a/core/src/main/java/io/questdb/client/cairo/ColumnType.java b/core/src/main/java/io/questdb/client/cairo/ColumnType.java index b00046f..275650b 100644 --- a/core/src/main/java/io/questdb/client/cairo/ColumnType.java +++ b/core/src/main/java/io/questdb/client/cairo/ColumnType.java @@ -214,19 +214,6 @@ public static int encodeArrayType(int elemType, int nDims, boolean checkSupporte | ARRAY; } - /** - * Encodes an array type with weak dimensionality. The dimensionality is still - * encoded but marked as tentative and can be updated based on actual data. - * This is useful for PostgreSQL wire protocol where type information doesn't - * include array dimensions. - *

- * The number of dimensions of this type is undefined, so the decoded number on - * dimensions for the returned column type will be -1. - */ - public static int encodeArrayTypeWithWeakDims(short elemType, boolean checkSupportedElementTypes) { - return encodeArrayType(elemType, 1, checkSupportedElementTypes) | TYPE_FLAG_ARRAY_WEAK_DIMS; - } - /** * Generate a decimal type from a given precision and scale. * It will choose the proper subtype (DECIMAL8, DECIMAL16, etc.) from the precision, depending on the amount diff --git a/core/src/main/java/io/questdb/client/cairo/GeoHashes.java b/core/src/main/java/io/questdb/client/cairo/GeoHashes.java deleted file mode 100644 index e785f91..0000000 --- a/core/src/main/java/io/questdb/client/cairo/GeoHashes.java +++ /dev/null @@ -1,107 +0,0 @@ -/******************************************************************************* - * ___ _ ____ ____ - * / _ \ _ _ ___ ___| |_| _ \| __ ) - * | | | | | | |/ _ \/ __| __| | | | _ \ - * | |_| | |_| | __/\__ \ |_| |_| | |_) | - * \__\_\\__,_|\___||___/\__|____/|____/ - * - * Copyright (c) 2014-2019 Appsicle - * Copyright (c) 2019-2026 QuestDB - * - * Licensed 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. - * - ******************************************************************************/ - -package io.questdb.client.cairo; - -import io.questdb.client.std.Numbers; -import io.questdb.client.std.NumericException; -import io.questdb.client.std.str.CharSink; - -public class GeoHashes { - - // geohash null value: -1 - // we use the highest bit of every storage size (byte, short, int, long) - // to indicate null value. When a null value is cast down, nullity is - // preserved, i.e. highest bit remains set: - // long nullLong = -1L; - // short nullShort = (short) nullLong; - // nullShort == nullLong; - // in addition, -1 is the first negative non geohash value. - public static final int MAX_STRING_LENGTH = 12; - public static final long NULL = -1L; - - private static final char[] base32 = { - '0', '1', '2', '3', '4', '5', '6', '7', - '8', '9', 'b', 'c', 'd', 'e', 'f', 'g', - 'h', 'j', 'k', 'm', 'n', 'p', 'q', 'r', - 's', 't', 'u', 'v', 'w', 'x', 'y', 'z' - }; - - public static void append(long hash, int bits, CharSink sink) { - if (hash == GeoHashes.NULL) { - sink.putAscii("null"); - } else { - sink.putAscii('\"'); - if (bits < 0) { - GeoHashes.appendCharsUnsafe(hash, -bits, sink); - } else { - GeoHashes.appendBinaryStringUnsafe(hash, bits, sink); - } - sink.putAscii('\"'); - } - } - - public static void appendBinaryStringUnsafe(long hash, int bits, CharSink sink) { - // Below assertion can happen if there is corrupt metadata - // which should not happen in production code since reader and writer check table metadata - assert bits > 0 && bits <= ColumnType.GEOLONG_MAX_BITS; - for (int i = bits - 1; i >= 0; --i) { - sink.putAscii(((hash >> i) & 1) == 1 ? '1' : '0'); - } - } - - public static void appendChars(long hash, int chars, CharSink sink) { - if (hash != NULL) { - appendCharsUnsafe(hash, chars, sink); - } - } - - public static void appendCharsUnsafe(long hash, int chars, CharSink sink) { - // Below assertion can happen if there is corrupt metadata - // which should not happen in production code since reader and writer check table metadata - assert chars > 0 && chars <= MAX_STRING_LENGTH; - for (int i = chars - 1; i >= 0; --i) { - sink.putAscii(base32[(int) ((hash >> i * 5) & 0x1F)]); - } - } - - public static long fromCoordinatesDeg(double lat, double lon, int bits) throws NumericException { - if (lat < -90.0 || lat > 90.0) { - throw NumericException.instance(); - } - if (lon < -180.0 || lon > 180.0) { - throw NumericException.instance(); - } - if (bits < 0 || bits > ColumnType.GEOLONG_MAX_BITS) { - throw NumericException.instance(); - } - return fromCoordinatesDegUnsafe(lat, lon, bits); - } - - public static long fromCoordinatesDegUnsafe(double lat, double lon, int bits) { - long latq = (long) Math.scalb((lat + 90.0) / 180.0, 32); - long lngq = (long) Math.scalb((lon + 180.0) / 360.0, 32); - return Numbers.interleaveBits(latq, lngq) >>> (64 - bits); - } -} diff --git a/core/src/main/java/io/questdb/client/cairo/arr/BorrowedArray.java b/core/src/main/java/io/questdb/client/cairo/arr/BorrowedArray.java deleted file mode 100644 index 947574a..0000000 --- a/core/src/main/java/io/questdb/client/cairo/arr/BorrowedArray.java +++ /dev/null @@ -1,47 +0,0 @@ -/******************************************************************************* - * ___ _ ____ ____ - * / _ \ _ _ ___ ___| |_| _ \| __ ) - * | | | | | | |/ _ \/ __| __| | | | _ \ - * | |_| | |_| | __/\__ \ |_| |_| | |_) | - * \__\_\\__,_|\___||___/\__|____/|____/ - * - * Copyright (c) 2014-2019 Appsicle - * Copyright (c) 2019-2026 QuestDB - * - * Licensed 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. - * - ******************************************************************************/ - -package io.questdb.client.cairo.arr; - -import io.questdb.client.cairo.ColumnType; -import io.questdb.client.std.Mutable; - -public class BorrowedArray extends MutableArray implements Mutable { - - public BorrowedArray() { - this.flatView = new BorrowedFlatArrayView(); - } - - /** - * Resets to an invalid array. - */ - @Override - public void clear() { - this.type = ColumnType.UNDEFINED; - borrowedFlatView().reset(); - shape.clear(); - strides.clear(); - } - -} \ No newline at end of file diff --git a/core/src/main/java/io/questdb/client/cutlass/http/HttpCookie.java b/core/src/main/java/io/questdb/client/cutlass/http/HttpCookie.java deleted file mode 100644 index b653c2d..0000000 --- a/core/src/main/java/io/questdb/client/cutlass/http/HttpCookie.java +++ /dev/null @@ -1,82 +0,0 @@ -/******************************************************************************* - * ___ _ ____ ____ - * / _ \ _ _ ___ ___| |_| _ \| __ ) - * | | | | | | |/ _ \/ __| __| | | | _ \ - * | |_| | |_| | __/\__ \ |_| |_| | |_) | - * \__\_\\__,_|\___||___/\__|____/|____/ - * - * Copyright (c) 2014-2019 Appsicle - * Copyright (c) 2019-2026 QuestDB - * - * Licensed 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. - * - ******************************************************************************/ - -package io.questdb.client.cutlass.http; - -import io.questdb.client.std.Mutable; -import io.questdb.client.std.str.CharSink; -import io.questdb.client.std.str.DirectUtf8String; -import io.questdb.client.std.str.Sinkable; -import org.jetbrains.annotations.NotNull; - -public class HttpCookie implements Mutable, Sinkable { - public DirectUtf8String cookieName; - public DirectUtf8String domain; - public long expires = -1L; - public boolean httpOnly; - public long maxAge; - public boolean partitioned; - public DirectUtf8String path; - public DirectUtf8String sameSite; - public boolean secure; - public DirectUtf8String value; - - @Override - public void clear() { - this.domain = null; - this.expires = -1L; - this.httpOnly = false; - this.maxAge = 0L; - this.partitioned = false; - this.path = null; - this.sameSite = null; - this.secure = false; - this.value = null; - this.cookieName = null; - } - - @Override - public void toSink(@NotNull CharSink sink) { - sink.put('{'); - - sink.put("cookieName=").putQuoted(cookieName); - sink.put(", value=").putQuoted(value); - if (domain != null) { - sink.put(", domain=").putQuoted(domain); - } - if (path != null) { - sink.put(", path=").putQuoted(path); - } - sink.put(", secure=").put(secure); - sink.put(", httpOnly=").put(httpOnly); - sink.put(", partitioned=").put(partitioned); - sink.put(", expires=").put(expires); - sink.put(", maxAge=").put(maxAge); - if (sameSite != null) { - sink.put(", sameSite=").putQuoted(sameSite); - } - sink.put('}'); - } - -} \ No newline at end of file diff --git a/core/src/main/java/io/questdb/client/cutlass/http/client/HttpClientException.java b/core/src/main/java/io/questdb/client/cutlass/http/client/HttpClientException.java index bd1f7f7..79f8185 100644 --- a/core/src/main/java/io/questdb/client/cutlass/http/client/HttpClientException.java +++ b/core/src/main/java/io/questdb/client/cutlass/http/client/HttpClientException.java @@ -31,6 +31,7 @@ public class HttpClientException extends RuntimeException { private final StringSink message = new StringSink(); private int errno = Integer.MIN_VALUE; + private boolean isTimeout; public HttpClientException(String message) { this.message.put(message); @@ -53,6 +54,10 @@ public String getMessage() { return errNoRender + " " + message; } + public boolean isTimeout() { + return isTimeout; + } + public HttpClientException put(char value) { message.put(value); return this; @@ -77,4 +82,9 @@ public HttpClientException putSize(long value) { message.putSize(value); return this; } + + public HttpClientException flagAsTimeout() { + this.isTimeout = true; + return this; + } } \ No newline at end of file diff --git a/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java b/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java new file mode 100644 index 0000000..84c4329 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java @@ -0,0 +1,902 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.http.client; + +import io.questdb.client.HttpClientConfiguration; +import io.questdb.client.cutlass.qwp.websocket.WebSocketCloseCode; +import io.questdb.client.cutlass.qwp.websocket.WebSocketFrameParser; +import io.questdb.client.cutlass.qwp.websocket.WebSocketOpcode; +import io.questdb.client.network.IOOperation; +import io.questdb.client.network.NetworkFacade; +import io.questdb.client.network.Socket; +import io.questdb.client.network.SocketFactory; +import io.questdb.client.network.TlsSessionInitFailedException; +import io.questdb.client.std.MemoryTag; +import io.questdb.client.std.Misc; +import io.questdb.client.std.QuietCloseable; +import io.questdb.client.std.SecureRnd; +import io.questdb.client.std.Unsafe; +import io.questdb.client.std.Vect; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +import static java.util.concurrent.TimeUnit.NANOSECONDS; + +/** + * Zero-GC WebSocket client built on QuestDB's native socket infrastructure. + *

+ * This client uses native memory buffers and non-blocking I/O with + * platform-specific event notification (epoll/kqueue/select). + *

+ * Features: + *

    + *
  • Zero-copy send path using {@link WebSocketSendBuffer}
  • + *
  • Automatic ping/pong handling
  • + *
  • TLS support
  • + *
  • Connection keep-alive
  • + *
+ *

+ * Thread safety: This class is NOT thread-safe. Each connection should be + * accessed from a single thread at a time. + */ +public abstract class WebSocketClient implements QuietCloseable { + + private static final int DEFAULT_RECV_BUFFER_SIZE = 65536; + private static final int DEFAULT_SEND_BUFFER_SIZE = 65536; + private static final Logger LOG = LoggerFactory.getLogger(WebSocketClient.class); + private static final ThreadLocal SHA1_DIGEST = ThreadLocal.withInitial(() -> { + try { + return MessageDigest.getInstance("SHA-1"); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-1 not available", e); + } + }); + private static final byte[] WEBSOCKET_GUID_BYTES = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11".getBytes(StandardCharsets.US_ASCII); + protected final NetworkFacade nf; + protected final Socket socket; + private final WebSocketSendBuffer controlFrameBuffer; + private final int defaultTimeout; + private final WebSocketFrameParser frameParser; + private final int maxRecvBufSize; + private final SecureRnd rnd; + private final WebSocketSendBuffer sendBuffer; + private boolean closed; + private int fragmentBufPos; + private long fragmentBufPtr; // native buffer for accumulating fragment payloads + private int fragmentBufSize; + // Fragmentation state (RFC 6455 Section 5.4) + private int fragmentOpcode = -1; // opcode of first fragment, -1 = not in a fragmented message + // Handshake key for verification + private String handshakeKey; + // Connection state + private CharSequence host; + private int port; + // Receive buffer (native memory) + private long recvBufPtr; + private int recvBufSize; + private int recvPos; // Write position + private int recvReadPos; // Read position + private boolean upgraded; + + public WebSocketClient(HttpClientConfiguration configuration, SocketFactory socketFactory) { + this.nf = configuration.getNetworkFacade(); + this.socket = socketFactory.newInstance(nf, LOG); + this.defaultTimeout = configuration.getTimeout(); + + int sendBufSize = Math.max(configuration.getInitialRequestBufferSize(), DEFAULT_SEND_BUFFER_SIZE); + int maxSendBufSize = Math.max(configuration.getMaximumRequestBufferSize(), sendBufSize); + WebSocketSendBuffer sendBuf = null; + WebSocketSendBuffer controlBuf = null; + try { + sendBuf = new WebSocketSendBuffer(sendBufSize, maxSendBufSize); + // Control frames (ping/pong/close) have max 125-byte payload + 14-byte header. + // This dedicated buffer prevents sendPongFrame from clobbering an in-progress + // frame being built in the main sendBuffer. + controlBuf = new WebSocketSendBuffer(256, 256); + + this.recvBufSize = Math.max(configuration.getResponseBufferSize(), DEFAULT_RECV_BUFFER_SIZE); + this.maxRecvBufSize = Math.max(configuration.getMaximumResponseBufferSize(), recvBufSize); + this.recvBufPtr = Unsafe.malloc(recvBufSize, MemoryTag.NATIVE_DEFAULT); + } catch (Throwable t) { + Misc.free(controlBuf); + Misc.free(sendBuf); + Misc.free(socket); + throw t; + } + this.sendBuffer = sendBuf; + this.controlFrameBuffer = controlBuf; + this.recvPos = 0; + this.recvReadPos = 0; + + this.frameParser = new WebSocketFrameParser(); + this.rnd = new SecureRnd(); + this.upgraded = false; + this.closed = false; + } + + @Override + public void close() { + if (!closed) { + closed = true; + + // Try to send close frame + if (upgraded && !socket.isClosed()) { + try { + sendCloseFrame(WebSocketCloseCode.NORMAL_CLOSURE, null, 1000); + } catch (Exception e) { + // Ignore errors during close + } + } + + disconnect(); + sendBuffer.close(); + controlFrameBuffer.close(); + + if (fragmentBufPtr != 0) { + Unsafe.free(fragmentBufPtr, fragmentBufSize, MemoryTag.NATIVE_DEFAULT); + fragmentBufPtr = 0; + } + + if (recvBufPtr != 0) { + Unsafe.free(recvBufPtr, recvBufSize, MemoryTag.NATIVE_DEFAULT); + recvBufPtr = 0; + } + } + } + + /** + * Connects to a WebSocket server. + * + * @param host the server hostname + * @param port the server port + * @param timeout connection timeout in milliseconds + */ + public void connect(CharSequence host, int port, int timeout) { + if (closed) { + throw new HttpClientException("WebSocket client is closed"); + } + + // Close existing connection if connecting to different host:port + if (this.host != null && (!this.host.equals(host) || this.port != port)) { + disconnect(); + } + + if (socket.isClosed()) { + doConnect(host, port, timeout); + } + + this.host = host; + this.port = port; + } + + /** + * Connects using default timeout. + */ + public void connect(CharSequence host, int port) { + connect(host, port, defaultTimeout); + } + + /** + * Disconnects the socket without closing the client. + * The client can be reconnected by calling connect() again. + */ + public void disconnect() { + Misc.free(socket); + upgraded = false; + host = null; + port = 0; + recvPos = 0; + recvReadPos = 0; + resetFragmentState(); + } + + /** + * Returns the connected host. + */ + public CharSequence getHost() { + return host; + } + + /** + * Returns the connected port. + */ + public int getPort() { + return port; + } + + /** + * Gets the send buffer for building WebSocket frames. + *

+ * Usage: + *

+     * WebSocketSendBuffer buf = client.getSendBuffer();
+     * buf.beginBinaryFrame();
+     * buf.putLong(data);
+     * WebSocketSendBuffer.FrameInfo frame = buf.endBinaryFrame();
+     * client.sendFrame(frame, timeout);
+     * buf.reset();
+     * 
+ */ + public WebSocketSendBuffer getSendBuffer() { + return sendBuffer; + } + + /** + * Returns whether the WebSocket is connected and upgraded. + */ + public boolean isConnected() { + return upgraded && !closed && !socket.isClosed(); + } + + /** + * Receives and processes WebSocket frames. + * + * @param handler frame handler callback + * @param timeout timeout in milliseconds + * @return true if a frame was received, false on timeout + */ + public boolean receiveFrame(WebSocketFrameHandler handler, int timeout) { + checkConnected(); + + // First, try to parse any data already in buffer + Boolean result = tryParseFrame(handler); + if (result != null) { + return result; + } + + // Need more data + long startTime = System.nanoTime(); + while (true) { + int remainingTimeout = remainingTime(timeout, startTime); + if (remainingTimeout <= 0) { + return false; // Timeout + } + + // Ensure buffer has space + if (recvPos >= recvBufSize - 1024) { + growRecvBuffer(); + } + + int bytesRead = recvOrTimeout(recvBufPtr + recvPos, recvBufSize - recvPos, remainingTimeout); + if (bytesRead <= 0) { + return false; // Timeout + } + recvPos += bytesRead; + + result = tryParseFrame(handler); + if (result != null) { + return result; + } + } + } + + /** + * Sends binary data as a WebSocket binary frame. + * + * @param dataPtr pointer to data + * @param length data length + * @param timeout timeout in milliseconds + */ + public void sendBinary(long dataPtr, int length, int timeout) { + checkConnected(); + sendBuffer.reset(); + sendBuffer.beginFrame(); + sendBuffer.putBlockOfBytes(dataPtr, length); + WebSocketSendBuffer.FrameInfo frame = sendBuffer.endBinaryFrame(); + doSend(sendBuffer.getBufferPtr() + frame.offset, frame.length, timeout); + sendBuffer.reset(); + } + + /** + * Sends binary data with default timeout. + */ + public void sendBinary(long dataPtr, int length) { + sendBinary(dataPtr, length, defaultTimeout); + } + + /** + * Sends a close frame. + */ + public void sendCloseFrame(int code, String reason, int timeout) { + controlFrameBuffer.reset(); + WebSocketSendBuffer.FrameInfo frame = controlFrameBuffer.writeCloseFrame(code, reason); + try { + doSend(controlFrameBuffer.getBufferPtr() + frame.offset, frame.length, timeout); + } finally { + controlFrameBuffer.reset(); + } + } + + /** + * Sends a ping frame. + */ + public void sendPing(int timeout) { + checkConnected(); + controlFrameBuffer.reset(); + WebSocketSendBuffer.FrameInfo frame = controlFrameBuffer.writePingFrame(); + doSend(controlFrameBuffer.getBufferPtr() + frame.offset, frame.length, timeout); + controlFrameBuffer.reset(); + } + + /** + * Non-blocking attempt to receive a WebSocket frame. + * Returns immediately if no complete frame is available. + * + * @param handler frame handler callback + * @return true if a frame was received, false if no data available + */ + public boolean tryReceiveFrame(WebSocketFrameHandler handler) { + checkConnected(); + + // First, try to parse any data already in buffer + Boolean result = tryParseFrame(handler); + if (result != null) { + return result; + } + + // Try one non-blocking recv + if (recvPos >= recvBufSize - 1024) { + growRecvBuffer(); + } + + int n = socket.recv(recvBufPtr + recvPos, recvBufSize - recvPos); + if (n < 0) { + throw new HttpClientException("peer disconnect [errno=").errno(nf.errno()).put(']'); + } + if (n == 0) { + return false; // No data available + } + recvPos += n; + + // Try to parse again + result = tryParseFrame(handler); + return result != null && result; + } + + /** + * Performs WebSocket upgrade handshake. + * + * @param path the WebSocket endpoint path (e.g., "/ws") + * @param timeout timeout in milliseconds + */ + public void upgrade(CharSequence path, int timeout) { + if (closed) { + throw new HttpClientException("WebSocket client is closed"); + } + if (socket.isClosed()) { + throw new HttpClientException("Not connected"); + } + if (upgraded) { + return; // Already upgraded + } + + // Generate random key + byte[] keyBytes = new byte[16]; + for (int i = 0; i < 16; i++) { + keyBytes[i] = (byte) rnd.nextInt(); + } + handshakeKey = Base64.getEncoder().encodeToString(keyBytes); + + // Build upgrade request + sendBuffer.reset(); + sendBuffer.putAscii("GET "); + sendBuffer.putAscii(path); + sendBuffer.putAscii(" HTTP/1.1\r\n"); + sendBuffer.putAscii("Host: "); + sendBuffer.putAscii(host); + if ((socket.supportsTls() && port != 443) || (!socket.supportsTls() && port != 80)) { + sendBuffer.putAscii(":"); + sendBuffer.putAscii(Integer.toString(port)); + } + sendBuffer.putAscii("\r\n"); + sendBuffer.putAscii("Upgrade: websocket\r\n"); + sendBuffer.putAscii("Connection: Upgrade\r\n"); + sendBuffer.putAscii("Sec-WebSocket-Key: "); + sendBuffer.putAscii(handshakeKey); + sendBuffer.putAscii("\r\n"); + sendBuffer.putAscii("Sec-WebSocket-Version: 13\r\n"); + sendBuffer.putAscii("\r\n"); + + // Send request + long startTime = System.nanoTime(); + doSend(sendBuffer.getBufferPtr(), sendBuffer.getWritePos(), timeout); + + // Read response + int remainingTimeout = getRemainingTimeOrThrow(timeout, startTime); + readUpgradeResponse(remainingTimeout); + + upgraded = true; + sendBuffer.reset(); + LOG.debug("WebSocket upgraded [path={}]", path); + } + + /** + * Performs upgrade with default timeout. + */ + public void upgrade(CharSequence path) { + upgrade(path, defaultTimeout); + } + + private static String computeAcceptKey(String key) { + MessageDigest sha1 = SHA1_DIGEST.get(); + sha1.reset(); + for (int i = 0, n = key.length(); i < n; i++) { + sha1.update((byte) key.charAt(i)); + } + sha1.update(WEBSOCKET_GUID_BYTES); + return Base64.getEncoder().encodeToString(sha1.digest()); + } + + private static boolean containsHeaderValue(String response, String headerName, String expectedValue, boolean ignoreValueCase) { + int headerLen = headerName.length(); + int responseLen = response.length(); + for (int i = 0; i <= responseLen - headerLen; i++) { + if (response.regionMatches(true, i, headerName, 0, headerLen)) { + int valueStart = i + headerLen; + int lineEnd = response.indexOf('\r', valueStart); + if (lineEnd < 0) { + lineEnd = responseLen; + } + String actualValue = response.substring(valueStart, lineEnd).trim(); + return ignoreValueCase + ? actualValue.equalsIgnoreCase(expectedValue) + : actualValue.equals(expectedValue); + } + } + return false; + } + + private static int remainingTime(int timeoutMillis, long startTimeNanos) { + return timeoutMillis - (int) NANOSECONDS.toMillis(System.nanoTime() - startTimeNanos); + } + + private void appendToFragmentBuffer(long payloadPtr, int payloadLen) { + if (payloadLen == 0) { + return; + } + int required = fragmentBufPos + payloadLen; + if (required > maxRecvBufSize) { + throw new HttpClientException("WebSocket fragment buffer size exceeded maximum [required=") + .put(required) + .put(", max=") + .put(maxRecvBufSize) + .put(']'); + } + if (fragmentBufPtr == 0) { + fragmentBufSize = Math.max(required, DEFAULT_RECV_BUFFER_SIZE); + fragmentBufPtr = Unsafe.malloc(fragmentBufSize, MemoryTag.NATIVE_DEFAULT); + } else if (required > fragmentBufSize) { + int newSize = (int) Math.min(Math.max((long) fragmentBufSize * 2, required), maxRecvBufSize); + fragmentBufPtr = Unsafe.realloc(fragmentBufPtr, fragmentBufSize, newSize, MemoryTag.NATIVE_DEFAULT); + fragmentBufSize = newSize; + } + Vect.memmove(fragmentBufPtr + fragmentBufPos, payloadPtr, payloadLen); + fragmentBufPos += payloadLen; + } + + private void checkConnected() { + if (closed) { + throw new HttpClientException("WebSocket client is closed"); + } + if (!upgraded) { + throw new HttpClientException("WebSocket not connected or upgraded"); + } + } + + private void compactRecvBuffer() { + if (recvReadPos > 0) { + int remaining = recvPos - recvReadPos; + if (remaining > 0) { + Vect.memmove(recvBufPtr, recvBufPtr + recvReadPos, remaining); + } + recvPos = remaining; + recvReadPos = 0; + } + } + + private int dieIfNegative(int byteCount) { + if (byteCount < 0) { + throw new HttpClientException("peer disconnect [errno=").errno(nf.errno()).put(']'); + } + return byteCount; + } + + private void doConnect(CharSequence host, int port, int timeout) { + int fd = nf.socketTcp(true); + if (fd < 0) { + throw new HttpClientException("could not allocate a file descriptor [errno=").errno(nf.errno()).put(']'); + } + + if (nf.setTcpNoDelay(fd, true) < 0) { + LOG.info("could not disable Nagle's algorithm [fd={}, errno={}]", fd, nf.errno()); + } + + socket.of(fd); + nf.configureKeepAlive(fd); + + long addrInfo = nf.getAddrInfo(host, port); + if (addrInfo == -1) { + disconnect(); + throw new HttpClientException("could not resolve host [host=").put(host).put(']'); + } + + if (nf.connectAddrInfo(fd, addrInfo) != 0) { + int errno = nf.errno(); + nf.freeAddrInfo(addrInfo); + disconnect(); + throw new HttpClientException("could not connect [host=").put(host) + .put(", port=").put(port) + .put(", errno=").put(errno).put(']'); + } + nf.freeAddrInfo(addrInfo); + + if (nf.configureNonBlocking(fd) < 0) { + int errno = nf.errno(); + disconnect(); + throw new HttpClientException("could not configure non-blocking [fd=").put(fd) + .put(", errno=").put(errno).put(']'); + } + + if (socket.supportsTls()) { + try { + socket.startTlsSession(host); + } catch (TlsSessionInitFailedException e) { + int errno = nf.errno(); + disconnect(); + throw new HttpClientException("could not start TLS session [fd=").put(fd) + .put(", error=").put(e.getFlyweightMessage()) + .put(", errno=").put(errno).put(']'); + } + } + + setupIoWait(); + LOG.debug("Connected to [host={}, port={}]", host, port); + } + + private void doSend(long ptr, int len, int timeout) { + long startTime = System.nanoTime(); + while (len > 0) { + int remainingTimeout = getRemainingTimeOrThrow(timeout, startTime); + ioWait(remainingTimeout, IOOperation.WRITE); + int sent = dieIfNegative(socket.send(ptr, len)); + while (socket.wantsTlsWrite()) { + remainingTimeout = getRemainingTimeOrThrow(timeout, startTime); + ioWait(remainingTimeout, IOOperation.WRITE); + dieIfNegative(socket.tlsIO(Socket.WRITE_FLAG)); + } + if (sent > 0) { + ptr += sent; + len -= sent; + } + } + } + + private int findHeaderEnd() { + // Look for \r\n\r\n + for (int i = 0; i < recvPos - 3; i++) { + if (Unsafe.getUnsafe().getByte(recvBufPtr + i) == '\r' && + Unsafe.getUnsafe().getByte(recvBufPtr + i + 1) == '\n' && + Unsafe.getUnsafe().getByte(recvBufPtr + i + 2) == '\r' && + Unsafe.getUnsafe().getByte(recvBufPtr + i + 3) == '\n') { + return i + 4; + } + } + return -1; + } + + private int getRemainingTimeOrThrow(int timeoutMillis, long startTimeNanos) { + int remaining = remainingTime(timeoutMillis, startTimeNanos); + if (remaining <= 0) { + throw new HttpClientException("timed out [errno=").errno(nf.errno()).put(']').flagAsTimeout(); + } + return remaining; + } + + private void growRecvBuffer() { + int newSize = (int) Math.min((long) recvBufSize * 2, maxRecvBufSize); + if (newSize >= maxRecvBufSize) { + if (recvBufSize >= maxRecvBufSize) { + throw new HttpClientException("WebSocket receive buffer size exceeded maximum [current=") + .put(recvBufSize) + .put(", max=") + .put(maxRecvBufSize) + .put(']'); + } + newSize = maxRecvBufSize; + } + recvBufPtr = Unsafe.realloc(recvBufPtr, recvBufSize, newSize, MemoryTag.NATIVE_DEFAULT); + recvBufSize = newSize; + } + + private void readUpgradeResponse(int timeout) { + // Read HTTP response into receive buffer + long startTime = System.nanoTime(); + + while (true) { + int remainingTimeout = getRemainingTimeOrThrow(timeout, startTime); + int bytesRead = recvOrDie(recvBufPtr + recvPos, recvBufSize - recvPos, remainingTimeout); + if (bytesRead > 0) { + recvPos += bytesRead; + } + + // Check for end of headers (\r\n\r\n) + int headerEnd = findHeaderEnd(); + if (headerEnd > 0) { + validateUpgradeResponse(headerEnd); + // Compact buffer - move remaining data to start + int remaining = recvPos - headerEnd; + if (remaining > 0) { + Vect.memmove(recvBufPtr, recvBufPtr + headerEnd, remaining); + } + recvPos = remaining; + recvReadPos = 0; + return; + } + + if (recvPos >= recvBufSize) { + throw new HttpClientException("HTTP response too large"); + } + } + } + + private int recvOrDie(long ptr, int len, int timeout) { + long startTime = System.nanoTime(); + int n = dieIfNegative(socket.recv(ptr, len)); + if (n == 0) { + ioWait(getRemainingTimeOrThrow(timeout, startTime), IOOperation.READ); + n = dieIfNegative(socket.recv(ptr, len)); + } + return n; + } + + private int recvOrTimeout(long ptr, int len, int timeout) { + long startTime = System.nanoTime(); + int n = socket.recv(ptr, len); + if (n < 0) { + throw new HttpClientException("peer disconnect [errno=").errno(nf.errno()).put(']'); + } + if (n == 0) { + try { + ioWait(timeout, IOOperation.READ); + } catch (HttpClientException e) { + if (!e.isTimeout()) { + throw e; + } + return 0; + } + n = socket.recv(ptr, len); + if (n < 0) { + throw new HttpClientException("peer disconnect [errno=").errno(nf.errno()).put(']'); + } + } + return n; + } + + private void resetFragmentState() { + fragmentOpcode = -1; + fragmentBufPos = 0; + } + + private void sendCloseFrameEcho(int code) { + try { + controlFrameBuffer.reset(); + WebSocketSendBuffer.FrameInfo frame = controlFrameBuffer.writeCloseFrame(code, null); + doSend(controlFrameBuffer.getBufferPtr() + frame.offset, frame.length, 1000); + controlFrameBuffer.reset(); + } catch (Exception e) { + LOG.error("Failed to echo close frame: {}", e.getMessage()); + } + } + + private void sendPongFrame(long payloadPtr, int payloadLen) { + try { + controlFrameBuffer.reset(); + WebSocketSendBuffer.FrameInfo frame = controlFrameBuffer.writePongFrame(payloadPtr, payloadLen); + doSend(controlFrameBuffer.getBufferPtr() + frame.offset, frame.length, 1000); + controlFrameBuffer.reset(); + } catch (Exception e) { + LOG.error("Failed to send pong: {}", e.getMessage()); + } + } + + private Boolean tryParseFrame(WebSocketFrameHandler handler) { + if (recvPos <= recvReadPos) { + return null; // No data + } + + frameParser.reset(); + int consumed = frameParser.parse(recvBufPtr + recvReadPos, recvBufPtr + recvPos); + + if (frameParser.getState() == WebSocketFrameParser.STATE_NEED_MORE || + frameParser.getState() == WebSocketFrameParser.STATE_NEED_PAYLOAD) { + return null; // Need more data + } + + if (frameParser.getState() == WebSocketFrameParser.STATE_ERROR) { + throw new HttpClientException("WebSocket frame parse error: ") + .put(WebSocketCloseCode.describe(frameParser.getErrorCode())); + } + + if (frameParser.getState() == WebSocketFrameParser.STATE_COMPLETE) { + long payloadPtr = recvBufPtr + recvReadPos + frameParser.getHeaderSize(); + long payloadLength = frameParser.getPayloadLength(); + if (payloadLength > Integer.MAX_VALUE) { + throw new HttpClientException("WebSocket frame payload too large [length=") + .put(payloadLength).put(']'); + } + int payloadLen = (int) payloadLength; + + // Unmask if needed (server frames should not be masked) + if (frameParser.isMasked()) { + frameParser.unmaskPayload(payloadPtr, payloadLen); + } + + // Handle frame by opcode + int opcode = frameParser.getOpcode(); + switch (opcode) { + case WebSocketOpcode.PING: + // Auto-respond with pong + sendPongFrame(payloadPtr, payloadLen); + if (handler != null) { + handler.onPing(payloadPtr, payloadLen); + } + break; + case WebSocketOpcode.PONG: + if (handler != null) { + handler.onPong(payloadPtr, payloadLen); + } + break; + case WebSocketOpcode.CLOSE: + int closeCode = 0; + String reason = null; + if (payloadLen >= 2) { + closeCode = ((Unsafe.getUnsafe().getByte(payloadPtr) & 0xFF) << 8) + | (Unsafe.getUnsafe().getByte(payloadPtr + 1) & 0xFF); + if (payloadLen > 2) { + byte[] reasonBytes = new byte[payloadLen - 2]; + for (int i = 0; i < reasonBytes.length; i++) { + reasonBytes[i] = Unsafe.getUnsafe().getByte(payloadPtr + 2 + i); + } + reason = new String(reasonBytes, StandardCharsets.UTF_8); + } + } + // RFC 6455 Section 5.5.1: echo a close frame back before + // marking the connection as no longer upgraded + sendCloseFrameEcho(closeCode); + upgraded = false; + if (handler != null) { + handler.onClose(closeCode, reason); + } + break; + case WebSocketOpcode.BINARY: + case WebSocketOpcode.TEXT: + if (frameParser.isFin()) { + if (fragmentOpcode != -1) { + throw new HttpClientException("WebSocket protocol error: new data frame during fragmented message"); + } + if (handler != null) { + if (opcode == WebSocketOpcode.BINARY) { + handler.onBinaryMessage(payloadPtr, payloadLen); + } else { + handler.onTextMessage(payloadPtr, payloadLen); + } + } + } else { + if (fragmentOpcode != -1) { + throw new HttpClientException("WebSocket protocol error: new data frame during fragmented message"); + } + fragmentOpcode = opcode; + appendToFragmentBuffer(payloadPtr, payloadLen); + } + break; + case WebSocketOpcode.CONTINUATION: + if (fragmentOpcode == -1) { + throw new HttpClientException("WebSocket protocol error: continuation frame without initial fragment"); + } + appendToFragmentBuffer(payloadPtr, payloadLen); + if (frameParser.isFin()) { + if (handler != null) { + if (fragmentOpcode == WebSocketOpcode.BINARY) { + handler.onBinaryMessage(fragmentBufPtr, fragmentBufPos); + } else { + handler.onTextMessage(fragmentBufPtr, fragmentBufPos); + } + } + resetFragmentState(); + } + break; + } + + // Advance read position + recvReadPos += consumed; + + // Compact buffer if needed + compactRecvBuffer(); + + return true; + } + + return false; + } + + private void validateUpgradeResponse(int headerEnd) { + // Extract response as string for parsing + byte[] responseBytes = new byte[headerEnd]; + for (int i = 0; i < headerEnd; i++) { + responseBytes[i] = Unsafe.getUnsafe().getByte(recvBufPtr + i); + } + String response = new String(responseBytes, StandardCharsets.US_ASCII); + + // Check status line + if (!response.startsWith("HTTP/1.1 101")) { + String statusLine = response.split("\r\n")[0]; + throw new HttpClientException("WebSocket upgrade failed: ").put(statusLine); + } + + // Verify Upgrade: websocket (case-insensitive value per RFC 6455 Section 4.1) + if (!containsHeaderValue(response, "Upgrade:", "websocket", true)) { + throw new HttpClientException("Missing or invalid Upgrade header in WebSocket response"); + } + + // Verify Connection: Upgrade (case-insensitive value per RFC 6455 Section 4.1) + if (!containsHeaderValue(response, "Connection:", "Upgrade", true)) { + throw new HttpClientException("Missing or invalid Connection header in WebSocket response"); + } + + // Verify Sec-WebSocket-Accept (exact value match per RFC 6455 Section 4.1) + String expectedAccept = computeAcceptKey(handshakeKey); + if (!containsHeaderValue(response, "Sec-WebSocket-Accept:", expectedAccept, false)) { + throw new HttpClientException("Invalid Sec-WebSocket-Accept header"); + } + } + + protected void dieWaiting(int n) { + if (n == 1) { + return; + } + if (n == 0) { + throw new HttpClientException("timed out [errno=").put(nf.errno()).put(']').flagAsTimeout(); + } + throw new HttpClientException("queue error [errno=").put(nf.errno()).put(']'); + } + + /** + * Waits for I/O readiness using platform-specific mechanism. + * + * @param timeout timeout in milliseconds + * @param op I/O operation (READ or WRITE) + */ + protected abstract void ioWait(int timeout, int op); + + /** + * Sets up platform-specific I/O wait mechanism after connection. + */ + protected abstract void setupIoWait(); +} diff --git a/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClientFactory.java b/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClientFactory.java new file mode 100644 index 0000000..c6b36d2 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClientFactory.java @@ -0,0 +1,136 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.http.client; + +import io.questdb.client.ClientTlsConfiguration; +import io.questdb.client.DefaultHttpClientConfiguration; +import io.questdb.client.HttpClientConfiguration; +import io.questdb.client.network.JavaTlsClientSocketFactory; +import io.questdb.client.network.PlainSocketFactory; +import io.questdb.client.network.SocketFactory; +import io.questdb.client.std.Os; + +/** + * Factory for creating platform-specific {@link WebSocketClient} instances. + *

+ * Usage: + *

+ * // Plain text connection
+ * WebSocketClient client = WebSocketClientFactory.newPlainTextInstance();
+ *
+ * // TLS connection
+ * WebSocketClient client = WebSocketClientFactory.newTlsInstance(config, tlsConfig);
+ *
+ * // Connect and upgrade
+ * client.connect("localhost", 9000);
+ * client.upgrade("/ws");
+ *
+ * // Send data
+ * WebSocketSendBuffer buf = client.getSendBuffer();
+ * buf.beginBinaryFrame();
+ * buf.putLong(data);
+ * WebSocketSendBuffer.FrameInfo frame = buf.endBinaryFrame();
+ * client.sendFrame(frame);
+ * buf.reset();
+ *
+ * // Receive data
+ * client.receiveFrame(handler);
+ *
+ * client.close();
+ * 
+ */ +public class WebSocketClientFactory { + + // Utility class -- no instantiation + private WebSocketClientFactory() { + } + + /** + * Creates a new WebSocket client with insecure TLS (no certificate validation). + *

+ * WARNING: Only use this for testing. Production code should use proper TLS validation. + * + * @return a new WebSocket client with insecure TLS + */ + public static WebSocketClient newInsecureTlsInstance() { + return newInstance(DefaultHttpClientConfiguration.INSTANCE, JavaTlsClientSocketFactory.INSECURE_NO_VALIDATION); + } + + /** + * Creates a new WebSocket client with the specified configuration and socket factory. + * + * @param configuration the HTTP client configuration + * @param socketFactory the socket factory for creating sockets + * @return a new platform-specific WebSocket client + */ + public static WebSocketClient newInstance(HttpClientConfiguration configuration, SocketFactory socketFactory) { + return switch (Os.type) { + case Os.LINUX -> new WebSocketClientLinux(configuration, socketFactory); + case Os.DARWIN, Os.FREEBSD -> new WebSocketClientOsx(configuration, socketFactory); + case Os.WINDOWS -> new WebSocketClientWindows(configuration, socketFactory); + default -> throw new UnsupportedOperationException("Unsupported platform: " + Os.type); + }; + } + + /** + * Creates a new plain text WebSocket client with default configuration. + * + * @return a new plain text WebSocket client + */ + public static WebSocketClient newPlainTextInstance() { + return newPlainTextInstance(DefaultHttpClientConfiguration.INSTANCE); + } + + /** + * Creates a new plain text WebSocket client with the specified configuration. + * + * @param configuration the HTTP client configuration + * @return a new plain text WebSocket client + */ + public static WebSocketClient newPlainTextInstance(HttpClientConfiguration configuration) { + return newInstance(configuration, PlainSocketFactory.INSTANCE); + } + + /** + * Creates a new TLS WebSocket client with the specified configuration. + * + * @param configuration the HTTP client configuration + * @param tlsConfig the TLS configuration + * @return a new TLS WebSocket client + */ + public static WebSocketClient newTlsInstance(HttpClientConfiguration configuration, ClientTlsConfiguration tlsConfig) { + return newInstance(configuration, new JavaTlsClientSocketFactory(tlsConfig)); + } + + /** + * Creates a new TLS WebSocket client with default HTTP configuration. + * + * @param tlsConfig the TLS configuration + * @return a new TLS WebSocket client + */ + public static WebSocketClient newTlsInstance(ClientTlsConfiguration tlsConfig) { + return newTlsInstance(DefaultHttpClientConfiguration.INSTANCE, tlsConfig); + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClientLinux.java b/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClientLinux.java new file mode 100644 index 0000000..f4ac6ba --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClientLinux.java @@ -0,0 +1,71 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.http.client; + +import io.questdb.client.HttpClientConfiguration; +import io.questdb.client.network.Epoll; +import io.questdb.client.network.EpollAccessor; +import io.questdb.client.network.IOOperation; +import io.questdb.client.network.SocketFactory; +import io.questdb.client.std.Misc; + +/** + * Linux-specific WebSocket client using epoll for I/O waiting. + */ +public class WebSocketClientLinux extends WebSocketClient { + private Epoll epoll; + + public WebSocketClientLinux(HttpClientConfiguration configuration, SocketFactory socketFactory) { + super(configuration, socketFactory); + epoll = new Epoll( + configuration.getEpollFacade(), + configuration.getWaitQueueCapacity() + ); + } + + @Override + public void close() { + super.close(); + epoll = Misc.free(epoll); + } + + @Override + protected void ioWait(int timeout, int op) { + final int event = op == IOOperation.WRITE ? EpollAccessor.EPOLLOUT : EpollAccessor.EPOLLIN; + if (epoll.control(socket.getFd(), 0, EpollAccessor.EPOLL_CTL_MOD, event) < 0) { + throw new HttpClientException("internal error: epoll_ctl failure [op=").put(op) + .put(", errno=").put(nf.errno()) + .put(']'); + } + dieWaiting(epoll.poll(timeout)); + } + + @Override + protected void setupIoWait() { + if (epoll.control(socket.getFd(), 0, EpollAccessor.EPOLL_CTL_ADD, EpollAccessor.EPOLLOUT) < 0) { + throw new HttpClientException("internal error: epoll_ctl failure [cmd=add, errno=").put(nf.errno()).put(']'); + } + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClientOsx.java b/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClientOsx.java new file mode 100644 index 0000000..d34df7c --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClientOsx.java @@ -0,0 +1,75 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.http.client; + +import io.questdb.client.HttpClientConfiguration; +import io.questdb.client.network.IOOperation; +import io.questdb.client.network.Kqueue; +import io.questdb.client.network.SocketFactory; +import io.questdb.client.std.Misc; + +/** + * macOS-specific WebSocket client using kqueue for I/O waiting. + */ +public class WebSocketClientOsx extends WebSocketClient { + private Kqueue kqueue; + + public WebSocketClientOsx(HttpClientConfiguration configuration, SocketFactory socketFactory) { + super(configuration, socketFactory); + this.kqueue = new Kqueue( + configuration.getKQueueFacade(), + configuration.getWaitQueueCapacity() + ); + } + + @Override + public void close() { + super.close(); + this.kqueue = Misc.free(kqueue); + } + + @Override + protected void ioWait(int timeout, int op) { + kqueue.setWriteOffset(0); + if (op == IOOperation.READ) { + kqueue.readFD(socket.getFd(), 0); + } else { + kqueue.writeFD(socket.getFd(), 0); + } + + // 1 = always one FD, we are a single threaded network client + if (kqueue.register(1) != 0) { + throw new HttpClientException("could not register with kqueue [op=").put(op) + .put(", errno=").errno(nf.errno()) + .put(']'); + } + dieWaiting(kqueue.poll(timeout)); + } + + @Override + protected void setupIoWait() { + // no-op on macOS + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClientWindows.java b/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClientWindows.java new file mode 100644 index 0000000..cdaec88 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClientWindows.java @@ -0,0 +1,74 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.http.client; + +import io.questdb.client.HttpClientConfiguration; +import io.questdb.client.network.FDSet; +import io.questdb.client.network.IOOperation; +import io.questdb.client.network.SelectFacade; +import io.questdb.client.network.SocketFactory; +import io.questdb.client.std.Misc; + +/** + * Windows-specific WebSocket client using select for I/O waiting. + */ +public class WebSocketClientWindows extends WebSocketClient { + private final SelectFacade sf; + private FDSet fdSet; + + public WebSocketClientWindows(HttpClientConfiguration configuration, SocketFactory socketFactory) { + super(configuration, socketFactory); + this.fdSet = new FDSet(configuration.getWaitQueueCapacity()); + this.sf = configuration.getSelectFacade(); + } + + @Override + public void close() { + super.close(); + this.fdSet = Misc.free(fdSet); + } + + @Override + protected void ioWait(int timeout, int op) { + final long readAddr; + final long writeAddr; + fdSet.clear(); + fdSet.add(socket.getFd()); + fdSet.setCount(1); + if (op == IOOperation.READ) { + readAddr = fdSet.address(); + writeAddr = 0; + } else { + readAddr = 0; + writeAddr = fdSet.address(); + } + dieWaiting(sf.select(readAddr, writeAddr, 0, timeout)); + } + + @Override + protected void setupIoWait() { + // no-op on Windows + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketFrameHandler.java b/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketFrameHandler.java new file mode 100644 index 0000000..e3682f5 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketFrameHandler.java @@ -0,0 +1,93 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.http.client; + +/** + * Callback interface for handling received WebSocket frames. + *

+ * Implementations should process received data efficiently and avoid blocking, + * as callbacks are invoked on the I/O thread. + *

+ * Thread safety: Callbacks are invoked from the thread that called receiveFrame(). + * Implementations must handle their own synchronization if accessed from multiple threads. + */ +public interface WebSocketFrameHandler { + + /** + * Called when a binary frame is received. + * + * @param payloadPtr pointer to the payload data in native memory + * @param payloadLen length of the payload in bytes + */ + void onBinaryMessage(long payloadPtr, int payloadLen); + + /** + * Called when a close frame is received from the server. + *

+ * After this callback, the connection will be closed. The handler should + * perform any necessary cleanup. + * + * @param code the close status code (e.g., 1000 for normal closure) + * @param reason the close reason (may be null or empty) + */ + void onClose(int code, String reason); + + /** + * Called when a ping frame is received. + *

+ * Default implementation does nothing. The WebSocketClient automatically + * sends a pong response, so this callback is for informational purposes only. + * + * @param payloadPtr pointer to the ping payload in native memory + * @param payloadLen length of the payload in bytes + */ + default void onPing(long payloadPtr, int payloadLen) { + // Default: handled automatically by client + } + + /** + * Called when a pong frame is received. + *

+ * Default implementation does nothing. + * + * @param payloadPtr pointer to the pong payload in native memory + * @param payloadLen length of the payload in bytes + */ + default void onPong(long payloadPtr, int payloadLen) { + // Default: ignore pong frames + } + + /** + * Called when a text frame is received. + *

+ * Default implementation does nothing. Override if text frames need handling. + * + * @param payloadPtr pointer to the UTF-8 encoded payload in native memory + * @param payloadLen length of the payload in bytes + */ + default void onTextMessage(long payloadPtr, int payloadLen) { + // Default: ignore text frames + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketSendBuffer.java b/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketSendBuffer.java new file mode 100644 index 0000000..d31044f --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketSendBuffer.java @@ -0,0 +1,563 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.http.client; + +import io.questdb.client.cutlass.line.array.ArrayBufferAppender; +import io.questdb.client.cutlass.qwp.client.NativeBufferWriter; +import io.questdb.client.cutlass.qwp.client.QwpBufferWriter; +import io.questdb.client.cutlass.qwp.websocket.WebSocketFrameWriter; +import io.questdb.client.cutlass.qwp.websocket.WebSocketOpcode; +import io.questdb.client.std.MemoryTag; +import io.questdb.client.std.Numbers; +import io.questdb.client.std.QuietCloseable; +import io.questdb.client.std.SecureRnd; +import io.questdb.client.std.Unsafe; +import io.questdb.client.std.Vect; + +/** + * Zero-GC WebSocket send buffer that implements {@link ArrayBufferAppender} for direct + * payload writing. Manages native memory with safe growth and handles WebSocket frame + * building (reserve header -> write payload -> patch header -> mask). + *

+ * Usage pattern: + *

+ * buffer.beginBinaryFrame();
+ * // Write payload using ArrayBufferAppender methods
+ * buffer.putLong(value);
+ * buffer.putBlockOfBytes(ptr, len);
+ * // Finish frame and get send info
+ * FrameInfo frame = buffer.endBinaryFrame();
+ * // Send frame using socket
+ * socket.send(buffer.getBufferPtr() + frame.offset, frame.length);
+ * buffer.reset();
+ * 
+ *

+ * Thread safety: This class is NOT thread-safe. Each connection should have its own buffer. + */ +public class WebSocketSendBuffer implements QwpBufferWriter, QuietCloseable { + + private static final int DEFAULT_INITIAL_CAPACITY = 65536; + private static final int MAX_BUFFER_SIZE = Integer.MAX_VALUE - 8; // Leave room for alignment + // Maximum header size: 2 (base) + 8 (64-bit length) + 4 (mask key) + private static final int MAX_HEADER_SIZE = 14; + private final FrameInfo frameInfo = new FrameInfo(); + private final int maxBufferSize; + private final SecureRnd rnd; + private int bufCapacity; + private long bufPtr; + private int frameStartOffset; // Where current frame's reserved header starts + private int payloadStartOffset; // Where payload begins (frameStart + MAX_HEADER_SIZE) + private int writePos; // Current write position (offset from bufPtr) + + /** + * Creates a new WebSocket send buffer with default initial capacity. + */ + public WebSocketSendBuffer() { + this(DEFAULT_INITIAL_CAPACITY, MAX_BUFFER_SIZE); + } + + /** + * Creates a new WebSocket send buffer with specified initial capacity. + * + * @param initialCapacity initial buffer size in bytes + */ + public WebSocketSendBuffer(int initialCapacity) { + this(initialCapacity, MAX_BUFFER_SIZE); + } + + /** + * Creates a new WebSocket send buffer with specified initial and max capacity. + * + * @param initialCapacity initial buffer size in bytes + * @param maxBufferSize maximum buffer size in bytes + */ + public WebSocketSendBuffer(int initialCapacity, int maxBufferSize) { + this.bufCapacity = Math.max(initialCapacity, MAX_HEADER_SIZE * 2); + this.maxBufferSize = maxBufferSize; + this.bufPtr = Unsafe.malloc(bufCapacity, MemoryTag.NATIVE_DEFAULT); + this.writePos = 0; + this.frameStartOffset = 0; + this.payloadStartOffset = 0; + this.rnd = new SecureRnd(); + } + + /** + * Begins a new WebSocket frame. Reserves space for the maximum header size. + * The opcode is specified later when ending the frame via {@link #endFrame(int)}. + */ + public void beginFrame() { + frameStartOffset = writePos; + // Reserve maximum header space + ensureCapacity(MAX_HEADER_SIZE); + writePos += MAX_HEADER_SIZE; + payloadStartOffset = writePos; + } + + @Override + public void close() { + if (bufPtr != 0) { + Unsafe.free(bufPtr, bufCapacity, MemoryTag.NATIVE_DEFAULT); + bufPtr = 0; + bufCapacity = 0; + } + } + + /** + * Finishes the current binary frame, writing the header and applying masking. + * Returns information about where to find the complete frame in the buffer. + *

+ * IMPORTANT: Only call this after all payload writes are complete. The buffer + * pointer is stable after this call (no more reallocations for this frame). + * + * @return frame info containing offset and length for sending + */ + public FrameInfo endBinaryFrame() { + return endFrame(WebSocketOpcode.BINARY); + } + + /** + * Finishes the current frame with the specified opcode. + * + * @param opcode the frame opcode + * @return frame info containing offset and length for sending + */ + public FrameInfo endFrame(int opcode) { + int payloadLen = writePos - payloadStartOffset; + + // Calculate actual header size (with mask key for client frames) + int actualHeaderSize = WebSocketFrameWriter.headerSize(payloadLen, true); + int unusedSpace = MAX_HEADER_SIZE - actualHeaderSize; + int actualFrameStart = frameStartOffset + unusedSpace; + + // Generate mask key + int maskKey = rnd.nextInt(); + + // Write header at actual position (after unused space) + WebSocketFrameWriter.writeHeader(bufPtr + actualFrameStart, true, opcode, payloadLen, maskKey); + + // Apply mask to payload + if (payloadLen > 0) { + WebSocketFrameWriter.maskPayload(bufPtr + payloadStartOffset, payloadLen, maskKey); + } + + return frameInfo.set(actualFrameStart, actualHeaderSize + payloadLen); + } + + /** + * Finishes the current text frame, writing the header and applying masking. + */ + public FrameInfo endTextFrame() { + return endFrame(WebSocketOpcode.TEXT); + } + + /** + * Ensures the buffer has capacity for the specified number of additional bytes. + * May reallocate the buffer if necessary. + * + * @param additionalBytes number of additional bytes needed + */ + @Override + public void ensureCapacity(int additionalBytes) { + long requiredCapacity = (long) writePos + additionalBytes; + if (requiredCapacity > bufCapacity) { + grow(requiredCapacity); + } + } + + /** + * Gets the buffer pointer. Only use this for reading after frame is complete. + */ + public long getBufferPtr() { + return bufPtr; + } + + /** + * Gets the current buffer capacity. + */ + public int getCapacity() { + return bufCapacity; + } + + /** + * Gets the payload length of the current frame being built. + */ + public int getCurrentPayloadLength() { + return writePos - payloadStartOffset; + } + + /** + * Gets the current write position (number of bytes written). + */ + @Override + public int getPosition() { + return writePos; + } + + /** + * Gets the current write position (total bytes written since last reset). + */ + public int getWritePos() { + return writePos; + } + + /** + * Patches an int value at the specified offset. + */ + @Override + public void patchInt(int offset, int value) { + Unsafe.getUnsafe().putInt(bufPtr + offset, value); + } + + /** + * Writes an ASCII string. + */ + public void putAscii(CharSequence cs) { + if (cs == null) { + return; + } + int len = cs.length(); + ensureCapacity(len); + for (int i = 0; i < len; i++) { + Unsafe.getUnsafe().putByte(bufPtr + writePos + i, (byte) cs.charAt(i)); + } + writePos += len; + } + + @Override + public void putBlockOfBytes(long from, long len) { + if (len <= 0) { + return; + } + if (len > Integer.MAX_VALUE) { + throw new IllegalArgumentException("len exceeds int range: " + len); + } + int intLen = (int) len; + ensureCapacity(intLen); + Vect.memcpy(bufPtr + writePos, from, intLen); + writePos += intLen; + } + + @Override + public void putByte(byte b) { + ensureCapacity(1); + Unsafe.getUnsafe().putByte(bufPtr + writePos, b); + writePos++; + } + + /** + * Writes raw bytes from a byte array. + */ + public void putBytes(byte[] bytes, int offset, int length) { + if (length <= 0) { + return; + } + ensureCapacity(length); + for (int i = 0; i < length; i++) { + Unsafe.getUnsafe().putByte(bufPtr + writePos + i, bytes[offset + i]); + } + writePos += length; + } + + @Override + public void putDouble(double value) { + ensureCapacity(8); + Unsafe.getUnsafe().putDouble(bufPtr + writePos, value); + writePos += 8; + } + + /** + * Writes a float value. + */ + public void putFloat(float value) { + ensureCapacity(4); + Unsafe.getUnsafe().putFloat(bufPtr + writePos, value); + writePos += 4; + } + + @Override + public void putInt(int value) { + ensureCapacity(4); + Unsafe.getUnsafe().putInt(bufPtr + writePos, value); + writePos += 4; + } + + @Override + public void putLong(long value) { + ensureCapacity(8); + Unsafe.getUnsafe().putLong(bufPtr + writePos, value); + writePos += 8; + } + + /** + * Writes a long value in big-endian format. + */ + public void putLongBE(long value) { + ensureCapacity(8); + Unsafe.getUnsafe().putLong(bufPtr + writePos, Long.reverseBytes(value)); + writePos += 8; + } + + /** + * Writes a short value in little-endian format. + */ + public void putShort(short value) { + ensureCapacity(2); + Unsafe.getUnsafe().putShort(bufPtr + writePos, value); + writePos += 2; + } + + /** + * Writes a length-prefixed UTF-8 string. + */ + @Override + public void putString(String value) { + if (value == null || value.isEmpty()) { + putVarint(0); + return; + } + int utf8Len = NativeBufferWriter.utf8Length(value); + putVarint(utf8Len); + putUtf8(value); + } + + /** + * Writes UTF-8 encoded bytes directly without length prefix. + */ + @Override + public void putUtf8(String value) { + if (value == null || value.isEmpty()) { + return; + } + for (int i = 0, n = value.length(); i < n; i++) { + char c = value.charAt(i); + if (c < 0x80) { + putByte((byte) c); + } else if (c < 0x800) { + putByte((byte) (0xC0 | (c >> 6))); + putByte((byte) (0x80 | (c & 0x3F))); + } else if (c >= 0xD800 && c <= 0xDBFF && i + 1 < n) { + char c2 = value.charAt(++i); + if (Character.isLowSurrogate(c2)) { + int codePoint = 0x10000 + ((c - 0xD800) << 10) + (c2 - 0xDC00); + putByte((byte) (0xF0 | (codePoint >> 18))); + putByte((byte) (0x80 | ((codePoint >> 12) & 0x3F))); + putByte((byte) (0x80 | ((codePoint >> 6) & 0x3F))); + putByte((byte) (0x80 | (codePoint & 0x3F))); + } else { + putByte((byte) '?'); + i--; + } + } else if (Character.isSurrogate(c)) { + putByte((byte) '?'); + } else { + putByte((byte) (0xE0 | (c >> 12))); + putByte((byte) (0x80 | ((c >> 6) & 0x3F))); + putByte((byte) (0x80 | (c & 0x3F))); + } + } + } + + /** + * Writes an unsigned variable-length integer (LEB128 encoding). + */ + @Override + public void putVarint(long value) { + while (value > 0x7F) { + putByte((byte) ((value & 0x7F) | 0x80)); + value >>>= 7; + } + putByte((byte) value); + } + + /** + * Resets the buffer for reuse. Does not deallocate memory. + */ + public void reset() { + writePos = 0; + frameStartOffset = 0; + payloadStartOffset = 0; + } + + /** + * Skips the specified number of bytes, advancing the position. + */ + @Override + public void skip(int bytes) { + ensureCapacity(bytes); + writePos += bytes; + } + + /** + * Writes a complete close frame. + * + * @param code close status code (e.g., 1000 for normal closure) + * @param reason optional reason string (may be null) + * @return frame info for sending + */ + public FrameInfo writeCloseFrame(int code, String reason) { + int payloadLen = 2; // status code + byte[] reasonBytes = null; + if (reason != null && !reason.isEmpty()) { + reasonBytes = reason.getBytes(java.nio.charset.StandardCharsets.UTF_8); + payloadLen += reasonBytes.length; + } + + if (payloadLen > 125) { + throw new HttpClientException("Close payload too large [len=").put(payloadLen).put(']'); + } + + int frameStart = writePos; + int headerSize = WebSocketFrameWriter.headerSize(payloadLen, true); + ensureCapacity(headerSize + payloadLen); + + int maskKey = rnd.nextInt(); + int written = WebSocketFrameWriter.writeHeader(bufPtr + writePos, true, WebSocketOpcode.CLOSE, payloadLen, maskKey); + writePos += written; + + // Write status code (big-endian) + long payloadStart = bufPtr + writePos; + Unsafe.getUnsafe().putByte(payloadStart, (byte) ((code >> 8) & 0xFF)); + Unsafe.getUnsafe().putByte(payloadStart + 1, (byte) (code & 0xFF)); + writePos += 2; + + // Write reason if present + if (reasonBytes != null) { + for (byte reasonByte : reasonBytes) { + Unsafe.getUnsafe().putByte(bufPtr + writePos++, reasonByte); + } + } + + // Mask the payload (including status code and reason) + WebSocketFrameWriter.maskPayload(payloadStart, payloadLen, maskKey); + + return frameInfo.set(frameStart, headerSize + payloadLen); + } + + /** + * Writes a complete ping frame (control frame, no masking needed for server). + * Note: Client frames MUST be masked per RFC 6455. This writes a masked ping. + * + * @return frame info for sending + */ + public FrameInfo writePingFrame() { + return writePingFrame(0, 0); + } + + /** + * Writes a complete ping frame with payload. + * + * @param payloadPtr pointer to ping payload + * @param payloadLen length of payload (max 125 bytes for control frames) + * @return frame info for sending + */ + public FrameInfo writePingFrame(long payloadPtr, int payloadLen) { + if (payloadLen > 125) { + throw new HttpClientException("Ping payload too large [len=").put(payloadLen).put(']'); + } + + int frameStart = writePos; + int headerSize = WebSocketFrameWriter.headerSize(payloadLen, true); + ensureCapacity(headerSize + payloadLen); + + int maskKey = rnd.nextInt(); + int written = WebSocketFrameWriter.writeHeader(bufPtr + writePos, true, WebSocketOpcode.PING, payloadLen, maskKey); + writePos += written; + + if (payloadLen > 0) { + Vect.memcpy(bufPtr + writePos, payloadPtr, payloadLen); + WebSocketFrameWriter.maskPayload(bufPtr + writePos, payloadLen, maskKey); + writePos += payloadLen; + } + + return frameInfo.set(frameStart, headerSize + payloadLen); + } + + /** + * Writes a complete pong frame. + * + * @param payloadPtr pointer to pong payload (should match received ping) + * @param payloadLen length of payload + * @return frame info for sending + */ + public FrameInfo writePongFrame(long payloadPtr, int payloadLen) { + if (payloadLen > 125) { + throw new HttpClientException("Pong payload too large [len=").put(payloadLen).put(']'); + } + + int frameStart = writePos; + int headerSize = WebSocketFrameWriter.headerSize(payloadLen, true); + ensureCapacity(headerSize + payloadLen); + + int maskKey = rnd.nextInt(); + int written = WebSocketFrameWriter.writeHeader(bufPtr + writePos, true, WebSocketOpcode.PONG, payloadLen, maskKey); + writePos += written; + + if (payloadLen > 0) { + Vect.memcpy(bufPtr + writePos, payloadPtr, payloadLen); + WebSocketFrameWriter.maskPayload(bufPtr + writePos, payloadLen, maskKey); + writePos += payloadLen; + } + + return frameInfo.set(frameStart, headerSize + payloadLen); + } + + private void grow(long requiredCapacity) { + if (requiredCapacity > maxBufferSize) { + throw new HttpClientException("WebSocket buffer size exceeded maximum [required=") + .put(requiredCapacity) + .put(", max=") + .put(maxBufferSize) + .put(']'); + } + int newCapacity = Math.min( + Math.max(Numbers.ceilPow2((int) requiredCapacity), (int) requiredCapacity), + maxBufferSize + ); + bufPtr = Unsafe.realloc(bufPtr, bufCapacity, newCapacity, MemoryTag.NATIVE_DEFAULT); + bufCapacity = newCapacity; + } + + /** + * Information about a completed WebSocket frame's location in the buffer. + * This class is mutable and reused to avoid allocations. Callers must + * extract values before calling any end*Frame() method again. + */ + public static final class FrameInfo { + /** + * Total length of the frame (header + payload). + */ + public int length; + /** + * Offset from buffer start where the frame begins. + */ + public int offset; + + FrameInfo set(int offset, int length) { + this.offset = offset; + this.length = length; + return this; + } + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/json/JsonLexer.java b/core/src/main/java/io/questdb/client/cutlass/json/JsonLexer.java index 8b4caf0..1f0cc05 100644 --- a/core/src/main/java/io/questdb/client/cutlass/json/JsonLexer.java +++ b/core/src/main/java/io/questdb/client/cutlass/json/JsonLexer.java @@ -65,6 +65,7 @@ public class JsonLexer implements Mutable, Closeable { private boolean quoted = false; private int state = S_START; private boolean useCache = false; + public JsonLexer(int cacheSize, int cacheSizeLimit) { this.cacheSizeLimit = cacheSizeLimit; // if cacheSizeLimit is 0 or negative, the cache is disabled @@ -398,4 +399,4 @@ private void utf8DecodeCacheAndBuffer(long lo, long hi, int position) throws Jso unquotedTerminators.add('{'); unquotedTerminators.add('['); } -} \ No newline at end of file +} diff --git a/core/src/main/java/io/questdb/client/cutlass/line/AbstractLineSender.java b/core/src/main/java/io/questdb/client/cutlass/line/AbstractLineSender.java index 0ed3937..11274a1 100644 --- a/core/src/main/java/io/questdb/client/cutlass/line/AbstractLineSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/line/AbstractLineSender.java @@ -368,7 +368,7 @@ private static int findEOL(long ptr, int len) { private byte[] receiveChallengeBytes() { int n = 0; - for (;;) { + for (; ; ) { int rc = lineChannel.receive(ptr + n, capacity - n); if (rc < 0) { int errno = lineChannel.errno(); @@ -505,4 +505,4 @@ protected AbstractLineSender writeFieldName(CharSequence name) { } throw new LineSenderException("table expected"); } -} \ No newline at end of file +} diff --git a/core/src/main/java/io/questdb/client/cutlass/line/LineSenderException.java b/core/src/main/java/io/questdb/client/cutlass/line/LineSenderException.java index 9c6cc16..6fdaf9a 100644 --- a/core/src/main/java/io/questdb/client/cutlass/line/LineSenderException.java +++ b/core/src/main/java/io/questdb/client/cutlass/line/LineSenderException.java @@ -39,15 +39,9 @@ *

  • For permanent errors: Either close and recreate the Sender, or call {@code reset()} to clear * the buffer and continue with new data
  • * + *

    + * @see io.questdb.client.Sender * - *

    Retryability

    - * The {@link #isRetryable()} method provides a best-effort indication of whether the error - * might be resolved by retrying at the application level. This is particularly important - * because this exception is only thrown after the sender has exhausted its own internal - * retry attempts. The retryability flag helps applications decide whether to implement - * additional retry logic with longer delays or different strategies. - * - * @see io.questdb.client.Sender * @see io.questdb.client.Sender#flush() * @see io.questdb.client.Sender#reset() */ @@ -115,4 +109,4 @@ public LineSenderException putAsPrintable(CharSequence nonPrintable) { message.putAsPrintable(nonPrintable); return this; } -} \ No newline at end of file +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/GlobalSymbolDictionary.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/GlobalSymbolDictionary.java new file mode 100644 index 0000000..b8c1dfe --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/GlobalSymbolDictionary.java @@ -0,0 +1,145 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.client; + +import io.questdb.client.std.CharSequenceIntHashMap; +import io.questdb.client.std.ObjList; + +/** + * Global symbol dictionary that maps symbol strings to sequential integer IDs. + *

    + * This dictionary is shared across all tables and columns within a client instance. + * IDs are assigned sequentially starting from 0, ensuring contiguous ID space. + *

    + * Thread safety: This class is NOT thread-safe. External synchronization is required + * if accessed from multiple threads. + */ +public class GlobalSymbolDictionary { + + private final ObjList idToSymbol; + private final CharSequenceIntHashMap symbolToId; + + public GlobalSymbolDictionary() { + this(64); // Default initial capacity + } + + public GlobalSymbolDictionary(int initialCapacity) { + this.symbolToId = new CharSequenceIntHashMap(initialCapacity); + this.idToSymbol = new ObjList<>(initialCapacity); + } + + /** + * Clears all symbols from the dictionary. + *

    + * After clearing, the next symbol added will get ID 0. + */ + public void clear() { + symbolToId.clear(); + idToSymbol.clear(); + } + + /** + * Checks if the dictionary contains the given symbol. + * + * @param symbol the symbol to check + * @return true if the symbol exists in the dictionary + */ + public boolean contains(String symbol) { + return symbol != null && symbolToId.get(symbol) != CharSequenceIntHashMap.NO_ENTRY_VALUE; + } + + /** + * Gets the ID for an existing symbol, or -1 if not found. + * + * @param symbol the symbol string + * @return the symbol ID, or -1 if not in dictionary + */ + public int getId(String symbol) { + if (symbol == null) { + return -1; + } + int id = symbolToId.get(symbol); + return id == CharSequenceIntHashMap.NO_ENTRY_VALUE ? -1 : id; + } + + /** + * Gets or adds a symbol to the dictionary. + *

    + * If the symbol already exists, returns its existing ID. + * If the symbol is new, assigns the next sequential ID and returns it. + * + * @param symbol the symbol string (must not be null) + * @return the global ID for this symbol (>= 0) + * @throws IllegalArgumentException if symbol is null + */ + public int getOrAddSymbol(String symbol) { + if (symbol == null) { + throw new IllegalArgumentException("symbol cannot be null"); + } + + int existingId = symbolToId.get(symbol); + if (existingId != CharSequenceIntHashMap.NO_ENTRY_VALUE) { + return existingId; + } + + // Assign new ID + int newId = idToSymbol.size(); + symbolToId.put(symbol, newId); + idToSymbol.add(symbol); + return newId; + } + + /** + * Gets the symbol string for a given ID. + * + * @param id the symbol ID + * @return the symbol string + * @throws IndexOutOfBoundsException if id is out of range + */ + public String getSymbol(int id) { + if (id < 0 || id >= idToSymbol.size()) { + throw new IndexOutOfBoundsException("Invalid symbol ID: " + id + ", dictionary size: " + idToSymbol.size()); + } + return idToSymbol.getQuick(id); + } + + /** + * Checks if the dictionary is empty. + * + * @return true if no symbols have been added + */ + public boolean isEmpty() { + return idToSymbol.size() == 0; + } + + /** + * Returns the number of symbols in the dictionary. + * + * @return dictionary size + */ + public int size() { + return idToSymbol.size(); + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/InFlightWindow.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/InFlightWindow.java new file mode 100644 index 0000000..6447cb8 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/InFlightWindow.java @@ -0,0 +1,479 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.client; + +import io.questdb.client.cutlass.line.LineSenderException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.LockSupport; + +/** + * Lock-free in-flight batch tracker for the sliding window protocol. + *

    + * Concurrency model (lock-free): + *

      + *
    • Async mode: the WebSocket I/O thread sends and receives; it calls + * {@link #tryAddInFlight(long)} before send and {@link #acknowledgeUpTo(long)} + * on ACKs (single writer for sent and acked).
    • + *
    • Sync mode: the caller thread sends and waits synchronously; it calls + * {@link #addInFlight(long)} (window size = 1) then waits for ACK itself on + * the same thread, so the window is always drained inline.
    • + *
    • Waiter: in async mode the caller thread may call {@link #awaitEmpty()} + * during flush to wait for the window to drain; it only reads the counters and + * parks/unparks.
    • + *
    + * Assumptions that keep it simple and lock-free: + *
      + *
    • Batch IDs are sequential (sender increments by 1)
    • + *
    • Single producer updates {@code highestSent}
    • + *
    • Single consumer updates {@code highestAcked}
    • + *
    + * With these constraints we can rely on volatile reads/writes (no CAS) and still + * offer blocking waits for space/empty without protecting the counters with locks. + */ +public class InFlightWindow { + + public static final long DEFAULT_TIMEOUT_MS = 30_000; + public static final int DEFAULT_WINDOW_SIZE = 8; + private static final Logger LOG = LoggerFactory.getLogger(InFlightWindow.class); + private static final long PARK_NANOS = 100_000; // 100 microseconds + // Spin parameters + private static final int SPIN_TRIES = 100; + private static final VarHandle TOTAL_ACKED; + private static final VarHandle TOTAL_FAILED; + + static { + try { + MethodHandles.Lookup lookup = MethodHandles.lookup(); + TOTAL_ACKED = lookup.findVarHandle(InFlightWindow.class, "totalAcked", long.class); + TOTAL_FAILED = lookup.findVarHandle(InFlightWindow.class, "totalFailed", long.class); + } catch (ReflectiveOperationException e) { + throw new ExceptionInInitializerError(e); + } + } + // Error state + private final AtomicReference lastError = new AtomicReference<>(); + private final int maxWindowSize; + private final long timeoutMs; + private volatile long failedBatchId = -1; + // highestAcked: the sequence number of the last acknowledged batch (cumulative) + private volatile long highestAcked = -1; + // Core state + // highestSent: the sequence number of the last batch added to the window + private volatile long highestSent = -1; + // Statistics — updated atomically via VarHandle + private long totalAcked = 0; + private long totalFailed = 0; + // Thread waiting for empty (flush thread) + private volatile Thread waitingForEmpty; + // Thread waiting for space (sender thread) + private volatile Thread waitingForSpace; + + /** + * Creates a new InFlightWindow with default configuration. + */ + public InFlightWindow() { + this(DEFAULT_WINDOW_SIZE, DEFAULT_TIMEOUT_MS); + } + + /** + * Creates a new InFlightWindow with custom configuration. + * + * @param maxWindowSize maximum number of batches in flight + * @param timeoutMs timeout for blocking operations + */ + public InFlightWindow(int maxWindowSize, long timeoutMs) { + if (maxWindowSize <= 0) { + throw new IllegalArgumentException("maxWindowSize must be positive"); + } + this.maxWindowSize = maxWindowSize; + this.timeoutMs = timeoutMs; + } + + /** + * Acknowledges a batch, removing it from the in-flight window. + *

    + * For sequential batch IDs, this is a cumulative acknowledgment - + * acknowledging batch N means all batches up to N are acknowledged. + *

    + * Called by: acker (WebSocket I/O thread) after receiving an ACK. + * + * @param batchId the batch ID that was acknowledged + * @return true if the batch was in flight, false if already acknowledged + */ + public boolean acknowledge(long batchId) { + return acknowledgeUpTo(batchId) > 0 || highestAcked >= batchId; + } + + /** + * Acknowledges all batches up to and including the given sequence (cumulative ACK). + * Lock-free with single consumer. + *

    + * Called by: acker (WebSocket I/O thread) after receiving an ACK. + * + * @param sequence the highest acknowledged sequence + * @return the number of batches acknowledged + */ + public int acknowledgeUpTo(long sequence) { + long sent = highestSent; + + // Nothing to acknowledge if window is empty or sequence is beyond what's sent + if (sent < 0) { + return 0; // No batches have been sent + } + + // Cap sequence at highestSent - can't acknowledge what hasn't been sent + long effectiveSequence = Math.min(sequence, sent); + + long prevAcked = highestAcked; + if (effectiveSequence <= prevAcked) { + // Already acknowledged up to this point + return 0; + } + highestAcked = effectiveSequence; + + int acknowledged = (int) (effectiveSequence - prevAcked); + TOTAL_ACKED.getAndAdd(this, (long) acknowledged); + + LOG.debug("Cumulative ACK [upTo={}, acknowledged={}, remaining={}]", sequence, acknowledged, getInFlightCount()); + + // Wake up waiting threads + Thread waiter = waitingForSpace; + if (waiter != null) { + LockSupport.unpark(waiter); + } + + waiter = waitingForEmpty; + if (waiter != null && getInFlightCount() == 0) { + LockSupport.unpark(waiter); + } + + return acknowledged; + } + + /** + * Adds a batch to the in-flight window. + *

    + * Blocks if the window is full until space becomes available or timeout. + * Uses spin-wait with exponential backoff, then parks. Blocking is only expected + * in modes where another actor can make progress on acknowledgments. In normal + * sync usage the window size is 1 and the same thread immediately waits for the + * ACK, so this should never actually park. If a caller uses a larger window here + * it must ensure ACKs are processed on another thread; a single-threaded caller + * with window>1 would deadlock by parking while also being the only thread that + * can advance {@link #acknowledgeUpTo(long)}. + *

    + * Called by: sync sender thread before sending a batch (window=1). + * + * @param batchId the batch ID to track + * @throws LineSenderException if timeout occurs or an error was reported + */ + public void addInFlight(long batchId) { + // Check for errors first + checkError(); + + // Fast path: try to add without waiting + if (tryAddInFlightInternal(batchId)) { + return; + } + + // Slow path: need to wait for space + long deadline = System.currentTimeMillis() + timeoutMs; + int spins = 0; + + // Register as waiting thread + waitingForSpace = Thread.currentThread(); + try { + while (true) { + // Check for errors + checkError(); + + // Try to add + if (tryAddInFlightInternal(batchId)) { + return; + } + + // Check timeout + long remaining = deadline - System.currentTimeMillis(); + if (remaining <= 0) { + throw new LineSenderException("Timeout waiting for window space, window full with " + + getInFlightCount() + " batches"); + } + + // Spin or park + if (spins < SPIN_TRIES) { + Thread.onSpinWait(); + spins++; + } else { + // Park with timeout + LockSupport.parkNanos(Math.min(PARK_NANOS, remaining * 1_000_000)); + if (Thread.interrupted()) { + throw new LineSenderException("Interrupted while waiting for window space"); + } + } + } + } finally { + waitingForSpace = null; + } + } + + /** + * Waits until all in-flight batches are acknowledged. + *

    + * Called by flush() to ensure all data is confirmed. + *

    + * Called by: waiter (flush thread), while producer/acker thread progresses. + * + * @throws LineSenderException if timeout occurs or an error was reported + */ + public void awaitEmpty() { + checkError(); + + // Fast path: already empty + if (getInFlightCount() == 0) { + LOG.debug("Window already empty"); + return; + } + + long deadline = System.currentTimeMillis() + timeoutMs; + int spins = 0; + + // Register as waiting thread + waitingForEmpty = Thread.currentThread(); + try { + while (getInFlightCount() > 0) { + checkError(); + + long remaining = deadline - System.currentTimeMillis(); + if (remaining <= 0) { + throw new LineSenderException("Timeout waiting for batch acknowledgments, " + + getInFlightCount() + " batches still in flight"); + } + + if (spins < SPIN_TRIES) { + Thread.onSpinWait(); + spins++; + } else { + LockSupport.parkNanos(Math.min(PARK_NANOS, remaining * 1_000_000)); + if (Thread.interrupted()) { + throw new LineSenderException("Interrupted while waiting for acknowledgments"); + } + } + } + + LOG.debug("Window empty, all batches ACKed"); + } finally { + waitingForEmpty = null; + } + } + + /** + * Clears the error state. + */ + public void clearError() { + lastError.set(null); + failedBatchId = -1; + } + + /** + * Marks a batch as failed, setting an error that will be propagated to waiters. + *

    + * Called by: acker (WebSocket I/O thread) on error response or send failure. + * + * @param batchId the batch ID that failed + * @param error the error that occurred + */ + public void fail(long batchId, Throwable error) { + this.failedBatchId = batchId; + this.lastError.set(error); + TOTAL_FAILED.getAndAdd(this, 1L); + + LOG.error("Batch failed [batchId={}, error={}]", batchId, String.valueOf(error)); + + wakeWaiters(); + } + + /** + * Marks all currently in-flight batches as failed. + *

    + * Used for transport-level failures (disconnect/protocol violation) where + * no further ACKs are expected and all waiters must be released. + * + * @param error terminal error to propagate + */ + public void failAll(Throwable error) { + long sent = highestSent; + long acked = highestAcked; + long inFlight = Math.max(0, sent - acked); + + this.failedBatchId = sent; + this.lastError.set(error); + TOTAL_FAILED.getAndAdd(this, Math.max(1L, inFlight)); + + LOG.error("All in-flight batches failed [inFlight={}, error={}]", inFlight, String.valueOf(error)); + + wakeWaiters(); + } + + /** + * Returns the current number of batches in flight. + * Wait-free operation. + */ + public int getInFlightCount() { + long sent = highestSent; + long acked = highestAcked; + // Ensure non-negative (can happen during initialization) + return (int) Math.max(0, sent - acked); + } + + /** + * Returns the last error, or null if no error. + */ + public Throwable getLastError() { + return lastError.get(); + } + + /** + * Returns the maximum window size. + */ + public int getMaxWindowSize() { + return maxWindowSize; + } + + /** + * Returns the total number of batches acknowledged. + */ + public long getTotalAcked() { + return (long) TOTAL_ACKED.getOpaque(this); + } + + /** + * Returns the total number of batches that failed. + */ + public long getTotalFailed() { + return (long) TOTAL_FAILED.getOpaque(this); + } + + /** + * Checks if there's space in the window for another batch. + * Wait-free operation. + * + * @return true if there's space, false if window is full + */ + public boolean hasWindowSpace() { + return getInFlightCount() < maxWindowSize; + } + + /** + * Returns true if the window is empty. + * Wait-free operation. + */ + public boolean isEmpty() { + return getInFlightCount() == 0; + } + + /** + * Returns true if the window is full. + * Wait-free operation. + */ + public boolean isFull() { + return getInFlightCount() >= maxWindowSize; + } + + /** + * Resets the window, clearing all state. + */ + public void reset() { + highestSent = -1; + highestAcked = -1; + lastError.set(null); + failedBatchId = -1; + + wakeWaiters(); + } + + /** + * Tries to add a batch to the in-flight window without blocking. + * Lock-free, assuming single producer for highestSent. + *

    + * Called by: async producer (WebSocket I/O thread) before sending a batch. + * + * @param batchId the batch ID to track (must be sequential) + * @return true if added, false if window is full + */ + public boolean tryAddInFlight(long batchId) { + // Check window space first + long sent = highestSent; + long acked = highestAcked; + + if (sent - acked >= maxWindowSize) { + return false; + } + + // Sequential caller: just publish the new highestSent + highestSent = batchId; + + LOG.debug("Added to window [batchId={}, windowSize={}]", batchId, getInFlightCount()); + return true; + } + + private void checkError() { + Throwable error = lastError.get(); + if (error != null) { + throw new LineSenderException("Batch " + failedBatchId + " failed: " + error.getMessage(), error); + } + } + + private boolean tryAddInFlightInternal(long batchId) { + long sent = highestSent; + long acked = highestAcked; + + if (sent - acked >= maxWindowSize) { + return false; + } + + // For sequential IDs, we just update highestSent + // The caller guarantees batchId is the next in sequence + highestSent = batchId; + + LOG.debug("Added to window [batchId={}, windowSize={}]", batchId, getInFlightCount()); + return true; + } + + private void wakeWaiters() { + Thread waiter = waitingForSpace; + if (waiter != null) { + LockSupport.unpark(waiter); + } + waiter = waitingForEmpty; + if (waiter != null) { + LockSupport.unpark(waiter); + } + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/MicrobatchBuffer.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/MicrobatchBuffer.java new file mode 100644 index 0000000..4f5bfbf --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/MicrobatchBuffer.java @@ -0,0 +1,471 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.client; + +import io.questdb.client.std.MemoryTag; +import io.questdb.client.std.QuietCloseable; +import io.questdb.client.std.Unsafe; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * A buffer for accumulating ILP data into microbatches before sending. + *

    + * This class implements a state machine for buffer lifecycle management in the + * double-buffering scheme used by {@link QwpWebSocketSender}: + *

    + * Buffer States:
    + * ┌─────────────┐    seal()     ┌─────────────┐    markSending()  ┌─────────────┐
    + * │   FILLING   │──────────────►│   SEALED    │──────────────────►│   SENDING   │
    + * │ (user owns) │               │ (in queue)  │                   │ (I/O owns)  │
    + * └─────────────┘               └─────────────┘                   └──────┬──────┘
    + *        ▲                                                               │
    + *        │                         markRecycled()                        │
    + *        └───────────────────────────────────────────────────────────────┘
    + *                              (after send complete)
    + * 
    + *

    + * Thread safety: This class is NOT thread-safe for concurrent writes. However, it + * supports safe hand-over between user thread and I/O thread through the state + * machine. State transitions use volatile fields to ensure visibility. + */ +public class MicrobatchBuffer implements QuietCloseable { + + // Buffer states + public static final int STATE_FILLING = 0; + public static final int STATE_RECYCLED = 3; + public static final int STATE_SEALED = 1; + public static final int STATE_SENDING = 2; + private static final AtomicLong nextBatchId = new AtomicLong(); + private final long maxAgeNanos; + private final int maxBytes; + // Flush trigger thresholds + private final int maxRows; + // Batch identification + private long batchId; + private int bufferCapacity; + private int bufferPos; + // Native memory buffer + private long bufferPtr; + private long firstRowTimeNanos; + // Symbol tracking for delta encoding + private int maxSymbolId = -1; + // For waiting on recycle (user thread waits for I/O thread to finish) + // CountDownLatch is not resettable, so we create a new instance on reset() + private volatile CountDownLatch recycleLatch = new CountDownLatch(1); + // Row tracking + private int rowCount; + // State machine + private volatile int state = STATE_FILLING; + + /** + * Creates a new MicrobatchBuffer with specified flush thresholds. + * + * @param initialCapacity initial buffer size in bytes + * @param maxRows maximum rows before auto-flush (0 = unlimited) + * @param maxBytes maximum bytes before auto-flush (0 = unlimited) + * @param maxAgeNanos maximum age in nanoseconds before auto-flush (0 = unlimited) + */ + public MicrobatchBuffer(int initialCapacity, int maxRows, int maxBytes, long maxAgeNanos) { + if (initialCapacity <= 0) { + throw new IllegalArgumentException("initialCapacity must be positive"); + } + this.bufferCapacity = initialCapacity; + this.bufferPtr = Unsafe.malloc(initialCapacity, MemoryTag.NATIVE_ILP_RSS); + this.bufferPos = 0; + this.rowCount = 0; + this.firstRowTimeNanos = 0; + this.maxRows = maxRows; + this.maxBytes = maxBytes; + this.maxAgeNanos = maxAgeNanos; + this.batchId = nextBatchId.getAndIncrement(); + } + + /** + * Creates a new MicrobatchBuffer with default thresholds (no auto-flush). + * + * @param initialCapacity initial buffer size in bytes + */ + public MicrobatchBuffer(int initialCapacity) { + this(initialCapacity, 0, 0, 0); + } + + /** + * Returns a human-readable name for the given state. + */ + public static String stateName(int state) { + return switch (state) { + case STATE_FILLING -> "FILLING"; + case STATE_SEALED -> "SEALED"; + case STATE_SENDING -> "SENDING"; + case STATE_RECYCLED -> "RECYCLED"; + default -> "UNKNOWN(" + state + ")"; + }; + } + + /** + * Waits for the buffer to be recycled (transition to RECYCLED state). + * Only the user thread should call this. + */ + public void awaitRecycled() { + try { + recycleLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + /** + * Waits for the buffer to be recycled with a timeout. + * + * @param timeout the maximum time to wait + * @param unit the time unit + * @return true if recycled, false if timeout elapsed + */ + public boolean awaitRecycled(long timeout, TimeUnit unit) { + try { + return recycleLatch.await(timeout, unit); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + + @Override + public void close() { + if (bufferPtr != 0) { + Unsafe.free(bufferPtr, bufferCapacity, MemoryTag.NATIVE_ILP_RSS); + bufferPtr = 0; + bufferCapacity = 0; + } + } + + /** + * Ensures the buffer has at least the specified capacity. + * Grows the buffer if necessary. + * + * @param requiredCapacity minimum required capacity + */ + public void ensureCapacity(int requiredCapacity) { + if (state != STATE_FILLING) { + throw new IllegalStateException("Cannot resize when state is " + stateName(state)); + } + if (requiredCapacity > bufferCapacity) { + int newCapacity = (int) Math.min(Math.max((long) bufferCapacity * 2, requiredCapacity), Integer.MAX_VALUE); + bufferPtr = Unsafe.realloc(bufferPtr, bufferCapacity, newCapacity, MemoryTag.NATIVE_ILP_RSS); + bufferCapacity = newCapacity; + } + } + + /** + * Returns the age of the first row in nanoseconds, or 0 if no rows. + */ + public long getAgeNanos() { + if (rowCount == 0) { + return 0; + } + return System.nanoTime() - firstRowTimeNanos; + } + + /** + * Returns the batch ID for this buffer. + */ + public long getBatchId() { + return batchId; + } + + /** + * Returns the buffer capacity. + */ + public int getBufferCapacity() { + return bufferCapacity; + } + + /** + * Returns the current write position in the buffer. + */ + public int getBufferPos() { + return bufferPos; + } + + /** + * Returns the buffer pointer for writing data. + * Only valid when state is FILLING. + */ + public long getBufferPtr() { + return bufferPtr; + } + + /** + * Returns the number of rows in this buffer. + */ + public int getRowCount() { + return rowCount; + } + + /** + * Returns the current state. + */ + public int getState() { + return state; + } + + /** + * Returns true if the buffer has any data. + */ + public boolean hasData() { + return bufferPos > 0; + } + + /** + * Increments the row count and records the first row time if this is the first row. + */ + public void incrementRowCount() { + if (state != STATE_FILLING) { + throw new IllegalStateException("Cannot increment row count when state is " + stateName(state)); + } + if (rowCount == 0) { + firstRowTimeNanos = System.nanoTime(); + } + rowCount++; + } + + /** + * Checks if the age limit has been exceeded. + */ + public boolean isAgeLimitExceeded() { + if (maxAgeNanos <= 0 || rowCount == 0) { + return false; + } + long ageNanos = System.nanoTime() - firstRowTimeNanos; + return ageNanos >= maxAgeNanos; + } + + /** + * Checks if the byte size limit has been exceeded. + */ + public boolean isByteLimitExceeded() { + return maxBytes > 0 && bufferPos >= maxBytes; + } + + /** + * Returns true if the buffer is in FILLING state (available for writing). + */ + public boolean isFilling() { + return state == STATE_FILLING; + } + + /** + * Returns true if the buffer is currently in use (not available for the user thread). + */ + public boolean isInUse() { + int s = state; + return s == STATE_SEALED || s == STATE_SENDING; + } + + /** + * Returns true if the buffer is in RECYCLED state (available for reset). + */ + public boolean isRecycled() { + return state == STATE_RECYCLED; + } + + /** + * Checks if the row count limit has been exceeded. + */ + public boolean isRowLimitExceeded() { + return maxRows > 0 && rowCount >= maxRows; + } + + /** + * Returns true if the buffer is in SEALED state (ready to send). + */ + public boolean isSealed() { + return state == STATE_SEALED; + } + + /** + * Returns true if the buffer is in SENDING state (being sent by I/O thread). + */ + public boolean isSending() { + return state == STATE_SENDING; + } + + /** + * Marks the buffer as recycled, transitioning from SENDING to RECYCLED. + * This signals to the user thread that the buffer can be reused. + * Only the I/O thread should call this. + * + * @throws IllegalStateException if not in SENDING state + */ + public void markRecycled() { + if (state != STATE_SENDING) { + throw new IllegalStateException("Cannot mark recycled in state " + stateName(state)); + } + state = STATE_RECYCLED; + recycleLatch.countDown(); + } + + /** + * Marks the buffer as being sent, transitioning from SEALED to SENDING. + * Only the I/O thread should call this. + * + * @throws IllegalStateException if not in SEALED state + */ + public void markSending() { + if (state != STATE_SEALED) { + throw new IllegalStateException("Cannot mark sending in state " + stateName(state)); + } + state = STATE_SENDING; + } + + /** + * Resets the buffer to FILLING state, clearing all data. + * Only valid when in RECYCLED state or when the buffer is fresh. + * + * @throws IllegalStateException if in SEALED or SENDING state + */ + public void reset() { + int s = state; + if (s == STATE_SEALED || s == STATE_SENDING) { + throw new IllegalStateException("Cannot reset buffer in state " + stateName(s)); + } + bufferPos = 0; + rowCount = 0; + firstRowTimeNanos = 0; + maxSymbolId = -1; + batchId = nextBatchId.getAndIncrement(); + state = STATE_FILLING; + recycleLatch = new CountDownLatch(1); + } + + /** + * Rolls back a seal operation, transitioning from SEALED back to FILLING. + *

    + * Used when enqueue fails after a buffer has been sealed but before ownership + * was transferred to the I/O thread. + * + * @throws IllegalStateException if not in SEALED state + */ + public void rollbackSealForRetry() { + if (state != STATE_SEALED) { + throw new IllegalStateException("Cannot rollback seal in state " + stateName(state)); + } + state = STATE_FILLING; + } + + /** + * Seals the buffer, transitioning from FILLING to SEALED. + * After sealing, no more data can be written. + * Only the user thread should call this. + * + * @throws IllegalStateException if not in FILLING state + */ + public void seal() { + if (state != STATE_FILLING) { + throw new IllegalStateException("Cannot seal buffer in state " + stateName(state)); + } + state = STATE_SEALED; + } + + /** + * Sets the buffer position after external writes. + * Only valid when state is FILLING. + * + * @param pos new position + */ + public void setBufferPos(int pos) { + if (state != STATE_FILLING) { + throw new IllegalStateException("Cannot set position when state is " + stateName(state)); + } + if (pos < 0 || pos > bufferCapacity) { + throw new IllegalArgumentException("Position out of bounds: " + pos); + } + this.bufferPos = pos; + } + + /** + * Sets the maximum symbol ID used in this batch. + * Used for delta symbol dictionary tracking. + */ + public void setMaxSymbolId(int maxSymbolId) { + this.maxSymbolId = maxSymbolId; + } + + /** + * Checks if the buffer should be flushed based on configured thresholds. + * + * @return true if any flush threshold is exceeded + */ + public boolean shouldFlush() { + if (!hasData()) { + return false; + } + return isRowLimitExceeded() || isByteLimitExceeded() || isAgeLimitExceeded(); + } + + @Override + public String toString() { + return "MicrobatchBuffer{" + + "batchId=" + batchId + + ", state=" + stateName(state) + + ", rows=" + rowCount + + ", bytes=" + bufferPos + + ", capacity=" + bufferCapacity + + '}'; + } + + /** + * Writes bytes to the buffer at the current position. + * Grows the buffer if necessary. + * + * @param src source address + * @param length number of bytes to write + */ + public void write(long src, int length) { + if (state != STATE_FILLING) { + throw new IllegalStateException("Cannot write when state is " + stateName(state)); + } + ensureCapacity((int) Math.min((long) bufferPos + length, Integer.MAX_VALUE)); + Unsafe.getUnsafe().copyMemory(src, bufferPtr + bufferPos, length); + bufferPos += length; + } + + /** + * Writes a single byte to the buffer. + * + * @param b byte to write + */ + public void writeByte(byte b) { + if (state != STATE_FILLING) { + throw new IllegalStateException("Cannot write when state is " + stateName(state)); + } + ensureCapacity((int) Math.min((long) bufferPos + 1, Integer.MAX_VALUE)); + Unsafe.getUnsafe().putByte(bufferPtr + bufferPos, b); + bufferPos++; + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/NativeBufferWriter.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/NativeBufferWriter.java new file mode 100644 index 0000000..e538769 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/NativeBufferWriter.java @@ -0,0 +1,306 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.client; + +import io.questdb.client.std.MemoryTag; +import io.questdb.client.std.QuietCloseable; +import io.questdb.client.std.Unsafe; + +/** + * A simple native memory buffer writer for encoding ILP v4 messages. + *

    + * This class provides write methods similar to HttpClient.Request but writes + * to a native memory buffer that can be sent over WebSocket. + *

    + * All multi-byte values are written in little-endian format unless otherwise specified. + */ +public class NativeBufferWriter implements QwpBufferWriter, QuietCloseable { + + private static final int DEFAULT_CAPACITY = 8192; + + private long bufferPtr; + private int capacity; + private int position; + + public NativeBufferWriter() { + this(DEFAULT_CAPACITY); + } + + public NativeBufferWriter(int initialCapacity) { + this.capacity = initialCapacity; + this.bufferPtr = Unsafe.malloc(capacity, MemoryTag.NATIVE_DEFAULT); + this.position = 0; + } + + /** + * Returns the UTF-8 encoded length of a string. + * + * @param s the string (may be null) + * @return the number of bytes needed to encode the string as UTF-8 + */ + public static int utf8Length(String s) { + if (s == null) return 0; + int len = 0; + for (int i = 0, n = s.length(); i < n; i++) { + char c = s.charAt(i); + if (c < 0x80) { + len++; + } else if (c < 0x800) { + len += 2; + } else if (c >= 0xD800 && c <= 0xDBFF && i + 1 < n && Character.isLowSurrogate(s.charAt(i + 1))) { + i++; + len += 4; + } else if (Character.isSurrogate(c)) { + len++; + } else { + len += 3; + } + } + return len; + } + + @Override + public void close() { + if (bufferPtr != 0) { + Unsafe.free(bufferPtr, capacity, MemoryTag.NATIVE_DEFAULT); + bufferPtr = 0; + } + } + + /** + * Ensures the buffer has at least the specified additional capacity. + * + * @param needed additional bytes needed beyond current position + */ + @Override + public void ensureCapacity(int needed) { + if (position + needed > capacity) { + int newCapacity = Math.max(capacity * 2, position + needed); + bufferPtr = Unsafe.realloc(bufferPtr, capacity, newCapacity, MemoryTag.NATIVE_DEFAULT); + capacity = newCapacity; + } + } + + /** + * Returns the buffer pointer. + */ + @Override + public long getBufferPtr() { + return bufferPtr; + } + + /** + * Returns the current buffer capacity. + */ + @Override + public int getCapacity() { + return capacity; + } + + /** + * Returns the current write position (number of bytes written). + */ + @Override + public int getPosition() { + return position; + } + + /** + * Patches an int value at the specified offset. + * Used for updating length fields after writing content. + */ + @Override + public void patchInt(int offset, int value) { + Unsafe.getUnsafe().putInt(bufferPtr + offset, value); + } + + /** + * Writes a block of bytes from native memory. + */ + @Override + public void putBlockOfBytes(long from, long len) { + if (len < 0 || len > Integer.MAX_VALUE) { + throw new IllegalArgumentException("len exceeds int range: " + len); + } + int intLen = (int) len; + ensureCapacity(intLen); + Unsafe.getUnsafe().copyMemory(from, bufferPtr + position, intLen); + position += intLen; + } + + /** + * Writes a single byte. + */ + @Override + public void putByte(byte value) { + ensureCapacity(1); + Unsafe.getUnsafe().putByte(bufferPtr + position, value); + position++; + } + + /** + * Writes a double (8 bytes, little-endian). + */ + @Override + public void putDouble(double value) { + ensureCapacity(8); + Unsafe.getUnsafe().putDouble(bufferPtr + position, value); + position += 8; + } + + /** + * Writes a float (4 bytes, little-endian). + */ + @Override + public void putFloat(float value) { + ensureCapacity(4); + Unsafe.getUnsafe().putFloat(bufferPtr + position, value); + position += 4; + } + + /** + * Writes an int (4 bytes, little-endian). + */ + @Override + public void putInt(int value) { + ensureCapacity(4); + Unsafe.getUnsafe().putInt(bufferPtr + position, value); + position += 4; + } + + /** + * Writes a long (8 bytes, little-endian). + */ + @Override + public void putLong(long value) { + ensureCapacity(8); + Unsafe.getUnsafe().putLong(bufferPtr + position, value); + position += 8; + } + + /** + * Writes a long in big-endian order. + */ + @Override + public void putLongBE(long value) { + ensureCapacity(8); + Unsafe.getUnsafe().putLong(bufferPtr + position, Long.reverseBytes(value)); + position += 8; + } + + /** + * Writes a short (2 bytes, little-endian). + */ + @Override + public void putShort(short value) { + ensureCapacity(2); + Unsafe.getUnsafe().putShort(bufferPtr + position, value); + position += 2; + } + + /** + * Writes a length-prefixed UTF-8 string. + */ + @Override + public void putString(String value) { + if (value == null || value.isEmpty()) { + putVarint(0); + return; + } + + int utf8Len = utf8Length(value); + putVarint(utf8Len); + putUtf8(value); + } + + /** + * Writes UTF-8 bytes directly without length prefix. + */ + @Override + public void putUtf8(String value) { + if (value == null || value.isEmpty()) { + return; + } + for (int i = 0, n = value.length(); i < n; i++) { + char c = value.charAt(i); + if (c < 0x80) { + putByte((byte) c); + } else if (c < 0x800) { + putByte((byte) (0xC0 | (c >> 6))); + putByte((byte) (0x80 | (c & 0x3F))); + } else if (c >= 0xD800 && c <= 0xDBFF && i + 1 < n) { + char c2 = value.charAt(++i); + if (Character.isLowSurrogate(c2)) { + int codePoint = 0x10000 + ((c - 0xD800) << 10) + (c2 - 0xDC00); + putByte((byte) (0xF0 | (codePoint >> 18))); + putByte((byte) (0x80 | ((codePoint >> 12) & 0x3F))); + putByte((byte) (0x80 | ((codePoint >> 6) & 0x3F))); + putByte((byte) (0x80 | (codePoint & 0x3F))); + } else { + putByte((byte) '?'); + i--; + } + } else if (Character.isSurrogate(c)) { + putByte((byte) '?'); + } else { + putByte((byte) (0xE0 | (c >> 12))); + putByte((byte) (0x80 | ((c >> 6) & 0x3F))); + putByte((byte) (0x80 | (c & 0x3F))); + } + } + } + + /** + * Writes a varint (unsigned LEB128). + */ + @Override + public void putVarint(long value) { + while (value > 0x7F) { + putByte((byte) ((value & 0x7F) | 0x80)); + value >>>= 7; + } + putByte((byte) value); + } + + /** + * Resets the buffer for reuse. + */ + @Override + public void reset() { + position = 0; + } + + /** + * Skips the specified number of bytes, advancing the position. + * Used when data has been written directly to the buffer via getBufferPtr(). + * + * @param bytes number of bytes to skip + */ + @Override + public void skip(int bytes) { + ensureCapacity(bytes); + position += bytes; + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpBufferWriter.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpBufferWriter.java new file mode 100644 index 0000000..644fdf8 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpBufferWriter.java @@ -0,0 +1,138 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.client; + +import io.questdb.client.cutlass.line.array.ArrayBufferAppender; + +/** + * Buffer writer interface for ILP v4 message encoding. + *

    + * This interface extends {@link ArrayBufferAppender} with additional methods + * required for encoding ILP v4 messages, including varint encoding, string + * handling, and buffer manipulation. + *

    + * Implementations include: + *

      + *
    • {@link NativeBufferWriter} - standalone native memory buffer
    • + *
    • {@link io.questdb.client.cutlass.http.client.WebSocketSendBuffer} - WebSocket frame buffer
    • + *
    + *

    + * All multi-byte values are written in little-endian format unless the method + * name explicitly indicates big-endian (e.g., {@link #putLongBE}). + */ +public interface QwpBufferWriter extends ArrayBufferAppender { + + /** + * Ensures the buffer has capacity for at least the specified + * additional bytes beyond the current position. + * + * @param additionalBytes number of additional bytes needed + */ + void ensureCapacity(int additionalBytes); + + /** + * Returns the native memory pointer to the buffer start. + *

    + * The returned pointer is valid until the next buffer growth operation. + * Use with care and only for reading completed data. + */ + long getBufferPtr(); + + /** + * Returns the current buffer capacity in bytes. + */ + int getCapacity(); + + /** + * Returns the current write position (number of bytes written). + */ + int getPosition(); + + /** + * Patches an int value at the specified offset in the buffer. + *

    + * Used for updating length fields after writing content. + * + * @param offset the byte offset from buffer start + * @param value the int value to write + */ + void patchInt(int offset, int value); + + /** + * Writes a float (4 bytes, little-endian). + */ + void putFloat(float value); + + /** + * Writes a long in big-endian byte order. + */ + void putLongBE(long value); + + /** + * Writes a short (2 bytes, little-endian). + */ + void putShort(short value); + + /** + * Writes a length-prefixed UTF-8 string. + *

    + * Format: varint length + UTF-8 bytes + * + * @param value the string to write (may be null or empty) + */ + void putString(String value); + + /** + * Writes UTF-8 encoded bytes directly without length prefix. + * + * @param value the string to encode (may be null or empty) + */ + void putUtf8(String value); + + /** + * Writes an unsigned variable-length integer (LEB128 encoding). + *

    + * Each byte contains 7 bits of data with the high bit indicating + * whether more bytes follow. + */ + void putVarint(long value); + + /** + * Resets the buffer for reuse, setting the position to 0. + *

    + * Does not deallocate memory. + */ + void reset(); + + /** + * Skips the specified number of bytes, advancing the position. + *

    + * Used when data has been written directly to the buffer via + * {@link #getBufferPtr()}. + * + * @param bytes number of bytes to skip + */ + void skip(int bytes); +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketEncoder.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketEncoder.java new file mode 100644 index 0000000..632e4c8 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketEncoder.java @@ -0,0 +1,477 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.client; + +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.cutlass.qwp.protocol.QwpColumnDef; +import io.questdb.client.cutlass.qwp.protocol.QwpGorillaEncoder; +import io.questdb.client.cutlass.qwp.protocol.QwpTableBuffer; +import io.questdb.client.std.QuietCloseable; +import io.questdb.client.std.Unsafe; + +import static io.questdb.client.cutlass.qwp.protocol.QwpConstants.*; + +/** + * Encodes ILP v4 messages for WebSocket transport. + *

    + * This encoder reads column data from off-heap {@link io.questdb.client.cutlass.qwp.protocol.OffHeapAppendMemory} + * buffers in {@link QwpTableBuffer.ColumnBuffer} and uses bulk {@code putBlockOfBytes} for fixed-width + * types where wire format matches native byte order. + *

    + * Types that use bulk copy (native byte-order on wire): + * BYTE, SHORT, INT, LONG, FLOAT, DOUBLE, DATE, UUID, LONG256 + *

    + * Types that require element-by-element encoding: + * BOOLEAN (bit-packed on wire), TIMESTAMP (Gorilla), DECIMAL64/128/256 (big-endian on wire) + */ +public class QwpWebSocketEncoder implements QuietCloseable { + + public static final byte ENCODING_GORILLA = 0x01; + public static final byte ENCODING_UNCOMPRESSED = 0x00; + private final QwpGorillaEncoder gorillaEncoder = new QwpGorillaEncoder(); + private NativeBufferWriter buffer; + private byte flags; + + public QwpWebSocketEncoder() { + this.buffer = new NativeBufferWriter(); + this.flags = 0; + } + + public QwpWebSocketEncoder(int bufferSize) { + this.buffer = new NativeBufferWriter(bufferSize); + this.flags = 0; + } + + @Override + public void close() { + if (buffer != null) { + buffer.close(); + buffer = null; + } + } + + public int encode(QwpTableBuffer tableBuffer, boolean useSchemaRef) { + buffer.reset(); + writeHeader(1, 0); + int payloadStart = buffer.getPosition(); + encodeTable(tableBuffer, useSchemaRef, false); + int payloadLength = buffer.getPosition() - payloadStart; + buffer.patchInt(8, payloadLength); + return buffer.getPosition(); + } + + public int encodeWithDeltaDict( + QwpTableBuffer tableBuffer, + GlobalSymbolDictionary globalDict, + int confirmedMaxId, + int batchMaxId, + boolean useSchemaRef + ) { + buffer.reset(); + int deltaStart = confirmedMaxId + 1; + int deltaCount = Math.max(0, batchMaxId - confirmedMaxId); + byte savedFlags = flags; + flags |= FLAG_DELTA_SYMBOL_DICT; + writeHeader(1, 0); + int payloadStart = buffer.getPosition(); + buffer.putVarint(deltaStart); + buffer.putVarint(deltaCount); + for (int id = deltaStart; id < deltaStart + deltaCount; id++) { + String symbol = globalDict.getSymbol(id); + buffer.putString(symbol); + } + encodeTable(tableBuffer, useSchemaRef, true); + int payloadLength = buffer.getPosition() - payloadStart; + buffer.patchInt(8, payloadLength); + flags = savedFlags; + return buffer.getPosition(); + } + + public QwpBufferWriter getBuffer() { + return buffer; + } + + public boolean isGorillaEnabled() { + return (flags & FLAG_GORILLA) != 0; + } + + public void setGorillaEnabled(boolean enabled) { + if (enabled) { + flags |= FLAG_GORILLA; + } else { + flags &= ~FLAG_GORILLA; + } + } + + public void writeHeader(int tableCount, int payloadLength) { + buffer.putByte((byte) 'Q'); + buffer.putByte((byte) 'W'); + buffer.putByte((byte) 'P'); + buffer.putByte((byte) '1'); + buffer.putByte(VERSION_1); + buffer.putByte(flags); + buffer.putShort((short) tableCount); + buffer.putInt(payloadLength); + } + + private void encodeColumn(QwpTableBuffer.ColumnBuffer col, QwpColumnDef colDef, int rowCount, boolean useGorilla, boolean useGlobalSymbols) { + int valueCount = col.getValueCount(); + long dataAddr = col.getDataAddress(); + + if (colDef.isNullable()) { + writeNullBitmap(col, rowCount); + } + + switch (col.getType()) { + case TYPE_BOOLEAN: + writeBooleanColumn(dataAddr, valueCount); + break; + case TYPE_BYTE: + buffer.putBlockOfBytes(dataAddr, valueCount); + break; + case TYPE_SHORT: + case TYPE_CHAR: + buffer.putBlockOfBytes(dataAddr, (long) valueCount * 2); + break; + case TYPE_INT, TYPE_FLOAT: + buffer.putBlockOfBytes(dataAddr, (long) valueCount * 4); + break; + case TYPE_LONG, TYPE_DATE, TYPE_DOUBLE: + buffer.putBlockOfBytes(dataAddr, (long) valueCount * 8); + break; + case TYPE_TIMESTAMP: + case TYPE_TIMESTAMP_NANOS: + writeTimestampColumn(dataAddr, valueCount, useGorilla); + break; + case TYPE_GEOHASH: + writeGeoHashColumn(dataAddr, valueCount, col.getGeoHashPrecision()); + break; + case TYPE_STRING: + case TYPE_VARCHAR: + writeStringColumn(col, valueCount); + break; + case TYPE_SYMBOL: + if (useGlobalSymbols) { + writeSymbolColumnWithGlobalIds(col, valueCount); + } else { + writeSymbolColumn(col, valueCount); + } + break; + case TYPE_UUID: + // Stored as lo+hi contiguously, matching wire order + buffer.putBlockOfBytes(dataAddr, (long) valueCount * 16); + break; + case TYPE_LONG256: + // Stored as 4 contiguous longs per value + buffer.putBlockOfBytes(dataAddr, (long) valueCount * 32); + break; + case TYPE_DOUBLE_ARRAY: + writeDoubleArrayColumn(col, valueCount); + break; + case TYPE_LONG_ARRAY: + writeLongArrayColumn(col, valueCount); + break; + case TYPE_DECIMAL64: + writeDecimal64Column(col.getDecimalScale(), dataAddr, valueCount); + break; + case TYPE_DECIMAL128: + writeDecimal128Column(col.getDecimalScale(), dataAddr, valueCount); + break; + case TYPE_DECIMAL256: + writeDecimal256Column(col.getDecimalScale(), dataAddr, valueCount); + break; + default: + throw new LineSenderException("Unknown column type: " + col.getType()); + } + } + + private void encodeTable(QwpTableBuffer tableBuffer, boolean useSchemaRef, boolean useGlobalSymbols) { + QwpColumnDef[] columnDefs = tableBuffer.getColumnDefs(); + int rowCount = tableBuffer.getRowCount(); + + if (useSchemaRef) { + writeTableHeaderWithSchemaRef( + tableBuffer.getTableName(), + rowCount, + tableBuffer.getSchemaHash(), + columnDefs.length + ); + } else { + writeTableHeaderWithSchema(tableBuffer.getTableName(), rowCount, columnDefs); + } + + boolean useGorilla = isGorillaEnabled(); + for (int i = 0; i < tableBuffer.getColumnCount(); i++) { + QwpTableBuffer.ColumnBuffer col = tableBuffer.getColumn(i); + QwpColumnDef colDef = columnDefs[i]; + encodeColumn(col, colDef, rowCount, useGorilla, useGlobalSymbols); + } + } + + /** + * Writes boolean column data (bit-packed on wire). + * Reads individual bytes from off-heap and packs into bits. + */ + private void writeBooleanColumn(long addr, int count) { + int packedSize = (count + 7) / 8; + for (int i = 0; i < packedSize; i++) { + byte b = 0; + for (int bit = 0; bit < 8; bit++) { + int idx = i * 8 + bit; + if (idx < count && Unsafe.getUnsafe().getByte(addr + idx) != 0) { + b |= (1 << bit); + } + } + buffer.putByte(b); + } + } + + /** + * Writes Decimal128 values in big-endian wire format. + * Reads hi/lo pairs from off-heap (stored as hi, lo per value). + */ + private void writeDecimal128Column(byte scale, long addr, int count) { + buffer.putByte(scale); + for (int i = 0; i < count; i++) { + long offset = (long) i * 16; + long hi = Unsafe.getUnsafe().getLong(addr + offset); + long lo = Unsafe.getUnsafe().getLong(addr + offset + 8); + buffer.putLongBE(hi); + buffer.putLongBE(lo); + } + } + + /** + * Writes Decimal256 values in big-endian wire format. + * Reads hh/hl/lh/ll quads from off-heap (stored contiguously per value). + */ + private void writeDecimal256Column(byte scale, long addr, int count) { + buffer.putByte(scale); + for (int i = 0; i < count; i++) { + long offset = (long) i * 32; + buffer.putLongBE(Unsafe.getUnsafe().getLong(addr + offset)); + buffer.putLongBE(Unsafe.getUnsafe().getLong(addr + offset + 8)); + buffer.putLongBE(Unsafe.getUnsafe().getLong(addr + offset + 16)); + buffer.putLongBE(Unsafe.getUnsafe().getLong(addr + offset + 24)); + } + } + + /** + * Writes Decimal64 values in big-endian wire format. + * Reads longs from off-heap. + */ + private void writeDecimal64Column(byte scale, long addr, int count) { + buffer.putByte(scale); + for (int i = 0; i < count; i++) { + buffer.putLongBE(Unsafe.getUnsafe().getLong(addr + (long) i * 8)); + } + } + + private void writeDoubleArrayColumn(QwpTableBuffer.ColumnBuffer col, int count) { + byte[] dims = col.getArrayDims(); + int[] shapes = col.getArrayShapes(); + double[] data = col.getDoubleArrayData(); + + int shapeIdx = 0; + int dataIdx = 0; + for (int row = 0; row < count; row++) { + int nDims = dims[row]; + buffer.putByte((byte) nDims); + + int elemCount = 1; + for (int d = 0; d < nDims; d++) { + int dimLen = shapes[shapeIdx++]; + buffer.putInt(dimLen); + elemCount = Math.multiplyExact(elemCount, dimLen); + } + + for (int e = 0; e < elemCount; e++) { + buffer.putDouble(data[dataIdx++]); + } + } + } + + /** + * Writes a GeoHash column in variable-width wire format. + *

    + * Wire format: [precision varint] [packed values: ceil(precision/8) bytes each] + * Values are stored as 8-byte longs in the off-heap buffer but only the + * lower ceil(precision/8) bytes are written to the wire. + */ + private void writeGeoHashColumn(long addr, int count, int precision) { + if (precision < 1) { + // All values are null: use minimum valid precision. + // The decoder will skip all values via the null bitmap, + // so the precision only needs to be structurally valid. + precision = 1; + } + buffer.putVarint(precision); + int valueSize = (precision + 7) / 8; + for (int i = 0; i < count; i++) { + long value = Unsafe.getUnsafe().getLong(addr + (long) i * 8); + for (int b = 0; b < valueSize; b++) { + buffer.putByte((byte) (value >>> (b * 8))); + } + } + } + + private void writeLongArrayColumn(QwpTableBuffer.ColumnBuffer col, int count) { + byte[] dims = col.getArrayDims(); + int[] shapes = col.getArrayShapes(); + long[] data = col.getLongArrayData(); + + int shapeIdx = 0; + int dataIdx = 0; + for (int row = 0; row < count; row++) { + int nDims = dims[row]; + buffer.putByte((byte) nDims); + + int elemCount = 1; + for (int d = 0; d < nDims; d++) { + int dimLen = shapes[shapeIdx++]; + buffer.putInt(dimLen); + elemCount = Math.multiplyExact(elemCount, dimLen); + } + + for (int e = 0; e < elemCount; e++) { + buffer.putLong(data[dataIdx++]); + } + } + } + + /** + * Writes a null bitmap from off-heap memory. + * On little-endian platforms, the byte layout of the long-packed bitmap + * in memory matches the wire format, enabling bulk copy. + */ + private void writeNullBitmap(QwpTableBuffer.ColumnBuffer col, int rowCount) { + long nullAddr = col.getNullBitmapAddress(); + if (nullAddr != 0) { + int bitmapSize = (rowCount + 7) / 8; + buffer.putBlockOfBytes(nullAddr, bitmapSize); + } else { + // Non-nullable column shouldn't reach here, but write zeros as fallback + int bitmapSize = (rowCount + 7) / 8; + for (int i = 0; i < bitmapSize; i++) { + buffer.putByte((byte) 0); + } + } + } + + private void writeStringColumn(QwpTableBuffer.ColumnBuffer col, int valueCount) { + // Offset array: (valueCount + 1) int32 values, pre-built in wire format + buffer.putBlockOfBytes(col.getStringOffsetsAddress(), (long) (valueCount + 1) * 4); + // UTF-8 data: raw bytes, contiguous + buffer.putBlockOfBytes(col.getStringDataAddress(), col.getStringDataSize()); + } + + /** + * Writes a symbol column with dictionary. + * Reads local symbol indices from off-heap data buffer. + */ + private void writeSymbolColumn(QwpTableBuffer.ColumnBuffer col, int count) { + long dataAddr = col.getDataAddress(); + String[] dictionary = col.getSymbolDictionary(); + + buffer.putVarint(dictionary.length); + for (String symbol : dictionary) { + buffer.putString(symbol); + } + + for (int i = 0; i < count; i++) { + int idx = Unsafe.getUnsafe().getInt(dataAddr + (long) i * 4); + buffer.putVarint(idx); + } + } + + /** + * Writes a symbol column using global IDs (for delta dictionary mode). + * Reads from auxiliary data buffer if available, otherwise falls back to local indices. + */ + private void writeSymbolColumnWithGlobalIds(QwpTableBuffer.ColumnBuffer col, int count) { + long auxAddr = col.getAuxDataAddress(); + if (auxAddr == 0) { + // Fall back to local indices + long dataAddr = col.getDataAddress(); + for (int i = 0; i < count; i++) { + int idx = Unsafe.getUnsafe().getInt(dataAddr + (long) i * 4); + buffer.putVarint(idx); + } + } else { + for (int i = 0; i < count; i++) { + int globalId = Unsafe.getUnsafe().getInt(auxAddr + (long) i * 4); + buffer.putVarint(globalId); + } + } + } + + private void writeTableHeaderWithSchema(String tableName, int rowCount, QwpColumnDef[] columns) { + buffer.putString(tableName); + buffer.putVarint(rowCount); + buffer.putVarint(columns.length); + buffer.putByte(SCHEMA_MODE_FULL); + for (QwpColumnDef col : columns) { + buffer.putString(col.getName()); + buffer.putByte(col.getWireTypeCode()); + } + } + + private void writeTableHeaderWithSchemaRef(String tableName, int rowCount, long schemaHash, int columnCount) { + buffer.putString(tableName); + buffer.putVarint(rowCount); + buffer.putVarint(columnCount); + buffer.putByte(SCHEMA_MODE_REFERENCE); + buffer.putLong(schemaHash); + } + + /** + * Writes a timestamp column with optional Gorilla compression. + * Reads longs directly from off-heap — zero heap allocation. + */ + private void writeTimestampColumn(long addr, int count, boolean useGorilla) { + if (useGorilla && count > 2) { + if (QwpGorillaEncoder.canUseGorilla(addr, count)) { + buffer.putByte(ENCODING_GORILLA); + int encodedSize = QwpGorillaEncoder.calculateEncodedSize(addr, count); + buffer.ensureCapacity(encodedSize); + int bytesWritten = gorillaEncoder.encodeTimestamps( + buffer.getBufferPtr() + buffer.getPosition(), + buffer.getCapacity() - buffer.getPosition(), + addr, + count + ); + buffer.skip(bytesWritten); + } else { + buffer.putByte(ENCODING_UNCOMPRESSED); + buffer.putBlockOfBytes(addr, (long) count * 8); + } + } else { + if (useGorilla) { + buffer.putByte(ENCODING_UNCOMPRESSED); + } + buffer.putBlockOfBytes(addr, (long) count * 8); + } + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java new file mode 100644 index 0000000..3b77d73 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -0,0 +1,1389 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.client; + +import io.questdb.client.Sender; +import io.questdb.client.cutlass.http.client.WebSocketClient; +import io.questdb.client.cutlass.http.client.WebSocketClientFactory; +import io.questdb.client.cutlass.http.client.WebSocketFrameHandler; +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.cutlass.line.array.DoubleArray; +import io.questdb.client.cutlass.line.array.LongArray; +import io.questdb.client.cutlass.qwp.protocol.QwpTableBuffer; +import io.questdb.client.std.CharSequenceObjHashMap; +import io.questdb.client.std.Chars; +import io.questdb.client.std.Decimal128; +import io.questdb.client.std.Decimal256; +import io.questdb.client.std.Decimal64; +import io.questdb.client.std.LongHashSet; +import io.questdb.client.std.ObjList; +import io.questdb.client.std.bytes.DirectByteSlice; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.concurrent.TimeUnit; + +import static io.questdb.client.cutlass.qwp.protocol.QwpConstants.*; + + +/** + * ILP v4 WebSocket client sender for streaming data to QuestDB. + *

    + * This sender uses a double-buffering scheme with asynchronous I/O for high throughput: + *

      + *
    • User thread writes rows to the active microbatch buffer
    • + *
    • When buffer is full (row count, byte size, or age), it's sealed and enqueued
    • + *
    • A dedicated I/O thread sends batches asynchronously
    • + *
    • Double-buffering ensures one buffer is always available for writing
    • + *
    + *

    + * Configuration options: + *

      + *
    • {@code autoFlushRows} - Maximum rows per batch (default: 500)
    • + *
    • {@code autoFlushBytes} - Maximum bytes per batch (default: 1MB)
    • + *
    • {@code autoFlushIntervalNanos} - Maximum age before auto-flush (default: 100ms)
    • + *
    + *

    + * Example usage: + *

    + * try (QwpWebSocketSender sender = QwpWebSocketSender.connect("localhost", 9000)) {
    + *     for (int i = 0; i < 100_000; i++) {
    + *         sender.table("metrics")
    + *               .symbol("host", "server-" + (i % 10))
    + *               .doubleColumn("cpu", Math.random() * 100)
    + *               .atNow();
    + *         // Rows are batched and sent asynchronously!
    + *     }
    + *     // flush() waits for all pending batches to be sent
    + *     sender.flush();
    + * }
    + * 
    + *

    + * Fast-path API for high-throughput generators + *

    + * For maximum throughput, bypass the fluent API to avoid per-row overhead + * (no column-name hashmap lookups, no {@code checkNotClosed()}/{@code checkTableSelected()} + * per column, direct access to column buffers). Use {@link #getTableBuffer(String)}, + * {@link #getOrAddGlobalSymbol(String)}, and {@link #incrementPendingRowCount()}: + *

    + * // Setup (once)
    + * QwpTableBuffer tableBuffer = sender.getTableBuffer("q");
    + * QwpTableBuffer.ColumnBuffer colSymbol = tableBuffer.getOrCreateColumn("s", TYPE_SYMBOL, true);
    + * QwpTableBuffer.ColumnBuffer colBid = tableBuffer.getOrCreateColumn("b", TYPE_DOUBLE, false);
    + *
    + * // Hot path (per row)
    + * colSymbol.addSymbolWithGlobalId(symbol, sender.getOrAddGlobalSymbol(symbol));
    + * colBid.addDouble(bid);
    + * tableBuffer.nextRow();
    + * sender.incrementPendingRowCount();
    + * 
    + */ +public class QwpWebSocketSender implements Sender { + + public static final int DEFAULT_AUTO_FLUSH_BYTES = 1024 * 1024; // 1MB + public static final long DEFAULT_AUTO_FLUSH_INTERVAL_NANOS = 100_000_000L; // 100ms + public static final int DEFAULT_AUTO_FLUSH_ROWS = 500; + public static final int DEFAULT_IN_FLIGHT_WINDOW_SIZE = InFlightWindow.DEFAULT_WINDOW_SIZE; // 8 + private static final int DEFAULT_BUFFER_SIZE = 8192; + private static final int DEFAULT_MICROBATCH_BUFFER_SIZE = 1024 * 1024; // 1MB + private static final Logger LOG = LoggerFactory.getLogger(QwpWebSocketSender.class); + private static final String WRITE_PATH = "/write/v4"; + private final AckFrameHandler ackHandler = new AckFrameHandler(this); + private final WebSocketResponse ackResponse = new WebSocketResponse(); + private final int autoFlushBytes; + private final long autoFlushIntervalNanos; + // Auto-flush configuration + private final int autoFlushRows; + private final Decimal256 currentDecimal256 = new Decimal256(); + // Encoder for ILP v4 messages + private final QwpWebSocketEncoder encoder; + // Global symbol dictionary for delta encoding + private final GlobalSymbolDictionary globalSymbolDictionary; + private final String host; + // Flow control configuration + private final int inFlightWindowSize; + private final int port; + // Track schema hashes that have been sent to the server (for schema reference mode) + // First time we send a schema: full schema. Subsequent times: 8-byte hash reference. + // Combined key = schemaHash XOR (tableNameHash << 32) to include table name in lookup. + private final LongHashSet sentSchemaHashes = new LongHashSet(); + private final CharSequenceObjHashMap tableBuffers; + private final boolean tlsEnabled; + private MicrobatchBuffer activeBuffer; + // Double-buffering for async I/O + private MicrobatchBuffer buffer0; + private MicrobatchBuffer buffer1; + // Cached column references to avoid repeated hashmap lookups + private QwpTableBuffer.ColumnBuffer cachedTimestampColumn; + private QwpTableBuffer.ColumnBuffer cachedTimestampNanosColumn; + // WebSocket client (zero-GC native implementation) + private WebSocketClient client; + private boolean closed; + private boolean connected; + // Track max global symbol ID used in current batch (for delta calculation) + private int currentBatchMaxSymbolId = -1; + private QwpTableBuffer currentTableBuffer; + private String currentTableName; + private long firstPendingRowTimeNanos; + // Configuration + private boolean gorillaEnabled = true; + // Flow control + private InFlightWindow inFlightWindow; + // Track highest symbol ID sent to server (for delta encoding) + // Once sent over TCP, server is guaranteed to receive it (or connection dies) + private volatile int maxSentSymbolId = -1; + // Batch sequence counter (must match server's messageSequence) + private long nextBatchSequence = 0; + // Async mode: pending row tracking + private int pendingRowCount; + private boolean sawBinaryAck; + private WebSocketSendQueue sendQueue; + + private QwpWebSocketSender( + String host, + int port, + boolean tlsEnabled, + int bufferSize, + int autoFlushRows, + int autoFlushBytes, + long autoFlushIntervalNanos, + int inFlightWindowSize + ) { + this.host = host; + this.port = port; + this.tlsEnabled = tlsEnabled; + this.encoder = new QwpWebSocketEncoder(bufferSize); + this.tableBuffers = new CharSequenceObjHashMap<>(); + this.currentTableBuffer = null; + this.currentTableName = null; + this.connected = false; + this.closed = false; + this.autoFlushRows = autoFlushRows; + this.autoFlushBytes = autoFlushBytes; + this.autoFlushIntervalNanos = autoFlushIntervalNanos; + this.inFlightWindowSize = inFlightWindowSize; + + // Initialize global symbol dictionary for delta encoding + this.globalSymbolDictionary = new GlobalSymbolDictionary(); + + // Initialize double-buffering if async mode (window > 1) + if (inFlightWindowSize > 1) { + int microbatchBufferSize = Math.max(DEFAULT_MICROBATCH_BUFFER_SIZE, autoFlushBytes * 2); + this.buffer0 = new MicrobatchBuffer(microbatchBufferSize, autoFlushRows, autoFlushBytes, autoFlushIntervalNanos); + this.buffer1 = new MicrobatchBuffer(microbatchBufferSize, autoFlushRows, autoFlushBytes, autoFlushIntervalNanos); + this.activeBuffer = buffer0; + } + } + + /** + * Creates a new sender and connects to the specified host and port. + * Uses synchronous mode for backward compatibility. + * + * @param host server host + * @param port server HTTP port (WebSocket upgrade happens on same port) + * @return connected sender + */ + public static QwpWebSocketSender connect(String host, int port) { + return connect(host, port, false); + } + + /** + * Creates a new sender with TLS and connects to the specified host and port. + * Uses synchronous mode with default auto-flush settings. + * + * @param host server host + * @param port server HTTP port + * @param tlsEnabled whether to use TLS + * @return connected sender + */ + public static QwpWebSocketSender connect(String host, int port, boolean tlsEnabled) { + return connect(host, port, tlsEnabled, + DEFAULT_AUTO_FLUSH_ROWS, DEFAULT_AUTO_FLUSH_BYTES, DEFAULT_AUTO_FLUSH_INTERVAL_NANOS); + } + + /** + * Creates a new sender with TLS and connects to the specified host and port. + * Uses synchronous mode with custom auto-flush settings. + * + * @param host server host + * @param port server HTTP port + * @param tlsEnabled whether to use TLS + * @param autoFlushRows rows per batch (0 = no limit) + * @param autoFlushBytes bytes per batch (0 = no limit) + * @param autoFlushIntervalNanos age before flush in nanos (0 = no limit) + * @return connected sender + */ + public static QwpWebSocketSender connect(String host, int port, boolean tlsEnabled, + int autoFlushRows, int autoFlushBytes, + long autoFlushIntervalNanos) { + QwpWebSocketSender sender = new QwpWebSocketSender( + host, port, tlsEnabled, DEFAULT_BUFFER_SIZE, + autoFlushRows, autoFlushBytes, autoFlushIntervalNanos, + 1 // window=1 for sync behavior + ); + sender.ensureConnected(); + return sender; + } + + /** + * Creates a new sender with async mode and custom configuration. + * + * @param host server host + * @param port server HTTP port + * @param tlsEnabled whether to use TLS + * @param autoFlushRows rows per batch (0 = no limit) + * @param autoFlushBytes bytes per batch (0 = no limit) + * @param autoFlushIntervalNanos age before flush in nanos (0 = no limit) + * @return connected sender + */ + public static QwpWebSocketSender connectAsync(String host, int port, boolean tlsEnabled, + int autoFlushRows, int autoFlushBytes, + long autoFlushIntervalNanos) { + return connectAsync(host, port, tlsEnabled, autoFlushRows, autoFlushBytes, autoFlushIntervalNanos, + DEFAULT_IN_FLIGHT_WINDOW_SIZE); + } + + /** + * Creates a new sender with async mode and full configuration including flow control. + * + * @param host server host + * @param port server HTTP port + * @param tlsEnabled whether to use TLS + * @param autoFlushRows rows per batch (0 = no limit) + * @param autoFlushBytes bytes per batch (0 = no limit) + * @param autoFlushIntervalNanos age before flush in nanos (0 = no limit) + * @param inFlightWindowSize max batches awaiting server ACK (default: 8) + * @return connected sender + */ + public static QwpWebSocketSender connectAsync( + String host, + int port, + boolean tlsEnabled, + int autoFlushRows, + int autoFlushBytes, + long autoFlushIntervalNanos, + int inFlightWindowSize + ) { + QwpWebSocketSender sender = new QwpWebSocketSender( + host, port, tlsEnabled, DEFAULT_BUFFER_SIZE, + autoFlushRows, autoFlushBytes, autoFlushIntervalNanos, + inFlightWindowSize + ); + sender.ensureConnected(); + return sender; + } + + /** + * Creates a new sender with async mode and default configuration. + * + * @param host server host + * @param port server HTTP port + * @param tlsEnabled whether to use TLS + * @return connected sender + */ + public static QwpWebSocketSender connectAsync(String host, int port, boolean tlsEnabled) { + return connectAsync(host, port, tlsEnabled, + DEFAULT_AUTO_FLUSH_ROWS, DEFAULT_AUTO_FLUSH_BYTES, DEFAULT_AUTO_FLUSH_INTERVAL_NANOS); + } + + /** + * Creates a sender without connecting. For testing only. + *

    + * This allows unit tests to test sender logic without requiring a real server. + * Uses default auto-flush settings. + * + * @param host server host (not connected) + * @param port server port (not connected) + * @param inFlightWindowSize window size: 1 for sync behavior, >1 for async + * @return unconnected sender + */ + public static QwpWebSocketSender createForTesting(String host, int port, int inFlightWindowSize) { + return new QwpWebSocketSender( + host, port, false, DEFAULT_BUFFER_SIZE, + DEFAULT_AUTO_FLUSH_ROWS, DEFAULT_AUTO_FLUSH_BYTES, DEFAULT_AUTO_FLUSH_INTERVAL_NANOS, + inFlightWindowSize + ); + // Note: does NOT call ensureConnected() + } + + /** + * Creates a sender with custom flow control settings without connecting. For testing only. + * + * @param host server host (not connected) + * @param port server port (not connected) + * @param autoFlushRows rows per batch (0 = no limit) + * @param autoFlushBytes bytes per batch (0 = no limit) + * @param autoFlushIntervalNanos age before flush in nanos (0 = no limit) + * @param inFlightWindowSize window size: 1 for sync behavior, >1 for async + * @return unconnected sender + */ + public static QwpWebSocketSender createForTesting( + String host, int port, + int autoFlushRows, int autoFlushBytes, long autoFlushIntervalNanos, + int inFlightWindowSize) { + return new QwpWebSocketSender( + host, port, false, DEFAULT_BUFFER_SIZE, + autoFlushRows, autoFlushBytes, autoFlushIntervalNanos, + inFlightWindowSize + ); + // Note: does NOT call ensureConnected() + } + + @Override + public void at(long timestamp, ChronoUnit unit) { + checkNotClosed(); + checkTableSelected(); + if (unit == ChronoUnit.NANOS) { + atNanos(timestamp); + } else { + long micros = toMicros(timestamp, unit); + atMicros(micros); + } + } + + @Override + public void at(Instant timestamp) { + checkNotClosed(); + checkTableSelected(); + long micros = timestamp.getEpochSecond() * 1_000_000L + timestamp.getNano() / 1000L; + atMicros(micros); + } + + @Override + public void atNow() { + checkNotClosed(); + checkTableSelected(); + // Server-assigned timestamp - just send the row without designated timestamp + sendRow(); + } + + @Override + public QwpWebSocketSender boolColumn(CharSequence columnName, boolean value) { + checkNotClosed(); + checkTableSelected(); + QwpTableBuffer.ColumnBuffer col = currentTableBuffer.getOrCreateColumn(columnName.toString(), TYPE_BOOLEAN, false); + col.addBoolean(value); + return this; + } + + @Override + public DirectByteSlice bufferView() { + throw new LineSenderException("bufferView() is not supported for WebSocket sender"); + } + + /** + * Adds a BYTE column value to the current row. + * + * @param columnName the column name + * @param value the byte value + * @return this sender for method chaining + */ + public QwpWebSocketSender byteColumn(CharSequence columnName, byte value) { + checkNotClosed(); + checkTableSelected(); + QwpTableBuffer.ColumnBuffer col = currentTableBuffer.getOrCreateColumn(columnName.toString(), TYPE_BYTE, false); + col.addByte(value); + return this; + } + + @Override + public void cancelRow() { + checkNotClosed(); + if (currentTableBuffer != null) { + currentTableBuffer.cancelCurrentRow(); + } + } + + /** + * Adds a CHAR column value to the current row. + *

    + * CHAR is stored as a 2-byte UTF-16 code unit in QuestDB. + * + * @param columnName the column name + * @param value the character value + * @return this sender for method chaining + */ + public QwpWebSocketSender charColumn(CharSequence columnName, char value) { + checkNotClosed(); + checkTableSelected(); + QwpTableBuffer.ColumnBuffer col = currentTableBuffer.getOrCreateColumn(columnName.toString(), TYPE_CHAR, false); + col.addShort((short) value); + return this; + } + + @Override + public void close() { + if (!closed) { + closed = true; + + // Flush any remaining data + try { + if (inFlightWindowSize > 1) { + // Async mode (window > 1): flush accumulated rows in table buffers first + flushPendingRows(); + + if (activeBuffer != null && activeBuffer.hasData()) { + sealAndSwapBuffer(); + } + // Wait for all batches to be sent and acknowledged before closing + if (sendQueue != null) { + sendQueue.flush(); + } + if (inFlightWindow != null) { + inFlightWindow.awaitEmpty(); + } + } else { + // Sync mode (window=1): flush pending rows synchronously + if (pendingRowCount > 0 && client != null && client.isConnected()) { + flushSync(); + } + } + } catch (Exception e) { + LOG.error("Error during close: {}", String.valueOf(e)); + } + + // Shut down the I/O thread before closing the socket or buffers + // it may be using. This must run even if the flush above failed. + if (sendQueue != null) { + try { + sendQueue.close(); + } catch (Exception e) { + LOG.error("Error closing send queue: {}", String.valueOf(e)); + } + } + + // Close buffers (async mode only, window > 1) + if (buffer0 != null) { + buffer0.close(); + } + if (buffer1 != null) { + buffer1.close(); + } + + if (client != null) { + client.close(); + client = null; + } + encoder.close(); + // Close all table buffers to free off-heap column memory + ObjList keys = tableBuffers.keys(); + for (int i = 0, n = keys.size(); i < n; i++) { + CharSequence key = keys.getQuick(i); + if (key != null) { + QwpTableBuffer tb = tableBuffers.get(key); + if (tb != null) { + tb.close(); + } + } + } + tableBuffers.clear(); + + LOG.info("QwpWebSocketSender closed"); + } + } + + @Override + public Sender decimalColumn(CharSequence name, Decimal64 value) { + if (value == null || value.isNull()) return this; + checkNotClosed(); + checkTableSelected(); + QwpTableBuffer.ColumnBuffer col = currentTableBuffer.getOrCreateColumn(name.toString(), TYPE_DECIMAL64, true); + col.addDecimal64(value); + return this; + } + + @Override + public Sender decimalColumn(CharSequence name, Decimal128 value) { + if (value == null || value.isNull()) return this; + checkNotClosed(); + checkTableSelected(); + QwpTableBuffer.ColumnBuffer col = currentTableBuffer.getOrCreateColumn(name.toString(), TYPE_DECIMAL128, true); + col.addDecimal128(value); + return this; + } + + @Override + public Sender decimalColumn(CharSequence name, Decimal256 value) { + if (value == null || value.isNull()) return this; + checkNotClosed(); + checkTableSelected(); + QwpTableBuffer.ColumnBuffer col = currentTableBuffer.getOrCreateColumn(name.toString(), TYPE_DECIMAL256, true); + col.addDecimal256(value); + return this; + } + + @Override + public Sender decimalColumn(CharSequence name, CharSequence value) { + if (value == null || value.isEmpty()) return this; + checkNotClosed(); + checkTableSelected(); + try { + currentDecimal256.ofString(value); + QwpTableBuffer.ColumnBuffer col = currentTableBuffer.getOrCreateColumn(name.toString(), TYPE_DECIMAL256, true); + col.addDecimal256(currentDecimal256); + } catch (Exception e) { + throw new LineSenderException("Failed to parse decimal value: " + value, e); + } + return this; + } + + @Override + public Sender doubleArray(@NotNull CharSequence name, double[] values) { + if (values == null) return this; + checkNotClosed(); + checkTableSelected(); + QwpTableBuffer.ColumnBuffer col = currentTableBuffer.getOrCreateColumn(name.toString(), TYPE_DOUBLE_ARRAY, true); + col.addDoubleArray(values); + return this; + } + + @Override + public Sender doubleArray(@NotNull CharSequence name, double[][] values) { + if (values == null) return this; + checkNotClosed(); + checkTableSelected(); + QwpTableBuffer.ColumnBuffer col = currentTableBuffer.getOrCreateColumn(name.toString(), TYPE_DOUBLE_ARRAY, true); + col.addDoubleArray(values); + return this; + } + + @Override + public Sender doubleArray(@NotNull CharSequence name, double[][][] values) { + if (values == null) return this; + checkNotClosed(); + checkTableSelected(); + QwpTableBuffer.ColumnBuffer col = currentTableBuffer.getOrCreateColumn(name.toString(), TYPE_DOUBLE_ARRAY, true); + col.addDoubleArray(values); + return this; + } + + @Override + public Sender doubleArray(CharSequence name, DoubleArray array) { + if (array == null) return this; + checkNotClosed(); + checkTableSelected(); + QwpTableBuffer.ColumnBuffer col = currentTableBuffer.getOrCreateColumn(name.toString(), TYPE_DOUBLE_ARRAY, true); + col.addDoubleArray(array); + return this; + } + + @Override + public QwpWebSocketSender doubleColumn(CharSequence columnName, double value) { + checkNotClosed(); + checkTableSelected(); + QwpTableBuffer.ColumnBuffer col = currentTableBuffer.getOrCreateColumn(columnName.toString(), TYPE_DOUBLE, false); + col.addDouble(value); + return this; + } + + /** + * Adds a FLOAT column value to the current row. + * + * @param columnName the column name + * @param value the float value + * @return this sender for method chaining + */ + public QwpWebSocketSender floatColumn(CharSequence columnName, float value) { + checkNotClosed(); + checkTableSelected(); + QwpTableBuffer.ColumnBuffer col = currentTableBuffer.getOrCreateColumn(columnName.toString(), TYPE_FLOAT, false); + col.addFloat(value); + return this; + } + + @Override + public void flush() { + checkNotClosed(); + ensureConnected(); + + if (inFlightWindowSize > 1) { + // Async mode (window > 1): flush pending rows and wait for ACKs + flushPendingRows(); + + // Flush any remaining data in the active microbatch buffer + if (activeBuffer.hasData()) { + sealAndSwapBuffer(); + } + + // Wait for all pending batches to be sent to the server + sendQueue.flush(); + + // Wait for all in-flight batches to be acknowledged by the server + inFlightWindow.awaitEmpty(); + + LOG.debug("Flush complete [totalBatches={}, totalBytes={}, totalAcked={}]", sendQueue.getTotalBatchesSent(), sendQueue.getTotalBytesSent(), inFlightWindow.getTotalAcked()); + } else { + // Sync mode (window=1): flush pending rows and wait for ACKs synchronously + flushSync(); + } + } + + /** + * Returns the auto-flush byte threshold. + */ + public int getAutoFlushBytes() { + return autoFlushBytes; + } + + /** + * Returns the auto-flush interval in nanoseconds. + */ + public long getAutoFlushIntervalNanos() { + return autoFlushIntervalNanos; + } + + /** + * Returns the auto-flush row threshold. + */ + public int getAutoFlushRows() { + return autoFlushRows; + } + + /** + * Returns the max symbol ID sent to the server. + * Once sent over TCP, server is guaranteed to receive it (or connection dies). + */ + public int getMaxSentSymbolId() { + return maxSentSymbolId; + } + + /** + * Returns the number of pending rows not yet flushed. + * For testing. + */ + public int getPendingRowCount() { + return pendingRowCount; + } + + /** + * Gets or creates a table buffer for direct access. + * For high-throughput generators that want to bypass fluent API overhead. + */ + public QwpTableBuffer getTableBuffer(String tableName) { + QwpTableBuffer buffer = tableBuffers.get(tableName); + if (buffer == null) { + buffer = new QwpTableBuffer(tableName); + tableBuffers.put(tableName, buffer); + } + currentTableBuffer = buffer; + currentTableName = tableName; + return buffer; + } + + + /** + * Adds an INT column value to the current row. + * + * @param columnName the column name + * @param value the int value + * @return this sender for method chaining + */ + public QwpWebSocketSender intColumn(CharSequence columnName, int value) { + checkNotClosed(); + checkTableSelected(); + QwpTableBuffer.ColumnBuffer col = currentTableBuffer.getOrCreateColumn(columnName.toString(), TYPE_INT, false); + col.addInt(value); + return this; + } + + /** + * Returns whether Gorilla encoding is enabled. + */ + public boolean isGorillaEnabled() { + return gorillaEnabled; + } + + /** + * Adds a LONG256 column value to the current row. + * + * @param columnName the column name + * @param l0 the least significant 64 bits + * @param l1 the second 64 bits + * @param l2 the third 64 bits + * @param l3 the most significant 64 bits + * @return this sender for method chaining + */ + public QwpWebSocketSender long256Column(CharSequence columnName, long l0, long l1, long l2, long l3) { + checkNotClosed(); + checkTableSelected(); + QwpTableBuffer.ColumnBuffer col = currentTableBuffer.getOrCreateColumn(columnName.toString(), TYPE_LONG256, true); + col.addLong256(l0, l1, l2, l3); + return this; + } + + @Override + public Sender longArray(@NotNull CharSequence name, long[] values) { + if (values == null) return this; + checkNotClosed(); + checkTableSelected(); + QwpTableBuffer.ColumnBuffer col = currentTableBuffer.getOrCreateColumn(name.toString(), TYPE_LONG_ARRAY, true); + col.addLongArray(values); + return this; + } + + @Override + public Sender longArray(@NotNull CharSequence name, long[][] values) { + if (values == null) return this; + checkNotClosed(); + checkTableSelected(); + QwpTableBuffer.ColumnBuffer col = currentTableBuffer.getOrCreateColumn(name.toString(), TYPE_LONG_ARRAY, true); + col.addLongArray(values); + return this; + } + + @Override + public Sender longArray(@NotNull CharSequence name, long[][][] values) { + if (values == null) return this; + checkNotClosed(); + checkTableSelected(); + QwpTableBuffer.ColumnBuffer col = currentTableBuffer.getOrCreateColumn(name.toString(), TYPE_LONG_ARRAY, true); + col.addLongArray(values); + return this; + } + + @Override + public Sender longArray(@NotNull CharSequence name, LongArray array) { + if (array == null) return this; + checkNotClosed(); + checkTableSelected(); + QwpTableBuffer.ColumnBuffer col = currentTableBuffer.getOrCreateColumn(name.toString(), TYPE_LONG_ARRAY, true); + col.addLongArray(array); + return this; + } + + @Override + public QwpWebSocketSender longColumn(CharSequence columnName, long value) { + checkNotClosed(); + checkTableSelected(); + QwpTableBuffer.ColumnBuffer col = currentTableBuffer.getOrCreateColumn(columnName.toString(), TYPE_LONG, false); + col.addLong(value); + return this; + } + + @Override + public void reset() { + checkNotClosed(); + // Reset ALL table buffers, not just the current one + ObjList keys = tableBuffers.keys(); + for (int i = 0, n = keys.size(); i < n; i++) { + QwpTableBuffer buf = tableBuffers.get(keys.getQuick(i)); + if (buf != null) { + buf.reset(); + } + } + pendingRowCount = 0; + firstPendingRowTimeNanos = 0; + currentTableBuffer = null; + currentTableName = null; + cachedTimestampColumn = null; + cachedTimestampNanosColumn = null; + } + + /** + * Sets whether to use Gorilla timestamp encoding. + */ + public void setGorillaEnabled(boolean enabled) { + this.gorillaEnabled = enabled; + this.encoder.setGorillaEnabled(enabled); + } + + /** + * Adds a SHORT column value to the current row. + * + * @param columnName the column name + * @param value the short value + * @return this sender for method chaining + */ + public QwpWebSocketSender shortColumn(CharSequence columnName, short value) { + checkNotClosed(); + checkTableSelected(); + QwpTableBuffer.ColumnBuffer col = currentTableBuffer.getOrCreateColumn(columnName.toString(), TYPE_SHORT, false); + col.addShort(value); + return this; + } + + @Override + public QwpWebSocketSender stringColumn(CharSequence columnName, CharSequence value) { + checkNotClosed(); + checkTableSelected(); + QwpTableBuffer.ColumnBuffer col = currentTableBuffer.getOrCreateColumn(columnName.toString(), TYPE_STRING, true); + col.addString(value != null ? value.toString() : null); + return this; + } + + @Override + public QwpWebSocketSender symbol(CharSequence columnName, CharSequence value) { + checkNotClosed(); + checkTableSelected(); + QwpTableBuffer.ColumnBuffer col = currentTableBuffer.getOrCreateColumn(columnName.toString(), TYPE_SYMBOL, true); + + if (value != null) { + // Register symbol in global dictionary and track max ID for delta calculation + String symbolValue = value.toString(); + int globalId = globalSymbolDictionary.getOrAddSymbol(symbolValue); + if (globalId > currentBatchMaxSymbolId) { + currentBatchMaxSymbolId = globalId; + } + // Store global ID in the column buffer + col.addSymbolWithGlobalId(symbolValue, globalId); + } else { + col.addSymbol(null); + } + return this; + } + + @Override + public QwpWebSocketSender table(CharSequence tableName) { + checkNotClosed(); + // Fast path: if table name matches current, skip hashmap lookup + if (currentTableName != null && currentTableBuffer != null && Chars.equals(tableName, currentTableName)) { + return this; + } + // Table changed - invalidate cached column references + cachedTimestampColumn = null; + cachedTimestampNanosColumn = null; + currentTableName = tableName.toString(); + currentTableBuffer = tableBuffers.get(currentTableName); + if (currentTableBuffer == null) { + currentTableBuffer = new QwpTableBuffer(currentTableName); + tableBuffers.put(currentTableName, currentTableBuffer); + } + // Both modes accumulate rows until flush + return this; + } + + @Override + public QwpWebSocketSender timestampColumn(CharSequence columnName, long value, ChronoUnit unit) { + checkNotClosed(); + checkTableSelected(); + if (unit == ChronoUnit.NANOS) { + QwpTableBuffer.ColumnBuffer col = currentTableBuffer.getOrCreateColumn(columnName.toString(), TYPE_TIMESTAMP_NANOS, true); + col.addLong(value); + } else { + long micros = toMicros(value, unit); + QwpTableBuffer.ColumnBuffer col = currentTableBuffer.getOrCreateColumn(columnName.toString(), TYPE_TIMESTAMP, true); + col.addLong(micros); + } + return this; + } + + @Override + public QwpWebSocketSender timestampColumn(CharSequence columnName, Instant value) { + checkNotClosed(); + checkTableSelected(); + long micros = value.getEpochSecond() * 1_000_000L + value.getNano() / 1000L; + QwpTableBuffer.ColumnBuffer col = currentTableBuffer.getOrCreateColumn(columnName.toString(), TYPE_TIMESTAMP, true); + col.addLong(micros); + return this; + } + + /** + * Adds a UUID column value to the current row. + * + * @param columnName the column name + * @param lo the low 64 bits of the UUID + * @param hi the high 64 bits of the UUID + * @return this sender for method chaining + */ + public QwpWebSocketSender uuidColumn(CharSequence columnName, long lo, long hi) { + checkNotClosed(); + checkTableSelected(); + QwpTableBuffer.ColumnBuffer col = currentTableBuffer.getOrCreateColumn(columnName.toString(), TYPE_UUID, true); + col.addUuid(hi, lo); + return this; + } + + private void atMicros(long timestampMicros) { + // Add designated timestamp column (empty name for designated timestamp) + // Use cached reference to avoid hashmap lookup per row + if (cachedTimestampColumn == null) { + cachedTimestampColumn = currentTableBuffer.getOrCreateColumn("", TYPE_TIMESTAMP, true); + } + cachedTimestampColumn.addLong(timestampMicros); + sendRow(); + } + + private void atNanos(long timestampNanos) { + // Add designated timestamp column (empty name for designated timestamp) + // Use cached reference to avoid hashmap lookup per row + if (cachedTimestampNanosColumn == null) { + cachedTimestampNanosColumn = currentTableBuffer.getOrCreateColumn("", TYPE_TIMESTAMP_NANOS, true); + } + cachedTimestampNanosColumn.addLong(timestampNanos); + sendRow(); + } + + private void checkNotClosed() { + if (closed) { + throw new LineSenderException("Sender is closed"); + } + } + + private void checkTableSelected() { + if (currentTableBuffer == null) { + throw new LineSenderException("table() must be called before adding columns"); + } + } + + /** + * Ensures the active buffer is ready for writing (in FILLING state). + * If the buffer is in RECYCLED state, resets it. If it's in use, waits for it. + */ + private void ensureActiveBufferReady() { + if (activeBuffer.isFilling()) { + return; // Already ready + } + + if (activeBuffer.isRecycled()) { + // Buffer was recycled but not reset - reset it now + activeBuffer.reset(); + return; + } + + // Buffer is in use (SEALED or SENDING) - wait for it + // Use a while loop to handle spurious wakeups and race conditions with the latch + while (activeBuffer.isInUse()) { + LOG.debug("Waiting for active buffer [id={}, state={}]", activeBuffer.getBatchId(), MicrobatchBuffer.stateName(activeBuffer.getState())); + boolean recycled = activeBuffer.awaitRecycled(30, TimeUnit.SECONDS); + if (!recycled) { + throw new LineSenderException("Timeout waiting for active buffer to be recycled"); + } + } + + // Buffer should now be RECYCLED - reset it + if (activeBuffer.isRecycled()) { + activeBuffer.reset(); + } + } + + private void ensureConnected() { + if (closed) { + throw new LineSenderException("Sender is closed"); + } + if (!connected) { + // Create WebSocket client using factory (zero-GC native implementation) + if (tlsEnabled) { + client = WebSocketClientFactory.newInsecureTlsInstance(); + } else { + client = WebSocketClientFactory.newPlainTextInstance(); + } + + // Connect and upgrade to WebSocket + try { + client.connect(host, port); + client.upgrade(WRITE_PATH); + } catch (Exception e) { + client.close(); + client = null; + throw new LineSenderException("Failed to connect to " + host + ":" + port, e); + } + + // a window for tracking batches awaiting ACK (both modes) + inFlightWindow = new InFlightWindow(inFlightWindowSize, InFlightWindow.DEFAULT_TIMEOUT_MS); + + // Initialize send queue for async mode (window > 1) + // The send queue handles both sending AND receiving (single I/O thread) + if (inFlightWindowSize > 1) { + sendQueue = new WebSocketSendQueue(client, inFlightWindow, + WebSocketSendQueue.DEFAULT_ENQUEUE_TIMEOUT_MS, + WebSocketSendQueue.DEFAULT_SHUTDOWN_TIMEOUT_MS); + } + // Sync mode (window=1): no send queue - we send and read ACKs synchronously + + // Clear sent schema hashes - server starts fresh on each connection + sentSchemaHashes.clear(); + + connected = true; + LOG.info("Connected to WebSocket [host={}, port={}, windowSize={}]", host, port, inFlightWindowSize); + } + } + + private void failExpectedIfNeeded(long expectedSequence, LineSenderException error) { + if (inFlightWindow != null && inFlightWindow.getLastError() == null) { + inFlightWindow.fail(expectedSequence, error); + } + } + + /** + * Flushes pending rows by encoding and sending them. + * Each table's rows are encoded into a separate ILP v4 message and sent as one WebSocket frame. + */ + private void flushPendingRows() { + if (pendingRowCount <= 0) { + return; + } + + // Invalidate cached column references — table buffers will be reset below + cachedTimestampColumn = null; + cachedTimestampNanosColumn = null; + + LOG.debug("Flushing pending rows [count={}, tables={}]", pendingRowCount, tableBuffers.size()); + + // Ensure activeBuffer is ready for writing + // It might be in RECYCLED state if previous batch was sent but we didn't swap yet + ensureActiveBufferReady(); + + // Encode all table buffers that have data + // Iterate over the keys list directly + ObjList keys = tableBuffers.keys(); + for (int i = 0, n = keys.size(); i < n; i++) { + CharSequence tableName = keys.getQuick(i); + if (tableName == null) { + continue; // Skip null entries (shouldn't happen but be safe) + } + QwpTableBuffer tableBuffer = tableBuffers.get(tableName); + if (tableBuffer == null) { + continue; + } + int rowCount = tableBuffer.getRowCount(); + if (rowCount > 0) { + // Check if this schema has been sent before (use schema reference mode if so) + // Combined key includes table name since server caches by (tableName, schemaHash) + long schemaHash = tableBuffer.getSchemaHash(); + long schemaKey = schemaHash ^ ((long) tableBuffer.getTableName().hashCode() << 32); + boolean useSchemaRef = sentSchemaHashes.contains(schemaKey); + + LOG.debug("Encoding table [name={}, rows={}, maxSentSymbolId={}, batchMaxId={}, useSchemaRef={}]", tableName, rowCount, maxSentSymbolId, currentBatchMaxSymbolId, useSchemaRef); + + // Encode this table's rows with delta symbol dictionary + int messageSize = encoder.encodeWithDeltaDict( + tableBuffer, + globalSymbolDictionary, + maxSentSymbolId, + currentBatchMaxSymbolId, + useSchemaRef + ); + + QwpBufferWriter buffer = encoder.getBuffer(); + + // Copy to microbatch buffer and seal immediately + // Each ILP v4 message must be in its own WebSocket frame + activeBuffer.ensureCapacity(messageSize); + activeBuffer.write(buffer.getBufferPtr(), messageSize); + activeBuffer.incrementRowCount(); + activeBuffer.setMaxSymbolId(currentBatchMaxSymbolId); + + // Seal and enqueue for sending + sealAndSwapBuffer(); + + // Update sent state only after successful enqueue. + // If sealAndSwapBuffer() threw, these remain unchanged so the + // next batch's delta dictionary will correctly re-include the + // symbols and schema that the server never received. + maxSentSymbolId = currentBatchMaxSymbolId; + if (!useSchemaRef) { + sentSchemaHashes.add(schemaKey); + } + + // Reset table buffer and batch-level symbol tracking + tableBuffer.reset(); + currentBatchMaxSymbolId = -1; + } + } + + // Reset pending count + pendingRowCount = 0; + firstPendingRowTimeNanos = 0; + } + + /** + * Flushes pending rows synchronously, blocking until server ACKs. + * Used in sync mode for simpler, blocking operation. + */ + private void flushSync() { + if (pendingRowCount <= 0) { + return; + } + + // Invalidate cached column references — table buffers will be reset below + cachedTimestampColumn = null; + cachedTimestampNanosColumn = null; + + LOG.debug("Sync flush [pendingRows={}, tables={}]", pendingRowCount, tableBuffers.size()); + + // Encode all table buffers that have data into a single message + ObjList keys = tableBuffers.keys(); + for (int i = 0, n = keys.size(); i < n; i++) { + CharSequence tableName = keys.getQuick(i); + if (tableName == null) { + continue; + } + QwpTableBuffer tableBuffer = tableBuffers.get(tableName); + if (tableBuffer == null || tableBuffer.getRowCount() == 0) { + continue; + } + + // Check if this schema has been sent before (use schema reference mode if so) + // Combined key includes table name since server caches by (tableName, schemaHash) + long schemaHash = tableBuffer.getSchemaHash(); + long schemaKey = schemaHash ^ ((long) tableBuffer.getTableName().hashCode() << 32); + boolean useSchemaRef = sentSchemaHashes.contains(schemaKey); + + // Encode this table's rows with delta symbol dictionary + int messageSize = encoder.encodeWithDeltaDict( + tableBuffer, + globalSymbolDictionary, + maxSentSymbolId, + currentBatchMaxSymbolId, + useSchemaRef + ); + + if (messageSize > 0) { + QwpBufferWriter buffer = encoder.getBuffer(); + + // Track batch in InFlightWindow before sending + long batchSequence = nextBatchSequence++; + inFlightWindow.addInFlight(batchSequence); + + LOG.debug("Sending sync batch [seq={}, bytes={}, rows={}, maxSentSymbolId={}, useSchemaRef={}]", batchSequence, messageSize, tableBuffer.getRowCount(), currentBatchMaxSymbolId, useSchemaRef); + + // Send over WebSocket + client.sendBinary(buffer.getBufferPtr(), messageSize); + + // Wait for ACK synchronously + waitForAck(batchSequence); + + // Update sent state only after successful send + ACK. + // If sendBinary() or waitForAck() threw, these remain unchanged + // so the next batch's delta dictionary will correctly re-include + // the symbols and schema that the server never received. + maxSentSymbolId = currentBatchMaxSymbolId; + if (!useSchemaRef) { + sentSchemaHashes.add(schemaKey); + } + } + + // Reset table buffer after sending + tableBuffer.reset(); + + // Reset batch-level symbol tracking + currentBatchMaxSymbolId = -1; + } + + // Reset pending row tracking + pendingRowCount = 0; + firstPendingRowTimeNanos = 0; + + LOG.debug("Sync flush complete [totalAcked={}]", inFlightWindow.getTotalAcked()); + } + + private long getPendingBytes() { + long bytes = 0; + ObjList keys = tableBuffers.keys(); + for (int i = 0, n = keys.size(); i < n; i++) { + CharSequence key = keys.getQuick(i); + if (key != null) { + QwpTableBuffer tb = tableBuffers.get(key); + if (tb != null) { + bytes += tb.getBufferedBytes(); + } + } + } + return bytes; + } + + /** + * Seals the current buffer and swaps to the other buffer. + * Enqueues the sealed buffer for async sending. + */ + private void sealAndSwapBuffer() { + if (!activeBuffer.hasData()) { + return; // Nothing to send + } + + MicrobatchBuffer toSend = activeBuffer; + toSend.seal(); + + LOG.debug("Sealing buffer [id={}, rows={}, bytes={}]", toSend.getBatchId(), toSend.getRowCount(), toSend.getBufferPos()); + + // Swap to the other buffer + activeBuffer = (activeBuffer == buffer0) ? buffer1 : buffer0; + + // If the other buffer is still being sent, wait for it + // Use a while loop to handle spurious wakeups and race conditions with the latch + while (activeBuffer.isInUse()) { + LOG.debug("Waiting for buffer recycle [id={}, state={}]", activeBuffer.getBatchId(), MicrobatchBuffer.stateName(activeBuffer.getState())); + boolean recycled = activeBuffer.awaitRecycled(30, TimeUnit.SECONDS); + if (!recycled) { + throw new LineSenderException("Timeout waiting for buffer to be recycled"); + } + LOG.debug("Buffer recycled [id={}, state={}]", activeBuffer.getBatchId(), MicrobatchBuffer.stateName(activeBuffer.getState())); + } + + // Reset the new active buffer + int stateBeforeReset = activeBuffer.getState(); + LOG.debug("Resetting buffer [id={}, state={}]", activeBuffer.getBatchId(), MicrobatchBuffer.stateName(stateBeforeReset)); + activeBuffer.reset(); + + // Enqueue the sealed buffer for sending. + // If enqueue fails, roll back local state so the same batch can be retried. + try { + if (!sendQueue.enqueue(toSend)) { + throw new LineSenderException("Failed to enqueue buffer for sending"); + } + } catch (LineSenderException e) { + activeBuffer = toSend; + if (toSend.isSealed()) { + toSend.rollbackSealForRetry(); + } + throw e; + } + } + + /** + * Accumulates the current row. + * Both sync and async modes buffer rows until flush (explicit or auto-flush). + * The difference is that sync mode flush() blocks until server ACKs. + */ + private void sendRow() { + ensureConnected(); + currentTableBuffer.nextRow(); + + // Both modes: accumulate rows, don't encode yet + if (pendingRowCount == 0) { + firstPendingRowTimeNanos = System.nanoTime(); + } + pendingRowCount++; + + // Check if any flush threshold is exceeded + if (shouldAutoFlush()) { + if (inFlightWindowSize > 1) { + flushPendingRows(); + } else { + // Sync mode (window=1): flush directly with ACK wait + flushSync(); + } + } + } + + /** + * Checks if any auto-flush threshold is exceeded. + */ + private boolean shouldAutoFlush() { + if (pendingRowCount <= 0) { + return false; + } + if (autoFlushRows > 0 && pendingRowCount >= autoFlushRows) { + return true; + } + if (autoFlushBytes > 0 && getPendingBytes() >= autoFlushBytes) { + return true; + } + if (autoFlushIntervalNanos > 0) { + long ageNanos = System.nanoTime() - firstPendingRowTimeNanos; + return ageNanos >= autoFlushIntervalNanos; + } + return false; + } + + private long toMicros(long value, ChronoUnit unit) { + return switch (unit) { + case NANOS -> value / 1000L; + case MICROS -> value; + case MILLIS -> value * 1000L; + case SECONDS -> value * 1_000_000L; + case MINUTES -> value * 60_000_000L; + case HOURS -> value * 3_600_000_000L; + case DAYS -> value * 86_400_000_000L; + default -> throw new LineSenderException("Unsupported time unit: " + unit); + }; + } + + /** + * Waits synchronously for an ACK from the server for the specified batch. + */ + private void waitForAck(long expectedSequence) { + long deadline = System.currentTimeMillis() + InFlightWindow.DEFAULT_TIMEOUT_MS; + + while (System.currentTimeMillis() < deadline) { + try { + sawBinaryAck = false; + boolean received = client.receiveFrame(ackHandler, 1000); // 1 second timeout per read attempt + + if (received) { + // Non-binary frames (e.g. ping/pong/text) are not ACKs. + if (!sawBinaryAck) { + continue; + } + long sequence = ackResponse.getSequence(); + if (ackResponse.isSuccess()) { + // Cumulative ACK - acknowledge all batches up to this sequence + inFlightWindow.acknowledgeUpTo(sequence); + if (sequence >= expectedSequence) { + return; // Our batch was acknowledged (cumulative) + } + // Got ACK for lower sequence - continue waiting + } else { + String errorMessage = ackResponse.getErrorMessage(); + LineSenderException error = new LineSenderException( + "Server error for batch " + sequence + ": " + + ackResponse.getStatusName() + " - " + errorMessage); + inFlightWindow.fail(sequence, error); + if (sequence == expectedSequence) { + throw error; + } + } + } + } catch (LineSenderException e) { + failExpectedIfNeeded(expectedSequence, e); + throw e; + } catch (Exception e) { + LineSenderException wrapped = new LineSenderException("Error waiting for ACK: " + e.getMessage(), e); + failExpectedIfNeeded(expectedSequence, wrapped); + throw wrapped; + } + } + + LineSenderException timeout = new LineSenderException("Timeout waiting for ACK for batch " + expectedSequence); + failExpectedIfNeeded(expectedSequence, timeout); + throw timeout; + } + + private record AckFrameHandler( + QwpWebSocketSender sender + ) implements WebSocketFrameHandler { + + @Override + public void onBinaryMessage(long payloadPtr, int payloadLen) { + sender.sawBinaryAck = true; + if (!WebSocketResponse.isStructurallyValid(payloadPtr, payloadLen)) { + throw new LineSenderException( + "Invalid ACK response payload [length=" + payloadLen + ']' + ); + } + if (!sender.ackResponse.readFrom(payloadPtr, payloadLen)) { + throw new LineSenderException("Failed to parse ACK response"); + } + } + + @Override + public void onClose(int code, String reason) { + throw new LineSenderException("WebSocket closed while waiting for ACK: " + reason); + } + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/WebSocketResponse.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/WebSocketResponse.java new file mode 100644 index 0000000..0070a5e --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/WebSocketResponse.java @@ -0,0 +1,267 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.client; + +import io.questdb.client.std.Unsafe; + +import java.nio.charset.StandardCharsets; + +/** + * Binary response format for WebSocket ILP v4 protocol. + *

    + * Response format (little-endian): + *

    + * +--------+----------+------------------+
    + * | status | sequence | error (if any)   |
    + * | 1 byte | 8 bytes  | 2 bytes + UTF-8  |
    + * +--------+----------+------------------+
    + * 
    + *

    + * Status codes: + *

      + *
    • 0: Success (ACK)
    • + *
    • 1: Parse error
    • + *
    • 2: Schema error
    • + *
    • 3: Write error
    • + *
    • 4: Security error
    • + *
    • 255: Internal error
    • + *
    + *

    + * The sequence number allows correlation with the original request. + * Error message is only present when status != 0. + */ +public class WebSocketResponse { + + public static final int MAX_ERROR_MESSAGE_LENGTH = 1024; + public static final int MIN_ERROR_RESPONSE_SIZE = 11; // status + sequence + error length + // Minimum response size: status (1) + sequence (8) + public static final int MIN_RESPONSE_SIZE = 9; + public static final byte STATUS_INTERNAL_ERROR = (byte) 255; + // Status codes + public static final byte STATUS_OK = 0; + public static final byte STATUS_PARSE_ERROR = 1; + public static final byte STATUS_SCHEMA_ERROR = 2; + public static final byte STATUS_SECURITY_ERROR = 4; + public static final byte STATUS_WRITE_ERROR = 3; + private String errorMessage; + private long sequence; + private byte status; + + public WebSocketResponse() { + this.status = STATUS_OK; + this.sequence = 0; + this.errorMessage = null; + } + + /** + * Creates an error response. + */ + public static WebSocketResponse error(long sequence, byte status, String errorMessage) { + WebSocketResponse response = new WebSocketResponse(); + response.status = status; + response.sequence = sequence; + response.errorMessage = errorMessage; + return response; + } + + /** + * Validates binary response framing without allocating. + *

    + * Accepted formats: + *

      + *
    • OK: exactly 9 bytes (status + sequence)
    • + *
    • Error: exactly 11 + errorLength bytes
    • + *
    + * + * @param ptr response buffer pointer + * @param length response frame payload length + * @return true if payload structure is valid + */ + public static boolean isStructurallyValid(long ptr, int length) { + if (length < MIN_RESPONSE_SIZE) { + return false; + } + + byte status = Unsafe.getUnsafe().getByte(ptr); + if (status == STATUS_OK) { + return length == MIN_RESPONSE_SIZE; + } + + if (length < MIN_ERROR_RESPONSE_SIZE) { + return false; + } + + int msgLen = Unsafe.getUnsafe().getShort(ptr + MIN_RESPONSE_SIZE) & 0xFFFF; + return length == MIN_ERROR_RESPONSE_SIZE + msgLen; + } + + /** + * Creates a success response. + */ + public static WebSocketResponse success(long sequence) { + WebSocketResponse response = new WebSocketResponse(); + response.status = STATUS_OK; + response.sequence = sequence; + return response; + } + + /** + * Returns the error message, or null for success responses. + */ + public String getErrorMessage() { + return errorMessage; + } + + /** + * Returns the sequence number. + */ + public long getSequence() { + return sequence; + } + + /** + * Returns a human-readable status name. + */ + public String getStatusName() { + return switch (status) { + case STATUS_OK -> "OK"; + case STATUS_PARSE_ERROR -> "PARSE_ERROR"; + case STATUS_SCHEMA_ERROR -> "SCHEMA_ERROR"; + case STATUS_WRITE_ERROR -> "WRITE_ERROR"; + case STATUS_SECURITY_ERROR -> "SECURITY_ERROR"; + case STATUS_INTERNAL_ERROR -> "INTERNAL_ERROR"; + default -> "UNKNOWN(" + (status & 0xFF) + ")"; + }; + } + + /** + * Returns true if this is a success response. + */ + public boolean isSuccess() { + return status == STATUS_OK; + } + + /** + * Reads a response from native memory. + * + * @param ptr source address + * @param length available bytes + * @return true if successfully parsed, false if not enough data + */ + public boolean readFrom(long ptr, int length) { + if (length < MIN_RESPONSE_SIZE) { + return false; + } + + int offset = 0; + + // Status (1 byte) + status = Unsafe.getUnsafe().getByte(ptr + offset); + offset += 1; + + // Sequence (8 bytes, little-endian) + sequence = Unsafe.getUnsafe().getLong(ptr + offset); + offset += 8; + + // Error message (if status != OK and more data available) + if (status != STATUS_OK && length > offset + 2) { + int msgLen = Unsafe.getUnsafe().getShort(ptr + offset) & 0xFFFF; + offset += 2; + + if (length >= offset + msgLen && msgLen > 0) { + byte[] msgBytes = new byte[msgLen]; + for (int i = 0; i < msgLen; i++) { + msgBytes[i] = Unsafe.getUnsafe().getByte(ptr + offset + i); + } + errorMessage = new String(msgBytes, StandardCharsets.UTF_8); + } else { + errorMessage = null; + } + } else { + errorMessage = null; + } + + return true; + } + + /** + * Calculates the serialized size of this response. + */ + public int serializedSize() { + int size = MIN_RESPONSE_SIZE; + if (errorMessage != null && !errorMessage.isEmpty()) { + byte[] msgBytes = errorMessage.getBytes(StandardCharsets.UTF_8); + int msgLen = Math.min(msgBytes.length, MAX_ERROR_MESSAGE_LENGTH); + size += 2 + msgLen; // 2 bytes for length prefix + } + return size; + } + + @Override + public String toString() { + if (isSuccess()) { + return "WebSocketResponse{status=OK, seq=" + sequence + "}"; + } else { + return "WebSocketResponse{status=" + getStatusName() + ", seq=" + sequence + + ", error=" + errorMessage + "}"; + } + } + + /** + * Writes this response to native memory. + * + * @param ptr destination address + * @return number of bytes written + */ + public int writeTo(long ptr) { + int offset = 0; + + // Status (1 byte) + Unsafe.getUnsafe().putByte(ptr + offset, status); + offset += 1; + + // Sequence (8 bytes, little-endian) + Unsafe.getUnsafe().putLong(ptr + offset, sequence); + offset += 8; + + // Error message (if any) + if (status != STATUS_OK && errorMessage != null && !errorMessage.isEmpty()) { + byte[] msgBytes = errorMessage.getBytes(StandardCharsets.UTF_8); + int msgLen = Math.min(msgBytes.length, MAX_ERROR_MESSAGE_LENGTH); + + // Length prefix (2 bytes, little-endian) + Unsafe.getUnsafe().putShort(ptr + offset, (short) msgLen); + offset += 2; + + // Message bytes + for (int i = 0; i < msgLen; i++) { + Unsafe.getUnsafe().putByte(ptr + offset + i, msgBytes[i]); + } + offset += msgLen; + } + + return offset; + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/WebSocketSendQueue.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/WebSocketSendQueue.java new file mode 100644 index 0000000..b155bab --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/WebSocketSendQueue.java @@ -0,0 +1,637 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.client; + +import io.questdb.client.cutlass.http.client.WebSocketClient; +import io.questdb.client.cutlass.http.client.WebSocketFrameHandler; +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.std.QuietCloseable; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Asynchronous I/O handler for WebSocket microbatch transmission. + *

    + * This class manages a dedicated I/O thread that handles both: + *

      + *
    • Sending batches via a single-slot handoff (volatile reference)
    • + *
    • Receiving and processing server ACK responses
    • + *
    + * The single-slot design matches the double-buffering scheme: at most one + * sealed buffer is pending while the other is being filled. + * Using a single thread eliminates concurrency issues with the WebSocket channel. + *

    + * Thread safety: + *

      + *
    • The pending slot is thread-safe for concurrent access
    • + *
    • Only the I/O thread interacts with the WebSocket channel
    • + *
    • Buffer state transitions ensure safe hand-over
    • + *
    + *

    + * Backpressure: + *

      + *
    • When the slot is occupied, {@link #enqueue} blocks
    • + *
    • This propagates backpressure to the user thread
    • + *
    + */ +public class WebSocketSendQueue implements QuietCloseable { + + public static final long DEFAULT_ENQUEUE_TIMEOUT_MS = 30_000; + public static final long DEFAULT_SHUTDOWN_TIMEOUT_MS = 10_000; + private static final Logger LOG = LoggerFactory.getLogger(WebSocketSendQueue.class); + // The WebSocket client for I/O (single-threaded access only) + private final WebSocketClient client; + // Configuration + private final long enqueueTimeoutMs; + // Optional InFlightWindow for tracking sent batches awaiting ACK + @Nullable + private final InFlightWindow inFlightWindow; + + // The I/O thread for async send/receive + private final Thread ioThread; + // Counter for batches currently being processed by the I/O thread + // This tracks batches that have been dequeued but not yet fully sent + private final AtomicInteger processingCount = new AtomicInteger(0); + // Lock for all coordination between user thread and I/O thread. + // Used for: queue poll + processingCount increment atomicity, + // flush() waiting, I/O thread waiting when idle. + private final Object processingLock = new Object(); + // Response parsing + private final WebSocketResponse response = new WebSocketResponse(); + private final ResponseHandler responseHandler = new ResponseHandler(); + // Synchronization for flush/close + private final CountDownLatch shutdownLatch; + private final long shutdownTimeoutMs; + // Statistics - receiving + private final AtomicLong totalAcks = new AtomicLong(0); + // Statistics - sending + private final AtomicLong totalBatchesSent = new AtomicLong(0); + private final AtomicLong totalBytesSent = new AtomicLong(0); + private final AtomicLong totalErrors = new AtomicLong(0); + // Error handling + private volatile Throwable lastError; + // Batch sequence counter (must match server's messageSequence) + private long nextBatchSequence = 0; + // Single pending buffer slot (double-buffering means at most 1 item in queue) + // Zero allocation - just a volatile reference handoff + private volatile MicrobatchBuffer pendingBuffer; + // Running state + private volatile boolean running; + private volatile boolean shuttingDown; + + /** + * Creates a new send queue with default configuration. + * + * @param client the WebSocket client for I/O + */ + public WebSocketSendQueue(WebSocketClient client) { + this(client, null, DEFAULT_ENQUEUE_TIMEOUT_MS, DEFAULT_SHUTDOWN_TIMEOUT_MS); + } + + /** + * Creates a new send queue with InFlightWindow for tracking sent batches. + * + * @param client the WebSocket client for I/O + * @param inFlightWindow the window to track sent batches awaiting ACK (may be null) + */ + public WebSocketSendQueue(WebSocketClient client, @Nullable InFlightWindow inFlightWindow) { + this(client, inFlightWindow, DEFAULT_ENQUEUE_TIMEOUT_MS, DEFAULT_SHUTDOWN_TIMEOUT_MS); + } + + /** + * Creates a new send queue with custom configuration. + * + * @param client the WebSocket client for I/O + * @param inFlightWindow the window to track sent batches awaiting ACK (may be null) + * @param enqueueTimeoutMs timeout for enqueue operations (ms) + * @param shutdownTimeoutMs timeout for graceful shutdown (ms) + */ + public WebSocketSendQueue(WebSocketClient client, @Nullable InFlightWindow inFlightWindow, + long enqueueTimeoutMs, long shutdownTimeoutMs) { + if (client == null) { + throw new IllegalArgumentException("client cannot be null"); + } + + this.client = client; + this.inFlightWindow = inFlightWindow; + this.enqueueTimeoutMs = enqueueTimeoutMs; + this.shutdownTimeoutMs = shutdownTimeoutMs; + this.running = true; + this.shuttingDown = false; + this.shutdownLatch = new CountDownLatch(1); + + // Start the I/O thread (handles both sending and receiving) + this.ioThread = new Thread(this::ioLoop, "questdb-websocket-io"); + this.ioThread.setDaemon(true); + this.ioThread.start(); + + LOG.info("WebSocket I/O thread started"); + } + + /** + * Closes the send queue gracefully. + *

    + * This method: + * 1. Stops accepting new batches + * 2. Waits for pending batches to be sent + * 3. Stops the I/O thread + *

    + * Note: This does NOT close the WebSocket channel - that's the caller's responsibility. + */ + @Override + public void close() { + if (!running) { + return; + } + + LOG.info("Closing WebSocket send queue [pending={}]", getPendingSize()); + + // Signal shutdown + shuttingDown = true; + + // Wait for pending batches to be sent + long startTime = System.currentTimeMillis(); + synchronized (processingLock) { + while (!isPendingEmpty()) { + long elapsed = System.currentTimeMillis() - startTime; + if (elapsed >= shutdownTimeoutMs) { + LOG.error("Shutdown timeout, {} batches not sent", getPendingSize()); + break; + } + try { + processingLock.wait(shutdownTimeoutMs - elapsed); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + } + + // Stop the I/O thread + running = false; + + // Wake up I/O thread if it's blocked on processingLock.wait() + synchronized (processingLock) { + processingLock.notifyAll(); + } + ioThread.interrupt(); + + // Wait for I/O thread to finish + try { + shutdownLatch.await(shutdownTimeoutMs, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + LOG.info("WebSocket send queue closed [totalBatches={}, totalBytes={}]", totalBatchesSent.get(), totalBytesSent.get()); + } + + /** + * Enqueues a sealed buffer for sending. + *

    + * The buffer must be in SEALED state. After this method returns successfully, + * ownership of the buffer transfers to the send queue. + * + * @param buffer the sealed buffer to send + * @return true if enqueued successfully + * @throws LineSenderException if the buffer is not sealed or an error occurred + */ + public boolean enqueue(MicrobatchBuffer buffer) { + if (buffer == null) { + throw new IllegalArgumentException("buffer cannot be null"); + } + if (!buffer.isSealed()) { + throw new LineSenderException("Buffer must be sealed before enqueue, state=" + + MicrobatchBuffer.stateName(buffer.getState())); + } + if (!running || shuttingDown) { + throw new LineSenderException("Send queue is not running"); + } + + // Check for errors from I/O thread + checkError(); + + final long deadline = System.currentTimeMillis() + enqueueTimeoutMs; + synchronized (processingLock) { + while (true) { + if (!running || shuttingDown) { + throw new LineSenderException("Send queue is not running"); + } + checkError(); + + if (offerPending(buffer)) { + processingLock.notifyAll(); + break; + } + + long remaining = deadline - System.currentTimeMillis(); + if (remaining <= 0) { + throw new LineSenderException("Enqueue timeout after " + enqueueTimeoutMs + "ms"); + } + try { + processingLock.wait(Math.min(10, remaining)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new LineSenderException("Interrupted while enqueueing", e); + } + } + } + + LOG.debug("Enqueued batch [id={}, bytes={}, rows={}]", buffer.getBatchId(), buffer.getBufferPos(), buffer.getRowCount()); + return true; + } + + /** + * Waits for all pending batches to be sent. + *

    + * This method blocks until the queue is empty and all in-flight sends complete. + * It does not close the queue - new batches can still be enqueued after flush. + * + * @throws LineSenderException if an error occurs during flush + */ + public void flush() { + checkError(); + + long startTime = System.currentTimeMillis(); + + // Wait under lock - I/O thread will notify when processingCount decrements + synchronized (processingLock) { + while (running) { + // Atomically check: queue empty AND not processing + if (isPendingEmpty() && processingCount.get() == 0) { + break; // All done + } + + try { + processingLock.wait(10); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new LineSenderException("Interrupted while flushing", e); + } + + // Check timeout + if (System.currentTimeMillis() - startTime > enqueueTimeoutMs) { + throw new LineSenderException("Flush timeout after " + enqueueTimeoutMs + "ms, " + + "queue=" + getPendingSize() + ", processing=" + processingCount.get()); + } + + // Check for errors + checkError(); + } + } + + // If loop exited because running=false we still need to surface the root cause. + checkError(); + + LOG.debug("Flush complete"); + } + + /** + * Returns the last error that occurred in the I/O thread, or null if no error. + */ + public Throwable getLastError() { + return lastError; + } + + /** + * Returns the total number of batches sent. + */ + public long getTotalBatchesSent() { + return totalBatchesSent.get(); + } + + /** + * Returns the total number of bytes sent. + */ + public long getTotalBytesSent() { + return totalBytesSent.get(); + } + + /** + * Checks if an error occurred in the I/O thread and throws if so. + */ + private void checkError() { + Throwable error = lastError; + if (error != null) { + throw new LineSenderException("Error in send queue I/O thread: " + error.getMessage(), error); + } + } + + /** + * Computes the current I/O state based on queue and in-flight status. + */ + private IoState computeState(boolean hasInFlight) { + if (!isPendingEmpty()) { + return IoState.ACTIVE; + } else if (hasInFlight) { + return IoState.DRAINING; + } else { + return IoState.IDLE; + } + } + + private void failTransport(LineSenderException error) { + Throwable rootError = lastError; + if (rootError == null) { + lastError = error; + rootError = error; + } + running = false; + shuttingDown = true; + if (inFlightWindow != null) { + inFlightWindow.failAll(rootError); + } + synchronized (processingLock) { + MicrobatchBuffer dropped = pollPending(); + if (dropped != null) { + if (dropped.isSealed()) { + dropped.markSending(); + } + if (dropped.isSending()) { + dropped.markRecycled(); + } + } + processingLock.notifyAll(); + } + } + + private int getPendingSize() { + return pendingBuffer == null ? 0 : 1; + } + + /** + * The main I/O loop that handles both sending batches and receiving ACKs. + *

    + * Uses a state machine: + *

      + *
    • IDLE: block on processingLock.wait() until work arrives
    • + *
    • ACTIVE: non-blocking poll queue, send batches, check for ACKs
    • + *
    • DRAINING: no batches but ACKs pending - poll for ACKs with short wait
    • + *
    + */ + private void ioLoop() { + LOG.info("I/O loop started"); + + try { + while (running || !isPendingEmpty()) { + MicrobatchBuffer batch = null; + boolean hasInFlight = (inFlightWindow != null && inFlightWindow.getInFlightCount() > 0); + IoState state = computeState(hasInFlight); + + switch (state) { + case IDLE: + // Nothing to do - wait for work under lock + synchronized (processingLock) { + // Re-check under lock to avoid missed wakeup + if (isPendingEmpty() && running) { + try { + processingLock.wait(100); + } catch (InterruptedException e) { + if (!running) return; + } + } + } + break; + + case ACTIVE: + case DRAINING: + // Try to receive any pending ACKs (non-blocking) + if (hasInFlight && client.isConnected()) { + tryReceiveAcks(); + } + + // Try to dequeue and send a batch + boolean hasWindowSpace = (inFlightWindow == null || inFlightWindow.hasWindowSpace()); + if (hasWindowSpace) { + // Atomically: poll queue + increment processingCount + synchronized (processingLock) { + batch = pollPending(); + if (batch != null) { + processingCount.incrementAndGet(); + } + } + + if (batch != null) { + try { + safeSendBatch(batch); + } finally { + // Atomically: decrement + notify flush() + synchronized (processingLock) { + processingCount.decrementAndGet(); + processingLock.notifyAll(); + } + } + } + } + + // In DRAINING state with no work, short wait to avoid busy loop + if (state == IoState.DRAINING && batch == null) { + synchronized (processingLock) { + try { + processingLock.wait(10); + } catch (InterruptedException e) { + if (!running) return; + } + } + } + break; + } + } + } finally { + shutdownLatch.countDown(); + LOG.info("I/O loop stopped [totalAcks={}, totalErrors={}]", totalAcks.get(), totalErrors.get()); + } + } + + private boolean isPendingEmpty() { + return pendingBuffer == null; + } + + private boolean offerPending(MicrobatchBuffer buffer) { + if (pendingBuffer != null) { + return false; // slot occupied + } + pendingBuffer = buffer; + return true; + } + + private MicrobatchBuffer pollPending() { + MicrobatchBuffer buffer = pendingBuffer; + if (buffer != null) { + pendingBuffer = null; + } + return buffer; + } + + /** + * Sends a batch with error handling. Does NOT manage processingCount. + */ + private void safeSendBatch(MicrobatchBuffer batch) { + try { + sendBatch(batch); + } catch (Throwable t) { + LOG.error("Error sending batch [id={}]{}", batch.getBatchId(), "", t); + failTransport(new LineSenderException("Error sending batch " + batch.getBatchId() + ": " + t.getMessage(), t)); + // Mark as recycled even on error to allow cleanup + if (batch.isSealed()) { + batch.markSending(); + } + if (batch.isSending()) { + batch.markRecycled(); + } + } + } + + /** + * Sends a single batch over the WebSocket channel. + */ + private void sendBatch(MicrobatchBuffer batch) { + // Transition state: SEALED -> SENDING + batch.markSending(); + + // Use our own sequence counter (must match server's messageSequence) + long batchSequence = nextBatchSequence++; + int bytes = batch.getBufferPos(); + int rows = batch.getRowCount(); + + LOG.debug("Sending batch [seq={}, bytes={}, rows={}, bufferId={}]", batchSequence, bytes, rows, batch.getBatchId()); + + // Add to in-flight window BEFORE sending (so we're ready for ACK) + // Use non-blocking tryAddInFlight since we already checked window space in ioLoop + if (inFlightWindow != null) { + LOG.debug("Adding to in-flight window [seq={}, inFlight={}, max={}]", batchSequence, inFlightWindow.getInFlightCount(), inFlightWindow.getMaxWindowSize()); + if (!inFlightWindow.tryAddInFlight(batchSequence)) { + // Should not happen since we checked hasWindowSpace before polling + throw new LineSenderException("In-flight window unexpectedly full"); + } + LOG.debug("Added to in-flight window [seq={}]", batchSequence); + } + + // Send over WebSocket + LOG.debug("Calling sendBinary [seq={}]", batchSequence); + client.sendBinary(batch.getBufferPtr(), bytes); + LOG.debug("sendBinary returned [seq={}]", batchSequence); + + // Update statistics + totalBatchesSent.incrementAndGet(); + totalBytesSent.addAndGet(bytes); + + // Transition state: SENDING -> RECYCLED + batch.markRecycled(); + + LOG.debug("Batch sent and recycled [seq={}, bufferId={}]", batchSequence, batch.getBatchId()); + } + + /** + * Tries to receive ACKs from the server (non-blocking). + */ + private void tryReceiveAcks() { + try { + client.tryReceiveFrame(responseHandler); + } catch (Exception e) { + if (running) { + LOG.error("Error receiving response: {}", e.getMessage()); + failTransport(new LineSenderException("Error receiving response: " + e.getMessage(), e)); + } + } + } + + /** + * I/O loop states for the state machine. + *
      + *
    • IDLE: queue empty, no in-flight batches - can block waiting for work
    • + *
    • ACTIVE: have batches to send - non-blocking loop
    • + *
    • DRAINING: queue empty but ACKs pending - poll for ACKs, short wait
    • + *
    + */ + private enum IoState { + IDLE, ACTIVE, DRAINING + } + + /** + * Handler for received WebSocket frames (ACKs from server). + */ + private class ResponseHandler implements WebSocketFrameHandler { + + @Override + public void onBinaryMessage(long payloadPtr, int payloadLen) { + if (!WebSocketResponse.isStructurallyValid(payloadPtr, payloadLen)) { + LineSenderException error = new LineSenderException( + "Invalid ACK response payload [length=" + payloadLen + ']' + ); + LOG.error("Invalid ACK response payload [length={}]", payloadLen); + failTransport(error); + return; + } + + // Parse response from binary payload + if (!response.readFrom(payloadPtr, payloadLen)) { + LineSenderException error = new LineSenderException("Failed to parse ACK response"); + LOG.error("Failed to parse response"); + failTransport(error); + return; + } + + long sequence = response.getSequence(); + + if (response.isSuccess()) { + // Cumulative ACK - acknowledge all batches up to this sequence + if (inFlightWindow != null) { + int acked = inFlightWindow.acknowledgeUpTo(sequence); + if (acked > 0) { + totalAcks.addAndGet(acked); + LOG.debug("Cumulative ACK received [upTo={}, acked={}]", sequence, acked); + } else { + LOG.debug("ACK for already-acknowledged sequences [upTo={}]", sequence); + } + } + } else { + // Error - fail the batch + String errorMessage = response.getErrorMessage(); + LOG.error("Error response [seq={}, status={}, error={}]", sequence, response.getStatusName(), errorMessage); + + if (inFlightWindow != null) { + LineSenderException error = new LineSenderException( + "Server error for batch " + sequence + ": " + + response.getStatusName() + " - " + errorMessage); + inFlightWindow.fail(sequence, error); + } + totalErrors.incrementAndGet(); + } + } + + @Override + public void onClose(int code, String reason) { + LOG.info("WebSocket closed by server [code={}, reason={}]", code, reason); + failTransport(new LineSenderException("WebSocket closed by server [code=" + code + ", reason=" + reason + ']')); + } + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/OffHeapAppendMemory.java b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/OffHeapAppendMemory.java new file mode 100644 index 0000000..a30cf3c --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/OffHeapAppendMemory.java @@ -0,0 +1,200 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.protocol; + +import io.questdb.client.std.MemoryTag; +import io.questdb.client.std.QuietCloseable; +import io.questdb.client.std.Unsafe; + +/** + * Lightweight append-only off-heap buffer for columnar data storage. + *

    + * This buffer provides typed append operations (putByte, putShort, etc.) backed by + * native memory allocated via {@link Unsafe}. Memory is tracked under + * {@link MemoryTag#NATIVE_ILP_RSS} for precise accounting. + *

    + * Growth strategy: capacity doubles on each resize via {@link Unsafe#realloc}. + */ +public class OffHeapAppendMemory implements QuietCloseable { + + private static final int DEFAULT_INITIAL_CAPACITY = 128; + private long appendAddress; + private long capacity; + private long pageAddress; + + public OffHeapAppendMemory() { + this(DEFAULT_INITIAL_CAPACITY); + } + + public OffHeapAppendMemory(long initialCapacity) { + this.capacity = Math.max(initialCapacity, 8); + this.pageAddress = Unsafe.malloc(this.capacity, MemoryTag.NATIVE_ILP_RSS); + this.appendAddress = pageAddress; + } + + /** + * Returns the address at the given byte offset from the start. + */ + public long addressOf(long offset) { + return pageAddress + offset; + } + + @Override + public void close() { + if (pageAddress != 0) { + Unsafe.free(pageAddress, capacity, MemoryTag.NATIVE_ILP_RSS); + pageAddress = 0; + appendAddress = 0; + capacity = 0; + } + } + + /** + * Returns the append offset (number of bytes written). + */ + public long getAppendOffset() { + return appendAddress - pageAddress; + } + + /** + * Sets the append position to the given byte offset. + * Used for truncateTo operations on column buffers. + */ + public void jumpTo(long offset) { + assert offset >= 0 && offset <= getAppendOffset(); + appendAddress = pageAddress + offset; + } + + /** + * Returns the base address of the buffer. + */ + public long pageAddress() { + return pageAddress; + } + + public void putBoolean(boolean value) { + putByte(value ? (byte) 1 : (byte) 0); + } + + public void putByte(byte value) { + ensureCapacity(1); + Unsafe.getUnsafe().putByte(appendAddress, value); + appendAddress++; + } + + public void putDouble(double value) { + ensureCapacity(8); + Unsafe.getUnsafe().putDouble(appendAddress, value); + appendAddress += 8; + } + + public void putFloat(float value) { + ensureCapacity(4); + Unsafe.getUnsafe().putFloat(appendAddress, value); + appendAddress += 4; + } + + public void putInt(int value) { + ensureCapacity(4); + Unsafe.getUnsafe().putInt(appendAddress, value); + appendAddress += 4; + } + + public void putLong(long value) { + ensureCapacity(8); + Unsafe.getUnsafe().putLong(appendAddress, value); + appendAddress += 8; + } + + public void putShort(short value) { + ensureCapacity(2); + Unsafe.getUnsafe().putShort(appendAddress, value); + appendAddress += 2; + } + + /** + * Encodes a Java String to UTF-8 directly into the off-heap buffer. + * Pre-ensures worst-case capacity to avoid per-byte checks. + */ + public void putUtf8(String value) { + if (value == null || value.isEmpty()) { + return; + } + int len = value.length(); + ensureCapacity((long) len * 4); // worst case: all supplementary chars + for (int i = 0; i < len; i++) { + char c = value.charAt(i); + if (c < 0x80) { + Unsafe.getUnsafe().putByte(appendAddress++, (byte) c); + } else if (c < 0x800) { + Unsafe.getUnsafe().putByte(appendAddress++, (byte) (0xC0 | (c >> 6))); + Unsafe.getUnsafe().putByte(appendAddress++, (byte) (0x80 | (c & 0x3F))); + } else if (c >= 0xD800 && c <= 0xDBFF && i + 1 < len) { + char c2 = value.charAt(++i); + if (Character.isLowSurrogate(c2)) { + int codePoint = 0x10000 + ((c - 0xD800) << 10) + (c2 - 0xDC00); + Unsafe.getUnsafe().putByte(appendAddress++, (byte) (0xF0 | (codePoint >> 18))); + Unsafe.getUnsafe().putByte(appendAddress++, (byte) (0x80 | ((codePoint >> 12) & 0x3F))); + Unsafe.getUnsafe().putByte(appendAddress++, (byte) (0x80 | ((codePoint >> 6) & 0x3F))); + Unsafe.getUnsafe().putByte(appendAddress++, (byte) (0x80 | (codePoint & 0x3F))); + } else { + Unsafe.getUnsafe().putByte(appendAddress++, (byte) '?'); + i--; + } + } else if (Character.isSurrogate(c)) { + Unsafe.getUnsafe().putByte(appendAddress++, (byte) '?'); + } else { + Unsafe.getUnsafe().putByte(appendAddress++, (byte) (0xE0 | (c >> 12))); + Unsafe.getUnsafe().putByte(appendAddress++, (byte) (0x80 | ((c >> 6) & 0x3F))); + Unsafe.getUnsafe().putByte(appendAddress++, (byte) (0x80 | (c & 0x3F))); + } + } + } + + /** + * Advances the append position by the given number of bytes without writing. + */ + public void skip(long bytes) { + ensureCapacity(bytes); + appendAddress += bytes; + } + + /** + * Resets the append position to 0 without freeing memory. + */ + public void truncate() { + appendAddress = pageAddress; + } + + private void ensureCapacity(long needed) { + long used = appendAddress - pageAddress; + if (used + needed > capacity) { + long newCapacity = Math.max(capacity * 2, used + needed); + pageAddress = Unsafe.realloc(pageAddress, capacity, newCapacity, MemoryTag.NATIVE_ILP_RSS); + capacity = newCapacity; + appendAddress = pageAddress + used; + } + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpBitWriter.java b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpBitWriter.java new file mode 100644 index 0000000..fbe6efd --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpBitWriter.java @@ -0,0 +1,235 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.protocol; + +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.std.Unsafe; + +/** + * Bit-level writer for ILP v4 protocol. + *

    + * This class writes bits to a buffer in LSB-first order within each byte. + * Bits are packed sequentially, spanning byte boundaries as needed. + *

    + * The implementation buffers up to 64 bits before flushing to the output buffer + * to minimize memory operations. All writes are to direct memory for performance. + *

    + * Usage pattern: + *

    + * QwpBitWriter writer = new QwpBitWriter();
    + * writer.reset(address, capacity);
    + * writer.writeBits(value, numBits);
    + * writer.writeBits(value2, numBits2);
    + * writer.flush(); // must call before reading output
    + * long bytesWritten = writer.getPosition() - address;
    + * 
    + */ +public class QwpBitWriter { + + // Buffer for accumulating bits before writing + private long bitBuffer; + // Number of bits currently in the buffer (0-63) + private int bitsInBuffer; + private long currentAddress; + private long endAddress; + private long startAddress; + + /** + * Creates a new bit writer. Call {@link #reset} before use. + */ + public QwpBitWriter() { + } + + /** + * Aligns the writer to the next byte boundary by padding with zeros. + * If already byte-aligned, this is a no-op. + */ + public void alignToByte() { + if (bitsInBuffer > 0) { + flush(); + } + } + + /** + * Finishes writing and returns the number of bytes written since reset. + *

    + * This method flushes any remaining bits and returns the total byte count. + * + * @return bytes written since reset + */ + public int finish() { + flush(); + return (int) (currentAddress - startAddress); + } + + /** + * Flushes any remaining bits in the buffer to memory. + *

    + * If there are partial bits (less than 8), they are written as the last byte + * with the remaining high bits set to zero. + *

    + * Must be called before reading the output or getting the final position. + */ + public void flush() { + if (bitsInBuffer > 0) { + if (currentAddress >= endAddress) { + throw new LineSenderException("QwpBitWriter buffer overflow"); + } + Unsafe.getUnsafe().putByte(currentAddress++, (byte) bitBuffer); + bitBuffer = 0; + bitsInBuffer = 0; + } + } + + /** + * Returns the current write position (address). + * Note: Call {@link #flush()} first to ensure all buffered bits are written. + * + * @return the current address after all written data + */ + public long getPosition() { + return currentAddress; + } + + /** + * Resets the writer to write to the specified memory region. + * + * @param address the starting address + * @param capacity the maximum number of bytes to write + */ + public void reset(long address, long capacity) { + this.startAddress = address; + this.currentAddress = address; + this.endAddress = address + capacity; + this.bitBuffer = 0; + this.bitsInBuffer = 0; + } + + /** + * Writes a single bit. + * + * @param bit the bit value (0 or 1, only LSB is used) + */ + public void writeBit(int bit) { + writeBits(bit & 1, 1); + } + + /** + * Writes multiple bits from the given value. + *

    + * Bits are taken from the LSB of the value. For example, if value=0b1101 + * and numBits=4, the bits written are 1, 0, 1, 1 (LSB to MSB order). + * + * @param value the value containing the bits (LSB-aligned) + * @param numBits number of bits to write (1-64) + */ + public void writeBits(long value, int numBits) { + if (numBits <= 0 || numBits > 64) { + throw new AssertionError("Asked to write more than 64 bits of a long"); + } + + // Mask the value to only include the requested bits + if (numBits < 64) { + value &= (1L << numBits) - 1; + } + + int bitsToWrite = numBits; + + while (bitsToWrite > 0) { + // How many bits can fit into the current buffer (max 64 total) + int availableInBuffer = 64 - bitsInBuffer; + int bitsThisRound = Math.min(bitsToWrite, availableInBuffer); + + // Add bits to the buffer + long mask = bitsThisRound == 64 ? -1L : (1L << bitsThisRound) - 1; + bitBuffer |= (value & mask) << bitsInBuffer; + bitsInBuffer += bitsThisRound; + value >>>= bitsThisRound; + bitsToWrite -= bitsThisRound; + + // Flush complete bytes from the buffer + while (bitsInBuffer >= 8) { + if (currentAddress >= endAddress) { + throw new LineSenderException("QwpBitWriter buffer overflow"); + } + Unsafe.getUnsafe().putByte(currentAddress++, (byte) bitBuffer); + bitBuffer >>>= 8; + bitsInBuffer -= 8; + } + } + } + + /** + * Writes a complete byte, ensuring byte alignment first. + * + * @param value the byte value + */ + public void writeByte(int value) { + alignToByte(); + if (currentAddress >= endAddress) { + throw new LineSenderException("QwpBitWriter buffer overflow"); + } + Unsafe.getUnsafe().putByte(currentAddress++, (byte) value); + } + + /** + * Writes a complete 32-bit integer in little-endian order, ensuring byte alignment first. + * + * @param value the integer value + */ + public void writeInt(int value) { + alignToByte(); + if (currentAddress + 4 > endAddress) { + throw new LineSenderException("QwpBitWriter buffer overflow"); + } + Unsafe.getUnsafe().putInt(currentAddress, value); + currentAddress += 4; + } + + /** + * Writes a complete 64-bit long in little-endian order, ensuring byte alignment first. + * + * @param value the long value + */ + public void writeLong(long value) { + alignToByte(); + if (currentAddress + 8 > endAddress) { + throw new LineSenderException("QwpBitWriter buffer overflow"); + } + Unsafe.getUnsafe().putLong(currentAddress, value); + currentAddress += 8; + } + + /** + * Writes a signed value using two's complement representation. + * + * @param value the signed value + * @param numBits number of bits to use for the representation + */ + public void writeSigned(long value, int numBits) { + // Two's complement is automatic in Java for the bit pattern + writeBits(value, numBits); + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpColumnDef.java b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpColumnDef.java new file mode 100644 index 0000000..a2f4f50 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpColumnDef.java @@ -0,0 +1,148 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.protocol; + +import static io.questdb.client.cutlass.qwp.protocol.QwpConstants.TYPE_BOOLEAN; +import static io.questdb.client.cutlass.qwp.protocol.QwpConstants.TYPE_CHAR; + +/** + * Represents a column definition in an ILP v4 schema. + *

    + * This class is immutable and safe for caching. + */ +public final class QwpColumnDef { + private final String name; + private final boolean nullable; + private final byte typeCode; + + /** + * Creates a column definition. + * + * @param name the column name (UTF-8) + * @param typeCode the ILP v4 type code (0x01-0x0F, optionally OR'd with 0x80 for nullable) + */ + public QwpColumnDef(String name, byte typeCode) { + this.name = name; + // Extract nullable flag (high bit) and base type + this.nullable = (typeCode & 0x80) != 0; + this.typeCode = (byte) (typeCode & 0x7F); + } + + /** + * Creates a column definition with explicit nullable flag. + * + * @param name the column name + * @param typeCode the base type code (0x01-0x0F) + * @param nullable whether the column is nullable + */ + public QwpColumnDef(String name, byte typeCode, boolean nullable) { + this.name = name; + this.typeCode = (byte) (typeCode & 0x7F); + this.nullable = nullable; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + QwpColumnDef that = (QwpColumnDef) o; + return typeCode == that.typeCode && + nullable == that.nullable && + name.equals(that.name); + } + + /** + * Gets the column name. + */ + public String getName() { + return name; + } + + /** + * Gets the base type code (without nullable flag). + * + * @return type code 0x01-0x0F + */ + public byte getTypeCode() { + return typeCode; + } + + /** + * Gets the type name for display purposes. + */ + public String getTypeName() { + return QwpConstants.getTypeName(typeCode); + } + + /** + * Gets the wire type code (with nullable flag if applicable). + * + * @return type code as sent on wire + */ + public byte getWireTypeCode() { + return nullable ? (byte) (typeCode | 0x80) : typeCode; + } + + @Override + public int hashCode() { + int result = name.hashCode(); + result = 31 * result + typeCode; + result = 31 * result + (nullable ? 1 : 0); + return result; + } + + /** + * Returns true if this column is nullable. + */ + public boolean isNullable() { + return nullable; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append(name).append(':').append(getTypeName()); + if (nullable) { + sb.append('?'); + } + return sb.toString(); + } + + /** + * Validates that this column definition has a valid type code. + * + * @throws IllegalArgumentException if type code is invalid + */ + public void validate() { + // Valid type codes: TYPE_BOOLEAN (0x01) through TYPE_CHAR (0x16) + // This includes all basic types, arrays, decimals, and char + boolean valid = (typeCode >= TYPE_BOOLEAN && typeCode <= TYPE_CHAR); + if (!valid) { + throw new IllegalArgumentException( + "invalid column type code: 0x" + Integer.toHexString(typeCode) + ); + } + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpConstants.java b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpConstants.java new file mode 100644 index 0000000..216f2fa --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpConstants.java @@ -0,0 +1,348 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.protocol; + +/** + * Constants for the ILP v4 binary protocol. + */ +public final class QwpConstants { + + /** + * Default initial receive buffer size (64 KB). + */ + public static final int DEFAULT_INITIAL_RECV_BUFFER_SIZE = 64 * 1024; + /** + * Default maximum batch size in bytes (16 MB). + */ + public static final int DEFAULT_MAX_BATCH_SIZE = 16 * 1024 * 1024; + + /** + * Maximum in-flight batches for pipelining. + */ + public static final int DEFAULT_MAX_IN_FLIGHT_BATCHES = 4; + /** + * Default maximum rows per table in a batch. + */ + public static final int DEFAULT_MAX_ROWS_PER_TABLE = 1_000_000; + /** + * Default maximum tables per batch. + */ + public static final int DEFAULT_MAX_TABLES_PER_BATCH = 256; + /** + * Flag bit: Delta symbol dictionary encoding enabled. + * When set, symbol columns use global IDs and send only new dictionary entries. + */ + public static final byte FLAG_DELTA_SYMBOL_DICT = 0x08; + /** + * Flag bit: Gorilla timestamp encoding enabled. + */ + public static final byte FLAG_GORILLA = 0x04; + + /** + * Flag bit: LZ4 compression enabled. + */ + public static final byte FLAG_LZ4 = 0x01; + + /** + * Flag bit: Zstd compression enabled. + */ + public static final byte FLAG_ZSTD = 0x02; + /** + * Mask for compression flags (bits 0-1). + */ + public static final byte FLAG_COMPRESSION_MASK = FLAG_LZ4 | FLAG_ZSTD; + /** + * Offset of flags byte in header. + */ + public static final int HEADER_OFFSET_FLAGS = 5; + /** + * Size of the message header in bytes. + */ + public static final int HEADER_SIZE = 12; + /** + * Magic bytes for capability request: "ILP?" (ASCII). + */ + public static final int MAGIC_CAPABILITY_REQUEST = 0x3F504C49; // "ILP?" in little-endian + /** + * Magic bytes for capability response: "ILP!" (ASCII). + */ + public static final int MAGIC_CAPABILITY_RESPONSE = 0x21504C49; // "ILP!" in little-endian + /** + * Magic bytes for fallback response (old server): "ILP0" (ASCII). + */ + public static final int MAGIC_FALLBACK = 0x30504C49; // "ILP0" in little-endian + /** + * Magic bytes for QWP v1 message: "QWP1" (ASCII). + */ + public static final int MAGIC_MESSAGE = 0x31505751; // "QWP1" in little-endian + /** + * Maximum columns per table (QuestDB limit). + */ + public static final int MAX_COLUMNS_PER_TABLE = 2048; + /** + * Schema mode: Full schema included. + */ + public static final byte SCHEMA_MODE_FULL = 0x00; + /** + * Schema mode: Schema reference (hash lookup). + */ + public static final byte SCHEMA_MODE_REFERENCE = 0x01; + /** + * Status: Server error. + */ + public static final byte STATUS_INTERNAL_ERROR = 0x06; + /** + * Status: Batch accepted successfully. + */ + public static final byte STATUS_OK = 0x00; + /** + * Status: Back-pressure, retry later. + */ + public static final byte STATUS_OVERLOADED = 0x07; + /** + * Status: Malformed message. + */ + public static final byte STATUS_PARSE_ERROR = 0x05; + /** + * Status: Some rows failed (partial failure). + */ + public static final byte STATUS_PARTIAL = 0x01; + /** + * Status: Column type incompatible. + */ + public static final byte STATUS_SCHEMA_MISMATCH = 0x03; + /** + * Status: Schema hash not recognized. + */ + public static final byte STATUS_SCHEMA_REQUIRED = 0x02; + /** + * Status: Table doesn't exist (auto-create disabled). + */ + public static final byte STATUS_TABLE_NOT_FOUND = 0x04; + /** + * Column type: BOOLEAN (1 bit per value, packed). + */ + public static final byte TYPE_BOOLEAN = 0x01; + /** + * Column type: BYTE (int8). + */ + public static final byte TYPE_BYTE = 0x02; + /** + * Column type: CHAR (2-byte UTF-16 code unit). + */ + public static final byte TYPE_CHAR = 0x16; + /** + * Column type: DATE (int64 milliseconds since epoch). + */ + public static final byte TYPE_DATE = 0x0B; + + /** + * Column type: DECIMAL128 (16 bytes, 38 digits precision). + * Wire format: [scale (1B in schema)] + [big-endian unscaled value (16B)] + */ + public static final byte TYPE_DECIMAL128 = 0x14; + /** + * Column type: DECIMAL256 (32 bytes, 77 digits precision). + * Wire format: [scale (1B in schema)] + [big-endian unscaled value (32B)] + */ + public static final byte TYPE_DECIMAL256 = 0x15; + + /** + * Column type: DECIMAL64 (8 bytes, 18 digits precision). + * Wire format: [scale (1B in schema)] + [big-endian unscaled value (8B)] + */ + public static final byte TYPE_DECIMAL64 = 0x13; + /** + * Column type: DOUBLE (IEEE 754 float64). + */ + public static final byte TYPE_DOUBLE = 0x07; + /** + * Column type: DOUBLE_ARRAY (N-dimensional array of IEEE 754 float64). + * Wire format: [nDims (1B)] [dim1_len (4B)]...[dimN_len (4B)] [flattened values (LE)] + */ + public static final byte TYPE_DOUBLE_ARRAY = 0x11; + /** + * Column type: FLOAT (IEEE 754 float32). + */ + public static final byte TYPE_FLOAT = 0x06; + /** + * Column type: GEOHASH (varint bits + packed geohash). + */ + public static final byte TYPE_GEOHASH = 0x0E; + /** + * Column type: INT (int32, little-endian). + */ + public static final byte TYPE_INT = 0x04; + /** + * Column type: LONG (int64, little-endian). + */ + public static final byte TYPE_LONG = 0x05; + /** + * Column type: LONG256 (32 bytes, big-endian). + */ + public static final byte TYPE_LONG256 = 0x0D; + + /** + * Column type: LONG_ARRAY (N-dimensional array of int64). + * Wire format: [nDims (1B)] [dim1_len (4B)]...[dimN_len (4B)] [flattened values (LE)] + */ + public static final byte TYPE_LONG_ARRAY = 0x12; + /** + * Mask for type code without nullable flag. + */ + public static final byte TYPE_MASK = 0x7F; + /** + * High bit indicating nullable column. + */ + public static final byte TYPE_NULLABLE_FLAG = (byte) 0x80; + /** + * Column type: SHORT (int16, little-endian). + */ + public static final byte TYPE_SHORT = 0x03; + /** + * Column type: STRING (length-prefixed UTF-8). + */ + public static final byte TYPE_STRING = 0x08; + /** + * Column type: SYMBOL (dictionary-encoded string). + */ + public static final byte TYPE_SYMBOL = 0x09; + /** + * Column type: TIMESTAMP (int64 microseconds since epoch). + * Use this for timestamps beyond nanosecond range (year > 2262). + */ + public static final byte TYPE_TIMESTAMP = 0x0A; + /** + * Column type: TIMESTAMP_NANOS (int64 nanoseconds since epoch). + * Use this for full nanosecond precision (limited to years 1677-2262). + */ + public static final byte TYPE_TIMESTAMP_NANOS = 0x10; + /** + * Column type: UUID (16 bytes, big-endian). + */ + public static final byte TYPE_UUID = 0x0C; + + /** + * Column type: VARCHAR (length-prefixed UTF-8, aux storage). + */ + public static final byte TYPE_VARCHAR = 0x0F; + /** + * Current protocol version. + */ + public static final byte VERSION_1 = 1; + + private QwpConstants() { + // utility class + } + + /** + * Returns the per-value size in bytes as encoded on the wire. BOOLEAN returns 0 + * because it is bit-packed (1 bit per value). GEOHASH returns -1 because it uses + * variable-width encoding (varint precision + ceil(precision/8) bytes per value). + *

    + * This is distinct from the in-memory buffer stride used by the client's + * {@code QwpTableBuffer.elementSizeInBuffer()}. + * + * @param typeCode the column type code (without nullable flag) + * @return size in bytes, 0 for bit-packed (BOOLEAN), or -1 for variable-width types + */ + public static int getFixedTypeSize(byte typeCode) { + int code = typeCode & TYPE_MASK; + return switch (code) { + case TYPE_BOOLEAN -> 0; // Special: bit-packed + case TYPE_BYTE -> 1; + case TYPE_SHORT, TYPE_CHAR -> 2; + case TYPE_INT, TYPE_FLOAT -> 4; + case TYPE_LONG, TYPE_DOUBLE, TYPE_TIMESTAMP, TYPE_TIMESTAMP_NANOS, TYPE_DATE, TYPE_DECIMAL64 -> 8; + case TYPE_UUID, TYPE_DECIMAL128 -> 16; + case TYPE_LONG256, TYPE_DECIMAL256 -> 32; + case TYPE_GEOHASH -> -1; // Variable width: varint precision + packed values + default -> -1; // Variable width + }; + } + + /** + * Returns a human-readable name for the type code. + * + * @param typeCode the column type code + * @return type name + */ + public static String getTypeName(byte typeCode) { + int code = typeCode & TYPE_MASK; + boolean nullable = (typeCode & TYPE_NULLABLE_FLAG) != 0; + String name = switch (code) { + case TYPE_BOOLEAN -> "BOOLEAN"; + case TYPE_BYTE -> "BYTE"; + case TYPE_SHORT -> "SHORT"; + case TYPE_CHAR -> "CHAR"; + case TYPE_INT -> "INT"; + case TYPE_LONG -> "LONG"; + case TYPE_FLOAT -> "FLOAT"; + case TYPE_DOUBLE -> "DOUBLE"; + case TYPE_STRING -> "STRING"; + case TYPE_SYMBOL -> "SYMBOL"; + case TYPE_TIMESTAMP -> "TIMESTAMP"; + case TYPE_TIMESTAMP_NANOS -> "TIMESTAMP_NANOS"; + case TYPE_DATE -> "DATE"; + case TYPE_UUID -> "UUID"; + case TYPE_LONG256 -> "LONG256"; + case TYPE_GEOHASH -> "GEOHASH"; + case TYPE_VARCHAR -> "VARCHAR"; + case TYPE_DOUBLE_ARRAY -> "DOUBLE_ARRAY"; + case TYPE_LONG_ARRAY -> "LONG_ARRAY"; + case TYPE_DECIMAL64 -> "DECIMAL64"; + case TYPE_DECIMAL128 -> "DECIMAL128"; + case TYPE_DECIMAL256 -> "DECIMAL256"; + default -> "UNKNOWN(" + code + ")"; + }; + return nullable ? name + "?" : name; + } + + /** + * Returns true if the type code represents a fixed-width type. + * + * @param typeCode the column type code (without nullable flag) + * @return true if fixed-width + */ + public static boolean isFixedWidthType(byte typeCode) { + int code = typeCode & TYPE_MASK; + return code == TYPE_BOOLEAN || + code == TYPE_BYTE || + code == TYPE_SHORT || + code == TYPE_CHAR || + code == TYPE_INT || + code == TYPE_LONG || + code == TYPE_FLOAT || + code == TYPE_DOUBLE || + code == TYPE_TIMESTAMP || + code == TYPE_TIMESTAMP_NANOS || + code == TYPE_DATE || + code == TYPE_UUID || + code == TYPE_LONG256 || + code == TYPE_DECIMAL64 || + code == TYPE_DECIMAL128 || + code == TYPE_DECIMAL256; + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpGorillaEncoder.java b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpGorillaEncoder.java new file mode 100644 index 0000000..302299c --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpGorillaEncoder.java @@ -0,0 +1,288 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.protocol; + +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.std.Unsafe; + +/** + * Gorilla delta-of-delta encoder for timestamps in ILP v4 format. + *

    + * This encoder is used by the WebSocket encoder to compress timestamp columns. + * It uses delta-of-delta compression where: + *

    + * DoD = (t[n] - t[n-1]) - (t[n-1] - t[n-2])
    + *
    + * if DoD == 0:              write '0'              (1 bit)
    + * elif DoD in [-64, 63]:    write '10' + 7-bit     (9 bits)
    + * elif DoD in [-256, 255]:  write '110' + 9-bit    (12 bits)
    + * elif DoD in [-2048, 2047]: write '1110' + 12-bit (16 bits)
    + * else:                     write '1111' + 32-bit  (36 bits)
    + * 
    + *

    + * The encoder writes first two timestamps uncompressed, then encodes + * remaining timestamps using delta-of-delta compression. + */ +public class QwpGorillaEncoder { + + private static final int BUCKET_12BIT_MAX = 2047; + private static final int BUCKET_12BIT_MIN = -2048; + private static final int BUCKET_7BIT_MAX = 63; + // Bucket boundaries (two's complement signed ranges) + private static final int BUCKET_7BIT_MIN = -64; + private static final int BUCKET_9BIT_MAX = 255; + private static final int BUCKET_9BIT_MIN = -256; + private final QwpBitWriter bitWriter = new QwpBitWriter(); + + /** + * Creates a new Gorilla encoder. + */ + public QwpGorillaEncoder() { + } + + /** + * Calculates the encoded size in bytes for Gorilla-encoded timestamps stored off-heap. + *

    + * Note: This does NOT include the encoding flag byte. Add 1 byte if + * the encoding flag is needed. + * + * @param srcAddress source address of contiguous int64 timestamps in native memory + * @param count number of timestamps + * @return encoded size in bytes (excluding encoding flag) + */ + public static int calculateEncodedSize(long srcAddress, int count) { + if (count == 0) { + return 0; + } + + int size = 8; // first timestamp + + if (count == 1) { + return size; + } + + size += 8; // second timestamp + + if (count == 2) { + return size; + } + + // Calculate bits for delta-of-delta encoding + long prevTimestamp = Unsafe.getUnsafe().getLong(srcAddress + 8); + long prevDelta = prevTimestamp - Unsafe.getUnsafe().getLong(srcAddress); + int totalBits = 0; + + for (int i = 2; i < count; i++) { + long ts = Unsafe.getUnsafe().getLong(srcAddress + (long) i * 8); + long delta = ts - prevTimestamp; + long deltaOfDelta = delta - prevDelta; + + totalBits += getBitsRequired(deltaOfDelta); + + prevDelta = delta; + prevTimestamp = ts; + } + + // Round up to bytes + size += (totalBits + 7) / 8; + + return size; + } + + /** + * Checks if Gorilla encoding can be used for timestamps stored off-heap. + *

    + * Gorilla encoding uses 32-bit signed integers for delta-of-delta values, + * so it cannot encode timestamps where the delta-of-delta exceeds the + * 32-bit signed integer range. + * + * @param srcAddress source address of contiguous int64 timestamps in native memory + * @param count number of timestamps + * @return true if Gorilla encoding can be used, false otherwise + */ + public static boolean canUseGorilla(long srcAddress, int count) { + if (count < 3) { + return true; // No DoD encoding needed for 0, 1, or 2 timestamps + } + + long prevDelta = Unsafe.getUnsafe().getLong(srcAddress + 8) - Unsafe.getUnsafe().getLong(srcAddress); + for (int i = 2; i < count; i++) { + long delta = Unsafe.getUnsafe().getLong(srcAddress + (long) i * 8) + - Unsafe.getUnsafe().getLong(srcAddress + (long) (i - 1) * 8); + long dod = delta - prevDelta; + if (dod < Integer.MIN_VALUE || dod > Integer.MAX_VALUE) { + return false; + } + prevDelta = delta; + } + return true; + } + + /** + * Returns the number of bits required to encode a delta-of-delta value. + * + * @param deltaOfDelta the delta-of-delta value + * @return bits required + */ + public static int getBitsRequired(long deltaOfDelta) { + int bucket = getBucket(deltaOfDelta); + return switch (bucket) { + case 0 -> 1; + case 1 -> 9; + case 2 -> 12; + case 3 -> 16; + default -> 36; + }; + } + + /** + * Determines which bucket a delta-of-delta value falls into. + * + * @param deltaOfDelta the delta-of-delta value + * @return bucket number (0 = 1-bit, 1 = 9-bit, 2 = 12-bit, 3 = 16-bit, 4 = 36-bit) + */ + public static int getBucket(long deltaOfDelta) { + if (deltaOfDelta == 0) { + return 0; // 1-bit + } else if (deltaOfDelta >= BUCKET_7BIT_MIN && deltaOfDelta <= BUCKET_7BIT_MAX) { + return 1; // 9-bit (2 prefix + 7 value) + } else if (deltaOfDelta >= BUCKET_9BIT_MIN && deltaOfDelta <= BUCKET_9BIT_MAX) { + return 2; // 12-bit (3 prefix + 9 value) + } else if (deltaOfDelta >= BUCKET_12BIT_MIN && deltaOfDelta <= BUCKET_12BIT_MAX) { + return 3; // 16-bit (4 prefix + 12 value) + } else { + return 4; // 36-bit (4 prefix + 32 value) + } + } + + /** + * Encodes a delta-of-delta value using bucket selection. + *

    + * Prefix patterns are written LSB-first to match the decoder's read order: + *

      + *
    • '0' -> write bit 0
    • + *
    • '10' -> write bit 1, then bit 0 (0b01 as 2-bit value)
    • + *
    • '110' -> write bit 1, bit 1, bit 0 (0b011 as 3-bit value)
    • + *
    • '1110' -> write bit 1, bit 1, bit 1, bit 0 (0b0111 as 4-bit value)
    • + *
    • '1111' -> write bit 1, bit 1, bit 1, bit 1 (0b1111 as 4-bit value)
    • + *
    + * + * @param deltaOfDelta the delta-of-delta value to encode + */ + public void encodeDoD(long deltaOfDelta) { + int bucket = getBucket(deltaOfDelta); + switch (bucket) { + case 0 -> bitWriter.writeBit(0); + case 1 -> { + bitWriter.writeBits(0b01, 2); + bitWriter.writeSigned(deltaOfDelta, 7); + } + case 2 -> { + bitWriter.writeBits(0b011, 3); + bitWriter.writeSigned(deltaOfDelta, 9); + } + case 3 -> { + bitWriter.writeBits(0b0111, 4); + bitWriter.writeSigned(deltaOfDelta, 12); + } + default -> { + bitWriter.writeBits(0b1111, 4); + bitWriter.writeSigned(deltaOfDelta, 32); + } + } + } + + /** + * Encodes timestamps from off-heap memory using Gorilla compression. + *

    + * Format: + *

    +     * - First timestamp: int64 (8 bytes, little-endian)
    +     * - Second timestamp: int64 (8 bytes, little-endian)
    +     * - Remaining timestamps: bit-packed delta-of-delta
    +     * 
    + *

    + * Precondition: the caller must verify that {@link #canUseGorilla(long, int)} + * returns {@code true} before calling this method. The largest delta-of-delta + * bucket uses 32-bit signed encoding, so values outside the {@code int} range + * are silently truncated, producing corrupt output on decode. + *

    + * Note: This method does NOT write the encoding flag byte. The caller is + * responsible for writing the ENCODING_GORILLA flag before calling this method. + * + * @param destAddress destination address in native memory + * @param capacity maximum number of bytes to write + * @param srcAddress source address of contiguous int64 timestamps in native memory + * @param count number of timestamps to encode + * @return number of bytes written + */ + public int encodeTimestamps(long destAddress, long capacity, long srcAddress, int count) { + if (count == 0) { + return 0; + } + + int pos; + + // Write first timestamp uncompressed + if (capacity < 8) { + throw new LineSenderException("Gorilla encoder buffer overflow"); + } + long ts0 = Unsafe.getUnsafe().getLong(srcAddress); + Unsafe.getUnsafe().putLong(destAddress, ts0); + pos = 8; + + if (count == 1) { + return pos; + } + + // Write second timestamp uncompressed + if (capacity < pos + 8) { + throw new LineSenderException("Gorilla encoder buffer overflow"); + } + long ts1 = Unsafe.getUnsafe().getLong(srcAddress + 8); + Unsafe.getUnsafe().putLong(destAddress + pos, ts1); + pos += 8; + + if (count == 2) { + return pos; + } + + // Encode remaining with delta-of-delta + bitWriter.reset(destAddress + pos, capacity - pos); + long prevTs = ts1; + long prevDelta = ts1 - ts0; + + for (int i = 2; i < count; i++) { + long ts = Unsafe.getUnsafe().getLong(srcAddress + (long) i * 8); + long delta = ts - prevTs; + long dod = delta - prevDelta; + encodeDoD(dod); + prevDelta = delta; + prevTs = ts; + } + + return pos + bitWriter.finish(); + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpSchemaHash.java b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpSchemaHash.java new file mode 100644 index 0000000..63d005d --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpSchemaHash.java @@ -0,0 +1,519 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.protocol; + + +import io.questdb.client.std.Unsafe; + +/** + * XXHash64 implementation for schema hashing in ILP v4 protocol. + *

    + * The schema hash is computed over column definitions (name + type) to enable + * schema caching. When a client sends a schema reference (hash), the server + * can look up the cached schema instead of re-parsing the full schema each time. + *

    + * This is a pure Java implementation of XXHash64 based on the original algorithm + * by Yann Collet. It's optimized for small inputs typical of schema hashing. + * + * @see xxHash + */ +public final class QwpSchemaHash { + + // Default seed (0 for ILP v4) + private static final long DEFAULT_SEED = 0L; + // XXHash64 constants + private static final long PRIME64_1 = 0x9E3779B185EBCA87L; + private static final long PRIME64_2 = 0xC2B2AE3D27D4EB4FL; + // Thread-local Hasher to avoid allocation on every computeSchemaHash call + private static final ThreadLocal HASHER_POOL = ThreadLocal.withInitial(Hasher::new); + private static final long PRIME64_3 = 0x165667B19E3779F9L; + private static final long PRIME64_4 = 0x85EBCA77C2B2AE63L; + private static final long PRIME64_5 = 0x27D4EB2F165667C5L; + + private QwpSchemaHash() { + // utility class + } + + /** + * Computes the schema hash for ILP v4 using String column names. + * Note: Iterates over String chars and converts to UTF-8 bytes directly to avoid getBytes() allocation. + * + * @param columnNames array of column names + * @param columnTypes array of type codes + * @return the schema hash + */ + public static long computeSchemaHash(String[] columnNames, byte[] columnTypes) { + // Use pooled hasher to avoid allocation + Hasher hasher = HASHER_POOL.get(); + hasher.reset(DEFAULT_SEED); + + for (int i = 0; i < columnNames.length; i++) { + String name = columnNames[i]; + // Encode UTF-8 directly without allocating byte array + for (int j = 0, len = name.length(); j < len; j++) { + char c = name.charAt(j); + if (c < 0x80) { + // Single byte (ASCII) + hasher.update((byte) c); + } else if (c < 0x800) { + // Two bytes + hasher.update((byte) (0xC0 | (c >> 6))); + hasher.update((byte) (0x80 | (c & 0x3F))); + } else if (c >= 0xD800 && c <= 0xDBFF && j + 1 < len) { + // Surrogate pair (4 bytes) + char c2 = name.charAt(++j); + if (Character.isLowSurrogate(c2)) { + int codePoint = 0x10000 + ((c - 0xD800) << 10) + (c2 - 0xDC00); + hasher.update((byte) (0xF0 | (codePoint >> 18))); + hasher.update((byte) (0x80 | ((codePoint >> 12) & 0x3F))); + hasher.update((byte) (0x80 | ((codePoint >> 6) & 0x3F))); + hasher.update((byte) (0x80 | (codePoint & 0x3F))); + } else { + hasher.update((byte) '?'); + j--; + } + } else if (Character.isSurrogate(c)) { + hasher.update((byte) '?'); + } else { + // Three bytes + hasher.update((byte) (0xE0 | (c >> 12))); + hasher.update((byte) (0x80 | ((c >> 6) & 0x3F))); + hasher.update((byte) (0x80 | (c & 0x3F))); + } + } + hasher.update(columnTypes[i]); + } + + return hasher.getValue(); + } + + /** + * Computes the schema hash directly from column buffers without intermediate arrays. + * This is the most efficient method when column data is already available. + * + * @param columns list of column buffers + * @return the schema hash + */ + public static long computeSchemaHashDirect(io.questdb.client.std.ObjList columns) { + // Use pooled hasher to avoid allocation + Hasher hasher = HASHER_POOL.get(); + hasher.reset(DEFAULT_SEED); + + for (int i = 0, n = columns.size(); i < n; i++) { + QwpTableBuffer.ColumnBuffer col = columns.get(i); + String name = col.getName(); + // Encode UTF-8 directly without allocating byte array + for (int j = 0, len = name.length(); j < len; j++) { + char c = name.charAt(j); + if (c < 0x80) { + hasher.update((byte) c); + } else if (c < 0x800) { + hasher.update((byte) (0xC0 | (c >> 6))); + hasher.update((byte) (0x80 | (c & 0x3F))); + } else if (c >= 0xD800 && c <= 0xDBFF && j + 1 < len) { + char c2 = name.charAt(++j); + if (Character.isLowSurrogate(c2)) { + int codePoint = 0x10000 + ((c - 0xD800) << 10) + (c2 - 0xDC00); + hasher.update((byte) (0xF0 | (codePoint >> 18))); + hasher.update((byte) (0x80 | ((codePoint >> 12) & 0x3F))); + hasher.update((byte) (0x80 | ((codePoint >> 6) & 0x3F))); + hasher.update((byte) (0x80 | (codePoint & 0x3F))); + } else { + hasher.update((byte) '?'); + j--; + } + } else if (Character.isSurrogate(c)) { + hasher.update((byte) '?'); + } else { + hasher.update((byte) (0xE0 | (c >> 12))); + hasher.update((byte) (0x80 | ((c >> 6) & 0x3F))); + hasher.update((byte) (0x80 | (c & 0x3F))); + } + } + // Wire type code: type | (nullable ? 0x80 : 0) + byte wireType = (byte) (col.getType() | (col.nullable ? 0x80 : 0)); + hasher.update(wireType); + } + + return hasher.getValue(); + } + + /** + * Computes XXHash64 of direct memory with custom seed. + * + * @param address start address + * @param length number of bytes + * @param seed the hash seed + * @return the 64-bit hash value + */ + public static long hash(long address, long length, long seed) { + long h64; + long end = address + length; + long pos = address; + + if (length >= 32) { + long limit = end - 32; + long v1 = seed + PRIME64_1 + PRIME64_2; + long v2 = seed + PRIME64_2; + long v3 = seed; + long v4 = seed - PRIME64_1; + + do { + v1 = round(v1, Unsafe.getUnsafe().getLong(pos)); + pos += 8; + v2 = round(v2, Unsafe.getUnsafe().getLong(pos)); + pos += 8; + v3 = round(v3, Unsafe.getUnsafe().getLong(pos)); + pos += 8; + v4 = round(v4, Unsafe.getUnsafe().getLong(pos)); + pos += 8; + } while (pos <= limit); + + h64 = Long.rotateLeft(v1, 1) + Long.rotateLeft(v2, 7) + + Long.rotateLeft(v3, 12) + Long.rotateLeft(v4, 18); + h64 = mergeRound(h64, v1); + h64 = mergeRound(h64, v2); + h64 = mergeRound(h64, v3); + h64 = mergeRound(h64, v4); + } else { + h64 = seed + PRIME64_5; + } + + h64 += length; + + // Process remaining 8-byte blocks + while (pos + 8 <= end) { + long k1 = Unsafe.getUnsafe().getLong(pos); + k1 *= PRIME64_2; + k1 = Long.rotateLeft(k1, 31); + k1 *= PRIME64_1; + h64 ^= k1; + h64 = Long.rotateLeft(h64, 27) * PRIME64_1 + PRIME64_4; + pos += 8; + } + + // Process remaining 4-byte block + if (pos + 4 <= end) { + h64 ^= (Unsafe.getUnsafe().getInt(pos) & 0xFFFFFFFFL) * PRIME64_1; + h64 = Long.rotateLeft(h64, 23) * PRIME64_2 + PRIME64_3; + pos += 4; + } + + // Process remaining bytes + while (pos < end) { + h64 ^= (Unsafe.getUnsafe().getByte(pos) & 0xFFL) * PRIME64_5; + h64 = Long.rotateLeft(h64, 11) * PRIME64_1; + pos++; + } + + return avalanche(h64); + } + + /** + * Computes XXHash64 of a byte array. + * + * @param data the data to hash + * @return the 64-bit hash value + */ + public static long hash(byte[] data) { + return hash(data, 0, data.length, DEFAULT_SEED); + } + + /** + * Computes XXHash64 of a byte array region with custom seed. + * + * @param data the data to hash + * @param offset starting offset + * @param length number of bytes to hash + * @param seed the hash seed + * @return the 64-bit hash value + */ + public static long hash(byte[] data, int offset, int length, long seed) { + long h64; + int end = offset + length; + int pos = offset; + + if (length >= 32) { + int limit = end - 32; + long v1 = seed + PRIME64_1 + PRIME64_2; + long v2 = seed + PRIME64_2; + long v3 = seed; + long v4 = seed - PRIME64_1; + + do { + v1 = round(v1, getLong(data, pos)); + pos += 8; + v2 = round(v2, getLong(data, pos)); + pos += 8; + v3 = round(v3, getLong(data, pos)); + pos += 8; + v4 = round(v4, getLong(data, pos)); + pos += 8; + } while (pos <= limit); + + h64 = Long.rotateLeft(v1, 1) + Long.rotateLeft(v2, 7) + + Long.rotateLeft(v3, 12) + Long.rotateLeft(v4, 18); + h64 = mergeRound(h64, v1); + h64 = mergeRound(h64, v2); + h64 = mergeRound(h64, v3); + h64 = mergeRound(h64, v4); + } else { + h64 = seed + PRIME64_5; + } + + h64 += length; + + // Process remaining 8-byte blocks + while (pos + 8 <= end) { + long k1 = getLong(data, pos); + k1 *= PRIME64_2; + k1 = Long.rotateLeft(k1, 31); + k1 *= PRIME64_1; + h64 ^= k1; + h64 = Long.rotateLeft(h64, 27) * PRIME64_1 + PRIME64_4; + pos += 8; + } + + // Process remaining 4-byte block + if (pos + 4 <= end) { + h64 ^= (getInt(data, pos) & 0xFFFFFFFFL) * PRIME64_1; + h64 = Long.rotateLeft(h64, 23) * PRIME64_2 + PRIME64_3; + pos += 4; + } + + // Process remaining bytes + while (pos < end) { + h64 ^= (data[pos] & 0xFFL) * PRIME64_5; + h64 = Long.rotateLeft(h64, 11) * PRIME64_1; + pos++; + } + + return avalanche(h64); + } + + /** + * Computes XXHash64 of direct memory. + * + * @param address start address + * @param length number of bytes + * @return the 64-bit hash value + */ + public static long hash(long address, long length) { + return hash(address, length, DEFAULT_SEED); + } + + private static long avalanche(long h64) { + h64 ^= h64 >>> 33; + h64 *= PRIME64_2; + h64 ^= h64 >>> 29; + h64 *= PRIME64_3; + h64 ^= h64 >>> 32; + return h64; + } + + private static int getInt(byte[] data, int pos) { + return (data[pos] & 0xFF) | + ((data[pos + 1] & 0xFF) << 8) | + ((data[pos + 2] & 0xFF) << 16) | + ((data[pos + 3] & 0xFF) << 24); + } + + private static long getLong(byte[] data, int pos) { + return ((long) data[pos] & 0xFF) | + (((long) data[pos + 1] & 0xFF) << 8) | + (((long) data[pos + 2] & 0xFF) << 16) | + (((long) data[pos + 3] & 0xFF) << 24) | + (((long) data[pos + 4] & 0xFF) << 32) | + (((long) data[pos + 5] & 0xFF) << 40) | + (((long) data[pos + 6] & 0xFF) << 48) | + (((long) data[pos + 7] & 0xFF) << 56); + } + + private static long mergeRound(long acc, long val) { + val = round(0, val); + acc ^= val; + acc = acc * PRIME64_1 + PRIME64_4; + return acc; + } + + private static long round(long acc, long input) { + acc += input * PRIME64_2; + acc = Long.rotateLeft(acc, 31); + acc *= PRIME64_1; + return acc; + } + + /** + * Streaming hasher for incremental hash computation. + *

    + * This is useful when building the schema hash incrementally + * as columns are processed. + */ + public static class Hasher { + private final byte[] buffer = new byte[32]; + private int bufferPos; + private long seed; + private long totalLen; + private long v1, v2, v3, v4; + + public Hasher() { + reset(DEFAULT_SEED); + } + + /** + * Finalizes and returns the hash value. + * + * @return the 64-bit hash + */ + public long getValue() { + long h64; + + if (totalLen >= 32) { + h64 = Long.rotateLeft(v1, 1) + Long.rotateLeft(v2, 7) + + Long.rotateLeft(v3, 12) + Long.rotateLeft(v4, 18); + h64 = mergeRound(h64, v1); + h64 = mergeRound(h64, v2); + h64 = mergeRound(h64, v3); + h64 = mergeRound(h64, v4); + } else { + h64 = seed + PRIME64_5; + } + + h64 += totalLen; + + // Process buffered data + int pos = 0; + while (pos + 8 <= bufferPos) { + long k1 = getLong(buffer, pos); + k1 *= PRIME64_2; + k1 = Long.rotateLeft(k1, 31); + k1 *= PRIME64_1; + h64 ^= k1; + h64 = Long.rotateLeft(h64, 27) * PRIME64_1 + PRIME64_4; + pos += 8; + } + + if (pos + 4 <= bufferPos) { + h64 ^= (getInt(buffer, pos) & 0xFFFFFFFFL) * PRIME64_1; + h64 = Long.rotateLeft(h64, 23) * PRIME64_2 + PRIME64_3; + pos += 4; + } + + while (pos < bufferPos) { + h64 ^= (buffer[pos] & 0xFFL) * PRIME64_5; + h64 = Long.rotateLeft(h64, 11) * PRIME64_1; + pos++; + } + + return avalanche(h64); + } + + /** + * Resets the hasher with the given seed. + * + * @param seed the hash seed + */ + public void reset(long seed) { + this.seed = seed; + v1 = seed + PRIME64_1 + PRIME64_2; + v2 = seed + PRIME64_2; + v3 = seed; + v4 = seed - PRIME64_1; + totalLen = 0; + bufferPos = 0; + } + + /** + * Updates the hash with a byte array. + * + * @param data the bytes to add + */ + public void update(byte[] data) { + update(data, 0, data.length); + } + + /** + * Updates the hash with a byte array region. + * + * @param data the bytes to add + * @param offset starting offset + * @param length number of bytes + */ + public void update(byte[] data, int offset, int length) { + totalLen += length; + + // Fill buffer first + if (bufferPos > 0) { + int toCopy = Math.min(32 - bufferPos, length); + System.arraycopy(data, offset, buffer, bufferPos, toCopy); + bufferPos += toCopy; + offset += toCopy; + length -= toCopy; + + if (bufferPos == 32) { + processBuffer(); + } + } + + // Process 32-byte blocks directly + while (length >= 32) { + v1 = round(v1, getLong(data, offset)); + v2 = round(v2, getLong(data, offset + 8)); + v3 = round(v3, getLong(data, offset + 16)); + v4 = round(v4, getLong(data, offset + 24)); + offset += 32; + length -= 32; + } + + // Buffer remaining + if (length > 0) { + System.arraycopy(data, offset, buffer, 0, length); + bufferPos = length; + } + } + + /** + * Updates the hash with a single byte. + * + * @param b the byte to add + */ + public void update(byte b) { + buffer[bufferPos++] = b; + totalLen++; + + if (bufferPos == 32) { + processBuffer(); + } + } + + private void processBuffer() { + v1 = round(v1, getLong(buffer, 0)); + v2 = round(v2, getLong(buffer, 8)); + v3 = round(v3, getLong(buffer, 16)); + v4 = round(v4, getLong(buffer, 24)); + bufferPos = 0; + } + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpTableBuffer.java b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpTableBuffer.java new file mode 100644 index 0000000..f3d7fe1 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpTableBuffer.java @@ -0,0 +1,1284 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.protocol; + +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.cutlass.line.array.ArrayBufferAppender; +import io.questdb.client.cutlass.line.array.DoubleArray; +import io.questdb.client.cutlass.line.array.LongArray; +import io.questdb.client.std.CharSequenceIntHashMap; +import io.questdb.client.std.Decimal128; +import io.questdb.client.std.Decimal256; +import io.questdb.client.std.Decimal64; +import io.questdb.client.std.Decimals; +import io.questdb.client.std.MemoryTag; +import io.questdb.client.std.ObjList; +import io.questdb.client.std.QuietCloseable; +import io.questdb.client.std.Unsafe; +import io.questdb.client.std.Vect; + +import java.util.Arrays; + +import static io.questdb.client.cutlass.qwp.protocol.QwpConstants.*; + +/** + * Buffers rows for a single table in columnar format. + *

    + * Fixed-width column data is stored off-heap via {@link OffHeapAppendMemory} for zero-GC + * buffering and bulk copy to network buffers. Variable-width data (strings, symbol + * dictionaries, arrays) remains on-heap. + */ +public class QwpTableBuffer implements QuietCloseable { + + private final CharSequenceIntHashMap columnNameToIndex; + private final ObjList columns; + private final String tableName; + private QwpColumnDef[] cachedColumnDefs; + private int columnAccessCursor; // tracks expected next column index + private boolean columnDefsCacheValid; + private ColumnBuffer[] fastColumns; // plain array for O(1) sequential access + private int rowCount; + private long schemaHash; + private boolean schemaHashComputed; + + public QwpTableBuffer(String tableName) { + this.tableName = tableName; + this.columns = new ObjList<>(); + this.columnNameToIndex = new CharSequenceIntHashMap(); + this.rowCount = 0; + this.schemaHash = 0; + this.schemaHashComputed = false; + this.columnDefsCacheValid = false; + } + + /** + * Cancels the current in-progress row. + *

    + * This removes any column values added since the last {@link #nextRow()} call. + * If no values have been added for the current row, this is a no-op. + */ + public void cancelCurrentRow() { + // Reset sequential access cursor + columnAccessCursor = 0; + // Truncate each column back to the committed row count + for (int i = 0, n = columns.size(); i < n; i++) { + ColumnBuffer col = fastColumns[i]; + col.truncateTo(rowCount); + } + } + + /** + * Clears the buffer completely, including column definitions. + * Frees all off-heap memory. + */ + public void clear() { + for (int i = 0, n = columns.size(); i < n; i++) { + columns.get(i).close(); + } + columns.clear(); + columnNameToIndex.clear(); + fastColumns = null; + columnAccessCursor = 0; + rowCount = 0; + schemaHash = 0; + schemaHashComputed = false; + columnDefsCacheValid = false; + cachedColumnDefs = null; + } + + @Override + public void close() { + clear(); + } + + /** + * Returns the total bytes buffered across all columns. + * This queries actual buffer sizes, not estimates. + */ + public long getBufferedBytes() { + long bytes = 0; + for (int i = 0, n = columns.size(); i < n; i++) { + bytes += fastColumns[i].getBufferedBytes(); + } + return bytes; + } + + /** + * Returns the column at the given index. + */ + public ColumnBuffer getColumn(int index) { + return columns.get(index); + } + + /** + * Returns the number of columns. + */ + public int getColumnCount() { + return columns.size(); + } + + /** + * Returns the column definitions (cached for efficiency). + */ + public QwpColumnDef[] getColumnDefs() { + if (!columnDefsCacheValid || cachedColumnDefs == null || cachedColumnDefs.length != columns.size()) { + cachedColumnDefs = new QwpColumnDef[columns.size()]; + for (int i = 0; i < columns.size(); i++) { + ColumnBuffer col = columns.get(i); + cachedColumnDefs[i] = new QwpColumnDef(col.name, col.type, col.nullable); + } + columnDefsCacheValid = true; + } + return cachedColumnDefs; + } + + /** + * Gets or creates a column with the given name and type. + *

    + * Optimized for the common case where columns are accessed in the same + * order every row: a sequential cursor avoids hash map lookups entirely. + */ + public ColumnBuffer getOrCreateColumn(String name, byte type, boolean nullable) { + // Fast path: predict next column in sequence + int n = columns.size(); + if (columnAccessCursor < n) { + ColumnBuffer candidate = fastColumns[columnAccessCursor]; + if (candidate.name.equals(name)) { + columnAccessCursor++; + if (candidate.type != type) { + throw new LineSenderException( + "Column type mismatch for " + name + ": existing=" + candidate.type + " new=" + type + ); + } + return candidate; + } + } + + // Slow path: hash map lookup + int idx = columnNameToIndex.get(name); + if (idx != CharSequenceIntHashMap.NO_ENTRY_VALUE) { + ColumnBuffer existing = columns.get(idx); + if (existing.type != type) { + throw new LineSenderException( + "Column type mismatch for " + name + ": existing=" + existing.type + " new=" + type + ); + } + return existing; + } + + // Create new column + ColumnBuffer col = new ColumnBuffer(name, type, nullable); + int index = columns.size(); + columns.add(col); + columnNameToIndex.put(name, index); + // Update fast access array + if (fastColumns == null || index >= fastColumns.length) { + int newLen = Math.max(8, index + 4); + ColumnBuffer[] newArr = new ColumnBuffer[newLen]; + if (fastColumns != null) { + System.arraycopy(fastColumns, 0, newArr, 0, index); + } + fastColumns = newArr; + } + fastColumns[index] = col; + schemaHashComputed = false; + columnDefsCacheValid = false; + return col; + } + + /** + * Returns the number of rows buffered. + */ + public int getRowCount() { + return rowCount; + } + + /** + * Returns the schema hash for this table. + *

    + * The hash is computed to match what QwpSchema.computeSchemaHash() produces: + * - Uses wire type codes (with nullable bit) + * - Hash is over name bytes + type code for each column + */ + public long getSchemaHash() { + if (!schemaHashComputed) { + // Compute hash directly from column buffers without intermediate arrays + schemaHash = QwpSchemaHash.computeSchemaHashDirect(columns); + schemaHashComputed = true; + } + return schemaHash; + } + + /** + * Returns the table name. + */ + public String getTableName() { + return tableName; + } + + /** + * Advances to the next row. + *

    + * This should be called after all column values for the current row have been set. + */ + public void nextRow() { + // Reset sequential access cursor for the next row + columnAccessCursor = 0; + // Ensure all columns have the same row count + for (int i = 0, n = columns.size(); i < n; i++) { + ColumnBuffer col = fastColumns[i]; + // If column wasn't set for this row, add a null + while (col.size < rowCount + 1) { + col.addNull(); + } + } + rowCount++; + } + + /** + * Resets the buffer for reuse. Keeps column definitions and allocated memory. + */ + public void reset() { + for (int i = 0, n = columns.size(); i < n; i++) { + fastColumns[i].reset(); + } + columnAccessCursor = 0; + rowCount = 0; + } + + /** + * Returns the in-memory buffer element stride in bytes. This is the size used + * to store each value in the client's off-heap {@link OffHeapAppendMemory} buffer. + * This is different from element size on the wire. + *

    + * For example, BOOLEAN is stored as 1 byte per value here (for easy indexed access) + * but bit-packed on the wire; GEOHASH is stored as 8-byte longs here but uses + * variable-width encoding on the wire. + *

    + * Returns 0 for variable-width types (string, arrays) that do not use a fixed-stride + * data buffer. + * + * @see QwpConstants#getFixedTypeSize(byte) for wire-format sizes + */ + static int elementSizeInBuffer(byte type) { + return switch (type) { + case TYPE_BOOLEAN, TYPE_BYTE -> 1; + case TYPE_SHORT, TYPE_CHAR -> 2; + case TYPE_INT, TYPE_SYMBOL, TYPE_FLOAT -> 4; + case TYPE_GEOHASH, TYPE_LONG, TYPE_TIMESTAMP, TYPE_TIMESTAMP_NANOS, + TYPE_DATE, TYPE_DECIMAL64, TYPE_DOUBLE -> 8; + case TYPE_UUID, TYPE_DECIMAL128 -> 16; + case TYPE_LONG256, TYPE_DECIMAL256 -> 32; + default -> 0; + }; + } + + /** + * Helper class to capture array data from DoubleArray/LongArray.appendToBufPtr(). + */ + private static class ArrayCapture implements ArrayBufferAppender { + final int[] shape = new int[32]; + double[] doubleData; + int doubleDataOffset; + long[] longData; + int longDataOffset; + byte nDims; + private boolean forLong; + private int shapeIndex; + + @Override + public void putBlockOfBytes(long from, long len) { + int count = (int) (len / 8); + if (forLong) { + if (longData == null || longData.length < count) { + longData = new long[count]; + } + for (int i = 0; i < count; i++) { + longData[longDataOffset++] = Unsafe.getUnsafe().getLong(from + i * 8L); + } + } else { + if (doubleData == null || doubleData.length < count) { + doubleData = new double[count]; + } + for (int i = 0; i < count; i++) { + doubleData[doubleDataOffset++] = Unsafe.getUnsafe().getDouble(from + i * 8L); + } + } + } + + @Override + public void putByte(byte b) { + if (shapeIndex == 0) { + nDims = b; + } + } + + @Override + public void putDouble(double value) { + if (doubleData != null && doubleDataOffset < doubleData.length) { + doubleData[doubleDataOffset++] = value; + } + } + + @Override + public void putInt(int value) { + if (shapeIndex < nDims) { + shape[shapeIndex++] = value; + if (shapeIndex == nDims) { + int totalElements = 1; + for (int i = 0; i < nDims; i++) { + totalElements *= shape[i]; + } + if (forLong) { + if (longData == null || longData.length < totalElements) { + longData = new long[totalElements]; + } + } else { + if (doubleData == null || doubleData.length < totalElements) { + doubleData = new double[totalElements]; + } + } + } + } + } + + @Override + public void putLong(long value) { + if (longData != null && longDataOffset < longData.length) { + longData[longDataOffset++] = value; + } + } + + void reset(boolean forLong) { + this.forLong = forLong; + shapeIndex = 0; + nDims = 0; + doubleDataOffset = 0; + longDataOffset = 0; + } + } + + /** + * Column buffer for a single column. + *

    + * Fixed-width data is stored off-heap in {@link OffHeapAppendMemory} for zero-GC + * operation and efficient bulk copy to network buffers. + */ + public static class ColumnBuffer implements QuietCloseable { + final int elemSize; + final String name; + final boolean nullable; + final byte type; + private final Decimal256 rescaleTemp = new Decimal256(); + private ArrayCapture arrayCapture; + private int arrayDataOffset; + // Array storage (double/long arrays - variable length per row) + private byte[] arrayDims; + private int arrayShapeOffset; + private int[] arrayShapes; + // Off-heap auxiliary buffer for global symbol IDs (SYMBOL type only) + private OffHeapAppendMemory auxBuffer; + // Off-heap data buffer for fixed-width types + private OffHeapAppendMemory dataBuffer; + // Decimal storage + private byte decimalScale = -1; + private double[] doubleArrayData; + // GeoHash precision (number of bits, 1-60) + private int geohashPrecision = -1; + private boolean hasNulls; + private long[] longArrayData; + private int maxGlobalSymbolId = -1; + private int nullBufCapRows; + // Off-heap null bitmap (bit-packed, 1 bit per row) + private long nullBufPtr; + private int size; // Total row count (including nulls) + private OffHeapAppendMemory stringData; + // Off-heap storage for string/varchar column data + private OffHeapAppendMemory stringOffsets; + // Symbol specific (dictionary stays on-heap) + private CharSequenceIntHashMap symbolDict; + private ObjList symbolList; + private int valueCount; // Actual stored values (excludes nulls) + + public ColumnBuffer(String name, byte type, boolean nullable) { + this.name = name; + this.type = type; + this.nullable = nullable; + this.elemSize = elementSizeInBuffer(type); + this.size = 0; + this.valueCount = 0; + this.hasNulls = false; + + try { + allocateStorage(type); + if (nullable) { + nullBufCapRows = 64; // multiple of 64 + long sizeBytes = (long) nullBufCapRows >>> 3; + nullBufPtr = Unsafe.calloc(sizeBytes, MemoryTag.NATIVE_ILP_RSS); + } + } catch (Throwable t) { + close(); + throw t; + } + } + + public void addBoolean(boolean value) { + ensureNullBitmapForNonNull(); + dataBuffer.putByte(value ? (byte) 1 : (byte) 0); + valueCount++; + size++; + } + + public void addByte(byte value) { + ensureNullBitmapForNonNull(); + dataBuffer.putByte(value); + valueCount++; + size++; + } + + public void addDecimal128(Decimal128 value) { + if (value == null || value.isNull()) { + addNull(); + return; + } + ensureNullBitmapForNonNull(); + if (decimalScale == -1) { + decimalScale = (byte) value.getScale(); + } else if (decimalScale != value.getScale()) { + rescaleTemp.ofRaw(value.getHigh(), value.getLow()); + rescaleTemp.setScale(value.getScale()); + rescaleTemp.rescale(decimalScale); + if (!rescaleTemp.fitsInStorageSizePow2(4)) { + throw new LineSenderException("Decimal128 overflow: rescaling from scale " + + value.getScale() + " to " + decimalScale + " exceeds 128-bit capacity"); + } + dataBuffer.putLong(rescaleTemp.getLh()); + dataBuffer.putLong(rescaleTemp.getLl()); + valueCount++; + size++; + return; + } + dataBuffer.putLong(value.getHigh()); + dataBuffer.putLong(value.getLow()); + valueCount++; + size++; + } + + public void addDecimal256(Decimal256 value) { + if (value == null || value.isNull()) { + addNull(); + return; + } + ensureNullBitmapForNonNull(); + Decimal256 src = value; + if (decimalScale == -1) { + decimalScale = (byte) value.getScale(); + } else if (decimalScale != value.getScale()) { + rescaleTemp.copyFrom(value); + rescaleTemp.rescale(decimalScale); + src = rescaleTemp; + } + dataBuffer.putLong(src.getHh()); + dataBuffer.putLong(src.getHl()); + dataBuffer.putLong(src.getLh()); + dataBuffer.putLong(src.getLl()); + valueCount++; + size++; + } + + public void addDecimal64(Decimal64 value) { + if (value == null || value.isNull()) { + addNull(); + return; + } + ensureNullBitmapForNonNull(); + if (decimalScale == -1) { + decimalScale = (byte) value.getScale(); + dataBuffer.putLong(value.getValue()); + } else if (decimalScale != value.getScale()) { + rescaleTemp.ofRaw(value.getValue()); + rescaleTemp.setScale(value.getScale()); + rescaleTemp.rescale(decimalScale); + if (!rescaleTemp.fitsInStorageSizePow2(3)) { + throw new LineSenderException("Decimal64 overflow: rescaling from scale " + + value.getScale() + " to " + decimalScale + " exceeds 64-bit capacity"); + } + dataBuffer.putLong(rescaleTemp.getLl()); + } else { + dataBuffer.putLong(value.getValue()); + } + valueCount++; + size++; + } + + public void addDouble(double value) { + ensureNullBitmapForNonNull(); + dataBuffer.putDouble(value); + valueCount++; + size++; + } + + public void addDoubleArray(double[] values) { + if (values == null) { + addNull(); + return; + } + ensureArrayCapacity(1, values.length); + arrayDims[valueCount] = 1; + arrayShapes[arrayShapeOffset++] = values.length; + for (double v : values) { + doubleArrayData[arrayDataOffset++] = v; + } + valueCount++; + size++; + } + + public void addDoubleArray(double[][] values) { + if (values == null) { + addNull(); + return; + } + int dim0 = values.length; + int dim1 = dim0 > 0 ? values[0].length : 0; + for (int i = 1; i < dim0; i++) { + if (values[i].length != dim1) { + throw new LineSenderException("irregular array shape"); + } + } + int elemCount = checkedElementCount((long) dim0 * dim1); + ensureArrayCapacity(2, elemCount); + arrayDims[valueCount] = 2; + arrayShapes[arrayShapeOffset++] = dim0; + arrayShapes[arrayShapeOffset++] = dim1; + for (double[] row : values) { + for (double v : row) { + doubleArrayData[arrayDataOffset++] = v; + } + } + valueCount++; + size++; + } + + public void addDoubleArray(double[][][] values) { + if (values == null) { + addNull(); + return; + } + int dim0 = values.length; + int dim1 = dim0 > 0 ? values[0].length : 0; + int dim2 = dim0 > 0 && dim1 > 0 ? values[0][0].length : 0; + for (int i = 0; i < dim0; i++) { + if (values[i].length != dim1) { + throw new LineSenderException("irregular array shape"); + } + for (int j = 0; j < dim1; j++) { + if (values[i][j].length != dim2) { + throw new LineSenderException("irregular array shape"); + } + } + } + int elemCount = checkedElementCount((long) dim0 * dim1 * dim2); + ensureArrayCapacity(3, elemCount); + arrayDims[valueCount] = 3; + arrayShapes[arrayShapeOffset++] = dim0; + arrayShapes[arrayShapeOffset++] = dim1; + arrayShapes[arrayShapeOffset++] = dim2; + for (double[][] plane : values) { + for (double[] row : plane) { + for (double v : row) { + doubleArrayData[arrayDataOffset++] = v; + } + } + } + valueCount++; + size++; + } + + public void addDoubleArray(DoubleArray array) { + if (array == null) { + addNull(); + return; + } + arrayCapture.reset(false); + array.appendToBufPtr(arrayCapture); + + ensureArrayCapacity(arrayCapture.nDims, arrayCapture.doubleDataOffset); + arrayDims[valueCount] = arrayCapture.nDims; + for (int i = 0; i < arrayCapture.nDims; i++) { + arrayShapes[arrayShapeOffset++] = arrayCapture.shape[i]; + } + for (int i = 0; i < arrayCapture.doubleDataOffset; i++) { + doubleArrayData[arrayDataOffset++] = arrayCapture.doubleData[i]; + } + valueCount++; + size++; + } + + public void addFloat(float value) { + ensureNullBitmapForNonNull(); + dataBuffer.putFloat(value); + valueCount++; + size++; + } + + /** + * Adds a geohash value with the given precision. + * + * @param value the geohash value (bit-packed) + * @param precision number of bits (1-60) + */ + public void addGeoHash(long value, int precision) { + if (precision < 1 || precision > 60) { + throw new LineSenderException("invalid GeoHash precision: " + precision + " (must be 1-60)"); + } + if (geohashPrecision == -1) { + geohashPrecision = precision; + } else if (geohashPrecision != precision) { + throw new LineSenderException( + "GeoHash precision mismatch: column has " + geohashPrecision + " bits, got " + precision + ); + } + ensureNullBitmapForNonNull(); + dataBuffer.putLong(value); + valueCount++; + size++; + } + + public void addInt(int value) { + ensureNullBitmapForNonNull(); + dataBuffer.putInt(value); + valueCount++; + size++; + } + + public void addLong(long value) { + ensureNullBitmapForNonNull(); + dataBuffer.putLong(value); + valueCount++; + size++; + } + + public void addLong256(long l0, long l1, long l2, long l3) { + ensureNullBitmapForNonNull(); + dataBuffer.putLong(l0); + dataBuffer.putLong(l1); + dataBuffer.putLong(l2); + dataBuffer.putLong(l3); + valueCount++; + size++; + } + + public void addLongArray(long[] values) { + if (values == null) { + addNull(); + return; + } + ensureArrayCapacity(1, values.length); + arrayDims[valueCount] = 1; + arrayShapes[arrayShapeOffset++] = values.length; + for (long v : values) { + longArrayData[arrayDataOffset++] = v; + } + valueCount++; + size++; + } + + public void addLongArray(long[][] values) { + if (values == null) { + addNull(); + return; + } + int dim0 = values.length; + int dim1 = dim0 > 0 ? values[0].length : 0; + for (int i = 1; i < dim0; i++) { + if (values[i].length != dim1) { + throw new LineSenderException("irregular array shape"); + } + } + int elemCount = checkedElementCount((long) dim0 * dim1); + ensureArrayCapacity(2, elemCount); + arrayDims[valueCount] = 2; + arrayShapes[arrayShapeOffset++] = dim0; + arrayShapes[arrayShapeOffset++] = dim1; + for (long[] row : values) { + for (long v : row) { + longArrayData[arrayDataOffset++] = v; + } + } + valueCount++; + size++; + } + + public void addLongArray(long[][][] values) { + if (values == null) { + addNull(); + return; + } + int dim0 = values.length; + int dim1 = dim0 > 0 ? values[0].length : 0; + int dim2 = dim0 > 0 && dim1 > 0 ? values[0][0].length : 0; + for (int i = 0; i < dim0; i++) { + if (values[i].length != dim1) { + throw new LineSenderException("irregular array shape"); + } + for (int j = 0; j < dim1; j++) { + if (values[i][j].length != dim2) { + throw new LineSenderException("irregular array shape"); + } + } + } + int elemCount = checkedElementCount((long) dim0 * dim1 * dim2); + ensureArrayCapacity(3, elemCount); + arrayDims[valueCount] = 3; + arrayShapes[arrayShapeOffset++] = dim0; + arrayShapes[arrayShapeOffset++] = dim1; + arrayShapes[arrayShapeOffset++] = dim2; + for (long[][] plane : values) { + for (long[] row : plane) { + for (long v : row) { + longArrayData[arrayDataOffset++] = v; + } + } + } + valueCount++; + size++; + } + + public void addLongArray(LongArray array) { + if (array == null) { + addNull(); + return; + } + arrayCapture.reset(true); + array.appendToBufPtr(arrayCapture); + + ensureArrayCapacity(arrayCapture.nDims, arrayCapture.longDataOffset); + arrayDims[valueCount] = arrayCapture.nDims; + for (int i = 0; i < arrayCapture.nDims; i++) { + arrayShapes[arrayShapeOffset++] = arrayCapture.shape[i]; + } + for (int i = 0; i < arrayCapture.longDataOffset; i++) { + longArrayData[arrayDataOffset++] = arrayCapture.longData[i]; + } + valueCount++; + size++; + } + + public void addNull() { + if (nullable) { + ensureNullCapacity(size + 1); + markNull(size); + } else { + // For non-nullable columns, store a sentinel/default value + switch (type) { + case TYPE_BOOLEAN, TYPE_BYTE -> dataBuffer.putByte((byte) 0); + case TYPE_SHORT, TYPE_CHAR -> dataBuffer.putShort((short) 0); + case TYPE_INT -> dataBuffer.putInt(0); + case TYPE_GEOHASH -> dataBuffer.putLong(-1L); + case TYPE_LONG, TYPE_TIMESTAMP, TYPE_TIMESTAMP_NANOS, TYPE_DATE -> + dataBuffer.putLong(Long.MIN_VALUE); + case TYPE_FLOAT -> dataBuffer.putFloat(Float.NaN); + case TYPE_DOUBLE -> dataBuffer.putDouble(Double.NaN); + case TYPE_STRING, TYPE_VARCHAR -> stringOffsets.putInt((int) stringData.getAppendOffset()); + case TYPE_SYMBOL -> dataBuffer.putInt(-1); + case TYPE_UUID -> { + dataBuffer.putLong(Long.MIN_VALUE); + dataBuffer.putLong(Long.MIN_VALUE); + } + case TYPE_LONG256 -> { + dataBuffer.putLong(Long.MIN_VALUE); + dataBuffer.putLong(Long.MIN_VALUE); + dataBuffer.putLong(Long.MIN_VALUE); + dataBuffer.putLong(Long.MIN_VALUE); + } + case TYPE_DECIMAL64 -> dataBuffer.putLong(Decimals.DECIMAL64_NULL); + case TYPE_DECIMAL128 -> { + dataBuffer.putLong(Decimals.DECIMAL128_HI_NULL); + dataBuffer.putLong(Decimals.DECIMAL128_LO_NULL); + } + case TYPE_DECIMAL256 -> { + dataBuffer.putLong(Decimals.DECIMAL256_HH_NULL); + dataBuffer.putLong(Decimals.DECIMAL256_HL_NULL); + dataBuffer.putLong(Decimals.DECIMAL256_LH_NULL); + dataBuffer.putLong(Decimals.DECIMAL256_LL_NULL); + } + case TYPE_DOUBLE_ARRAY, TYPE_LONG_ARRAY -> { + ensureArrayCapacity(1, 0); + arrayDims[valueCount] = 1; + arrayShapes[arrayShapeOffset++] = 0; + } + } + valueCount++; + } + size++; + } + + public void addShort(short value) { + ensureNullBitmapForNonNull(); + dataBuffer.putShort(value); + valueCount++; + size++; + } + + public void addString(String value) { + if (value == null && nullable) { + ensureNullCapacity(size + 1); + markNull(size); + } else { + ensureNullBitmapForNonNull(); + if (value != null) { + stringData.putUtf8(value); + } + stringOffsets.putInt((int) stringData.getAppendOffset()); + valueCount++; + } + size++; + } + + public void addSymbol(String value) { + if (value == null) { + addNull(); + return; + } + ensureNullBitmapForNonNull(); + int idx = symbolDict.get(value); + if (idx == CharSequenceIntHashMap.NO_ENTRY_VALUE) { + idx = symbolList.size(); + symbolDict.put(value, idx); + symbolList.add(value); + } + dataBuffer.putInt(idx); + valueCount++; + size++; + } + + public void addSymbolWithGlobalId(String value, int globalId) { + if (value == null) { + addNull(); + return; + } + ensureNullBitmapForNonNull(); + int localIdx = symbolDict.get(value); + if (localIdx == CharSequenceIntHashMap.NO_ENTRY_VALUE) { + localIdx = symbolList.size(); + symbolDict.put(value, localIdx); + symbolList.add(value); + } + dataBuffer.putInt(localIdx); + + if (auxBuffer == null) { + auxBuffer = new OffHeapAppendMemory(64); + } + auxBuffer.putInt(globalId); + + if (globalId > maxGlobalSymbolId) { + maxGlobalSymbolId = globalId; + } + + valueCount++; + size++; + } + + public void addUuid(long high, long low) { + ensureNullBitmapForNonNull(); + // Store in wire order: lo first, hi second + dataBuffer.putLong(low); + dataBuffer.putLong(high); + valueCount++; + size++; + } + + @Override + public void close() { + if (dataBuffer != null) { + dataBuffer.close(); + dataBuffer = null; + } + if (auxBuffer != null) { + auxBuffer.close(); + auxBuffer = null; + } + if (stringOffsets != null) { + stringOffsets.close(); + stringOffsets = null; + } + if (stringData != null) { + stringData.close(); + stringData = null; + } + if (nullBufPtr != 0) { + Unsafe.free(nullBufPtr, (long) nullBufCapRows >>> 3, MemoryTag.NATIVE_ILP_RSS); + nullBufPtr = 0; + nullBufCapRows = 0; + } + } + + public byte[] getArrayDims() { + return arrayDims; + } + + public int[] getArrayShapes() { + return arrayShapes; + } + + /** + * Returns the total bytes buffered in this column's storage. + */ + public long getBufferedBytes() { + long bytes = 0; + if (dataBuffer != null) { + bytes += dataBuffer.getAppendOffset(); + } + if (auxBuffer != null) { + bytes += auxBuffer.getAppendOffset(); + } + if (stringData != null) { + bytes += stringData.getAppendOffset(); + } + if (stringOffsets != null) { + bytes += stringOffsets.getAppendOffset(); + } + if (doubleArrayData != null) { + bytes += (long) arrayDataOffset * Double.BYTES; + } + if (longArrayData != null) { + bytes += (long) arrayDataOffset * Long.BYTES; + } + return bytes; + } + + /** + * Returns the off-heap address of the auxiliary data buffer (global symbol IDs). + * Returns 0 if no auxiliary data exists. + */ + public long getAuxDataAddress() { + return auxBuffer != null ? auxBuffer.pageAddress() : 0; + } + + /** + * Returns the off-heap address of the column data buffer. + */ + public long getDataAddress() { + return dataBuffer != null ? dataBuffer.pageAddress() : 0; + } + + public byte getDecimalScale() { + return decimalScale; + } + + public double[] getDoubleArrayData() { + return doubleArrayData; + } + + public int getGeoHashPrecision() { + return geohashPrecision; + } + + public long[] getLongArrayData() { + return longArrayData; + } + + public int getMaxGlobalSymbolId() { + return maxGlobalSymbolId; + } + + public String getName() { + return name; + } + + /** + * Returns the off-heap address of the null bitmap. + * Returns 0 for non-nullable columns. + */ + public long getNullBitmapAddress() { + return nullBufPtr; + } + + public int getSize() { + return size; + } + + public long getStringDataAddress() { + return stringData != null ? stringData.pageAddress() : 0; + } + + public long getStringDataSize() { + return stringData != null ? stringData.getAppendOffset() : 0; + } + + public long getStringOffsetsAddress() { + return stringOffsets != null ? stringOffsets.pageAddress() : 0; + } + + public String[] getSymbolDictionary() { + if (symbolList == null) { + return new String[0]; + } + String[] dict = new String[symbolList.size()]; + for (int i = 0; i < symbolList.size(); i++) { + dict[i] = symbolList.get(i); + } + return dict; + } + + public byte getType() { + return type; + } + + public int getValueCount() { + return valueCount; + } + + public boolean isNull(int index) { + if (nullBufPtr == 0) { + return false; + } + long longAddr = nullBufPtr + ((long) (index >>> 6)) * 8; + int bitIndex = index & 63; + return (Unsafe.getUnsafe().getLong(longAddr) & (1L << bitIndex)) != 0; + } + + public void reset() { + size = 0; + valueCount = 0; + hasNulls = false; + if (dataBuffer != null) { + dataBuffer.truncate(); + } + if (auxBuffer != null) { + auxBuffer.truncate(); + } + if (stringOffsets != null) { + stringOffsets.truncate(); + stringOffsets.putInt(0); // re-seed initial 0 offset + } + if (stringData != null) { + stringData.truncate(); + } + if (nullBufPtr != 0) { + Vect.memset(nullBufPtr, (long) nullBufCapRows >>> 3, 0); + } + if (symbolDict != null) { + symbolDict.clear(); + symbolList.clear(); + } + maxGlobalSymbolId = -1; + arrayShapeOffset = 0; + arrayDataOffset = 0; + decimalScale = -1; + geohashPrecision = -1; + } + + public void truncateTo(int newSize) { + if (newSize >= size) { + return; + } + + int newValueCount = 0; + if (nullable && nullBufPtr != 0) { + for (int i = 0; i < newSize; i++) { + if (!isNull(i)) { + newValueCount++; + } + } + // Clear null bits for truncated rows + for (int i = newSize; i < size; i++) { + long longAddr = nullBufPtr + ((long) (i >>> 6)) * 8; + int bitIndex = i & 63; + long current = Unsafe.getUnsafe().getLong(longAddr); + Unsafe.getUnsafe().putLong(longAddr, current & ~(1L << bitIndex)); + } + hasNulls = false; + for (int i = 0; i < newSize && !hasNulls; i++) { + if (isNull(i)) { + hasNulls = true; + } + } + } else { + newValueCount = newSize; + } + + size = newSize; + valueCount = newValueCount; + + // Rewind off-heap data buffer + if (dataBuffer != null && elemSize > 0) { + dataBuffer.jumpTo((long) newValueCount * elemSize); + } + + // Rewind string buffers + if (stringOffsets != null) { + int dataOffset = Unsafe.getUnsafe().getInt(stringOffsets.pageAddress() + (long) newValueCount * 4); + stringData.jumpTo(dataOffset); + stringOffsets.jumpTo((long) (newValueCount + 1) * 4); + } + + // Rewind aux buffer (symbol global IDs) + if (auxBuffer != null) { + auxBuffer.jumpTo((long) newValueCount * 4); + } + + // Rewind array offsets by walking the retained values + if (arrayDims != null) { + int newShapeOffset = 0; + int newDataOffset = 0; + for (int i = 0; i < newValueCount; i++) { + int nDims = arrayDims[i]; + int elemCount = 1; + for (int d = 0; d < nDims; d++) { + elemCount *= arrayShapes[newShapeOffset++]; + } + newDataOffset += elemCount; + } + arrayShapeOffset = newShapeOffset; + arrayDataOffset = newDataOffset; + } + } + + private static int checkedElementCount(long product) { + if (product > Integer.MAX_VALUE) { + throw new LineSenderException("array too large: total element count exceeds int range"); + } + return (int) product; + } + + private void allocateStorage(byte type) { + switch (type) { + case TYPE_BOOLEAN: + case TYPE_BYTE: + dataBuffer = new OffHeapAppendMemory(16); + break; + case TYPE_SHORT: + case TYPE_CHAR: + dataBuffer = new OffHeapAppendMemory(32); + break; + case TYPE_INT: + case TYPE_FLOAT: + dataBuffer = new OffHeapAppendMemory(64); + break; + case TYPE_GEOHASH: + case TYPE_LONG: + case TYPE_TIMESTAMP: + case TYPE_TIMESTAMP_NANOS: + case TYPE_DATE: + case TYPE_DECIMAL64: + case TYPE_DOUBLE: + dataBuffer = new OffHeapAppendMemory(128); + break; + case TYPE_UUID: + case TYPE_DECIMAL128: + dataBuffer = new OffHeapAppendMemory(256); + break; + case TYPE_LONG256: + case TYPE_DECIMAL256: + dataBuffer = new OffHeapAppendMemory(512); + break; + case TYPE_STRING: + case TYPE_VARCHAR: + stringOffsets = new OffHeapAppendMemory(64); + try { + stringOffsets.putInt(0); // seed initial 0 offset + stringData = new OffHeapAppendMemory(256); + } catch (Throwable t) { + stringOffsets.close(); + stringOffsets = null; + throw t; + } + break; + case TYPE_SYMBOL: + dataBuffer = new OffHeapAppendMemory(64); + symbolDict = new CharSequenceIntHashMap(); + symbolList = new ObjList<>(); + break; + case TYPE_DOUBLE_ARRAY: + case TYPE_LONG_ARRAY: + arrayDims = new byte[16]; + arrayCapture = new ArrayCapture(); + break; + } + } + + private void ensureArrayCapacity(int nDims, int dataElements) { + // Ensure per-row array dims capacity + if (valueCount >= arrayDims.length) { + arrayDims = Arrays.copyOf(arrayDims, arrayDims.length * 2); + } + + // Ensure null bitmap capacity + if (nullable) { + ensureNullCapacity(size + 1); + } + + // Ensure shape array capacity + int requiredShapeCapacity = arrayShapeOffset + nDims; + if (arrayShapes == null) { + arrayShapes = new int[Math.max(64, requiredShapeCapacity)]; + } else if (requiredShapeCapacity > arrayShapes.length) { + arrayShapes = Arrays.copyOf(arrayShapes, Math.max(arrayShapes.length * 2, requiredShapeCapacity)); + } + + // Ensure data array capacity + int requiredDataCapacity = arrayDataOffset + dataElements; + if (type == TYPE_DOUBLE_ARRAY) { + if (doubleArrayData == null) { + doubleArrayData = new double[Math.max(256, requiredDataCapacity)]; + } else if (requiredDataCapacity > doubleArrayData.length) { + doubleArrayData = Arrays.copyOf(doubleArrayData, Math.max(doubleArrayData.length * 2, requiredDataCapacity)); + } + } else if (type == TYPE_LONG_ARRAY) { + if (longArrayData == null) { + longArrayData = new long[Math.max(256, requiredDataCapacity)]; + } else if (requiredDataCapacity > longArrayData.length) { + longArrayData = Arrays.copyOf(longArrayData, Math.max(longArrayData.length * 2, requiredDataCapacity)); + } + } + } + + private void ensureNullBitmapForNonNull() { + if (nullBufPtr != 0) { + ensureNullCapacity(size + 1); + } + } + + private void ensureNullCapacity(int rows) { + if (rows > nullBufCapRows) { + int newCapRows = Math.max(nullBufCapRows * 2, ((rows + 63) >>> 6) << 6); + long newSizeBytes = (long) newCapRows >>> 3; + long oldSizeBytes = (long) nullBufCapRows >>> 3; + nullBufPtr = Unsafe.realloc(nullBufPtr, oldSizeBytes, newSizeBytes, MemoryTag.NATIVE_ILP_RSS); + Vect.memset(nullBufPtr + oldSizeBytes, newSizeBytes - oldSizeBytes, 0); + nullBufCapRows = newCapRows; + } + } + + private void markNull(int index) { + long longAddr = nullBufPtr + ((long) (index >>> 6)) * 8; + int bitIndex = index & 63; + long current = Unsafe.getUnsafe().getLong(longAddr); + Unsafe.getUnsafe().putLong(longAddr, current | (1L << bitIndex)); + hasNulls = true; + } + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/websocket/WebSocketCloseCode.java b/core/src/main/java/io/questdb/client/cutlass/qwp/websocket/WebSocketCloseCode.java new file mode 100644 index 0000000..6ee2071 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/websocket/WebSocketCloseCode.java @@ -0,0 +1,135 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.websocket; + +/** + * WebSocket close status codes as defined in RFC 6455. + */ +public final class WebSocketCloseCode { + /** + * Abnormal closure (1006). + * Reserved value. MUST NOT be sent in a Close frame. + * Used to indicate that a connection was closed abnormally. + */ + public static final int ABNORMAL_CLOSURE = 1006; + /** + * Going away (1001). + * The endpoint is going away, e.g., server shutting down or browser navigating away. + */ + public static final int GOING_AWAY = 1001; + /** + * Internal server error (1011). + * The server encountered an unexpected condition that prevented it from fulfilling the request. + */ + public static final int INTERNAL_ERROR = 1011; + /** + * Invalid frame payload data (1007). + * The endpoint received a message with invalid payload data. + */ + public static final int INVALID_PAYLOAD_DATA = 1007; + /** + * Mandatory extension (1010). + * The client expected the server to negotiate one or more extensions. + */ + public static final int MANDATORY_EXTENSION = 1010; + /** + * Message too big (1009). + * The endpoint received a message that is too big to process. + */ + public static final int MESSAGE_TOO_BIG = 1009; + /** + * Normal closure (1000). + * The connection successfully completed whatever purpose for which it was created. + */ + public static final int NORMAL_CLOSURE = 1000; + /** + * No status received (1005). + * Reserved value. MUST NOT be sent in a Close frame. + */ + public static final int NO_STATUS_RECEIVED = 1005; + /** + * Policy violation (1008). + * The endpoint received a message that violates its policy. + */ + public static final int POLICY_VIOLATION = 1008; + /** + * Protocol error (1002). + * The endpoint is terminating the connection due to a protocol error. + */ + public static final int PROTOCOL_ERROR = 1002; + /** + * Reserved (1004). + * Reserved for future use. + */ + public static final int RESERVED = 1004; + /** + * TLS handshake (1015). + * Reserved value. MUST NOT be sent in a Close frame. + * Used to indicate that the connection was closed due to TLS handshake failure. + */ + public static final int TLS_HANDSHAKE = 1015; + /** + * Unsupported data (1003). + * The endpoint received a type of data it cannot accept. + */ + public static final int UNSUPPORTED_DATA = 1003; + + private WebSocketCloseCode() { + // Constants class + } + + /** + * Returns a human-readable description of the close code. + * + * @param code the close code + * @return the description + */ + public static String describe(int code) { + return switch (code) { + case NORMAL_CLOSURE -> "Normal Closure"; + case GOING_AWAY -> "Going Away"; + case PROTOCOL_ERROR -> "Protocol Error"; + case UNSUPPORTED_DATA -> "Unsupported Data"; + case RESERVED -> "Reserved"; + case NO_STATUS_RECEIVED -> "No Status Received"; + case ABNORMAL_CLOSURE -> "Abnormal Closure"; + case INVALID_PAYLOAD_DATA -> "Invalid Payload Data"; + case POLICY_VIOLATION -> "Policy Violation"; + case MESSAGE_TOO_BIG -> "Message Too Big"; + case MANDATORY_EXTENSION -> "Mandatory Extension"; + case INTERNAL_ERROR -> "Internal Error"; + case TLS_HANDSHAKE -> "TLS Handshake"; + default -> { + if (code >= 3000 && code < 4000) { + yield "Library/Framework Code (" + code + ")"; + } else if (code >= 4000 && code < 5000) { + yield "Application Code (" + code + ")"; + } + yield "Unknown (" + code + ")"; + } + }; + } + +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/websocket/WebSocketFrameParser.java b/core/src/main/java/io/questdb/client/cutlass/qwp/websocket/WebSocketFrameParser.java new file mode 100644 index 0000000..ffd8a37 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/websocket/WebSocketFrameParser.java @@ -0,0 +1,284 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.websocket; + +import io.questdb.client.std.Unsafe; + +/** + * Zero-allocation WebSocket frame parser. + * Parses WebSocket frames according to RFC 6455. + * + *

    The parser operates on raw memory buffers and maintains minimal state. + * It can parse frames incrementally when data arrives in chunks. + * + *

    Thread safety: This class is NOT thread-safe. Each connection should + * have its own parser instance. + */ +public class WebSocketFrameParser { + /** + * Frame completely parsed. + */ + public static final int STATE_COMPLETE = 3; + /** + * Error state - frame is invalid. + */ + public static final int STATE_ERROR = 4; + /** + * Initial state, waiting for frame header. + */ + public static final int STATE_HEADER = 0; + /** + * Need more data to complete parsing. + */ + public static final int STATE_NEED_MORE = 1; + /** + * Header parsed, need payload data. + */ + public static final int STATE_NEED_PAYLOAD = 2; + // Frame header bits + private static final int FIN_BIT = 0x80; + private static final int LENGTH_MASK = 0x7F; + private static final int MASK_BIT = 0x80; + // Control frame max payload size (RFC 6455) + private static final int MAX_CONTROL_FRAME_PAYLOAD = 125; + private static final int OPCODE_MASK = 0x0F; + private static final int RSV_BITS = 0x70; + private int errorCode; + // Parsed frame data + private boolean fin; + private int headerSize; + private int maskKey; + private boolean masked; + private int opcode; + private long payloadLength; + // Parser state + private int state = STATE_HEADER; + + public int getErrorCode() { + return errorCode; + } + + public int getHeaderSize() { + return headerSize; + } + + public int getOpcode() { + return opcode; + } + + public long getPayloadLength() { + return payloadLength; + } + + public int getState() { + return state; + } + + public boolean isFin() { + return fin; + } + + public boolean isMasked() { + return masked; + } + + /** + * Parses a WebSocket frame from the given buffer. + * + * @param buf the start of the buffer + * @param limit the end of the buffer (exclusive) + * @return the number of bytes consumed, or 0 if more data is needed + */ + public int parse(long buf, long limit) { + long available = limit - buf; + + if (available < 2) { + state = STATE_NEED_MORE; + return 0; + } + + // Parse first two bytes + int byte0 = Unsafe.getUnsafe().getByte(buf) & 0xFF; + int byte1 = Unsafe.getUnsafe().getByte(buf + 1) & 0xFF; + + // Check reserved bits (must be 0 unless extension negotiated) + if ((byte0 & RSV_BITS) != 0) { + state = STATE_ERROR; + errorCode = WebSocketCloseCode.PROTOCOL_ERROR; + return 0; + } + + fin = (byte0 & FIN_BIT) != 0; + opcode = byte0 & OPCODE_MASK; + + // Validate opcode + if (!WebSocketOpcode.isValid(opcode)) { + state = STATE_ERROR; + errorCode = WebSocketCloseCode.PROTOCOL_ERROR; + return 0; + } + + // Control frames must not be fragmented + if (WebSocketOpcode.isControlFrame(opcode) && !fin) { + state = STATE_ERROR; + errorCode = WebSocketCloseCode.PROTOCOL_ERROR; + return 0; + } + + final boolean masked = (byte1 & MASK_BIT) != 0; + this.masked = masked; + int lengthField = byte1 & LENGTH_MASK; + + // Validate masking based on mode + // Configuration + // If true, expect masked frames from clients + if (masked) { + // Server frames MUST NOT be masked + state = STATE_ERROR; + errorCode = WebSocketCloseCode.PROTOCOL_ERROR; + return 0; + } + + // Calculate header size and payload length + int offset = 2; + + // If true, reject non-minimal length encodings + if (lengthField <= 125) { + payloadLength = lengthField; + } else if (lengthField == 126) { + // 16-bit extended length + if (available < 4) { + state = STATE_NEED_MORE; + return 0; + } + int high = Unsafe.getUnsafe().getByte(buf + 2) & 0xFF; + int low = Unsafe.getUnsafe().getByte(buf + 3) & 0xFF; + payloadLength = (high << 8) | low; + + // Strict mode: reject non-minimal encodings + + offset = 4; + } else { + // 64-bit extended length + if (available < 10) { + state = STATE_NEED_MORE; + return 0; + } + payloadLength = Long.reverseBytes(Unsafe.getUnsafe().getLong(buf + 2)); + + // Strict mode: reject non-minimal encodings + + // MSB must be 0 (no negative lengths) + if (payloadLength < 0) { + state = STATE_ERROR; + errorCode = WebSocketCloseCode.PROTOCOL_ERROR; + return 0; + } + + offset = 10; + } + + // Control frames must not have payload > 125 bytes + if (WebSocketOpcode.isControlFrame(opcode) && payloadLength > MAX_CONTROL_FRAME_PAYLOAD) { + state = STATE_ERROR; + errorCode = WebSocketCloseCode.PROTOCOL_ERROR; + return 0; + } + + // Close frame with 1 byte payload is invalid (must be 0 or >= 2) + if (opcode == WebSocketOpcode.CLOSE && payloadLength == 1) { + state = STATE_ERROR; + errorCode = WebSocketCloseCode.PROTOCOL_ERROR; + return 0; + } + + maskKey = 0; + headerSize = offset; + + // Check if we have the complete payload + long totalFrameSize = headerSize + payloadLength; + if (available < totalFrameSize) { + state = STATE_NEED_PAYLOAD; + return headerSize; + } + + state = STATE_COMPLETE; + return (int) totalFrameSize; + } + + /** + * Resets the parser state for parsing a new frame. + */ + public void reset() { + state = STATE_HEADER; + fin = false; + opcode = 0; + masked = false; + maskKey = 0; + payloadLength = 0; + headerSize = 0; + errorCode = 0; + } + + /** + * Unmasks the payload data in place. + * + * @param buf the start of the payload data + * @param len the length of the payload + */ + public void unmaskPayload(long buf, long len) { + if (!masked || maskKey == 0) { + // a zero maskKey is a no-op (makes no change to the data) + return; + } + + // Process 8 bytes at a time when possible for better performance + long i = 0; + long longMask = ((long) maskKey << 32) | (maskKey & 0xFFFFFFFFL); + + // Process 8-byte chunks + while (i + 8 <= len) { + long value = Unsafe.getUnsafe().getLong(buf + i); + Unsafe.getUnsafe().putLong(buf + i, value ^ longMask); + i += 8; + } + + // Process 4-byte chunk if remaining + if (i + 4 <= len) { + int value = Unsafe.getUnsafe().getInt(buf + i); + Unsafe.getUnsafe().putInt(buf + i, value ^ maskKey); + i += 4; + } + + // Process remaining bytes + while (i < len) { + byte b = Unsafe.getUnsafe().getByte(buf + i); + int shift = ((int) (i % 4)) << 3; // 0, 8, 16, or 24 + byte maskByte = (byte) ((maskKey >> shift) & 0xFF); + Unsafe.getUnsafe().putByte(buf + i, (byte) (b ^ maskByte)); + i++; + } + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/websocket/WebSocketFrameWriter.java b/core/src/main/java/io/questdb/client/cutlass/qwp/websocket/WebSocketFrameWriter.java new file mode 100644 index 0000000..2f3c653 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/websocket/WebSocketFrameWriter.java @@ -0,0 +1,150 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.websocket; + +import io.questdb.client.std.Unsafe; + +/** + * Zero-allocation WebSocket frame writer. + * Writes WebSocket frames according to RFC 6455. + * + *

    All methods are static utilities that write directly to memory buffers. + * + *

    Thread safety: This class is thread-safe as it contains no mutable state. + */ +public final class WebSocketFrameWriter { + // Frame header bits + private static final int FIN_BIT = 0x80; + private static final int MASK_BIT = 0x80; + + private WebSocketFrameWriter() { + // Static utility class + } + + /** + * Calculates the header size for a given payload length and masking. + * + * @param payloadLength the payload length + * @param masked true if the payload will be masked + * @return the header size in bytes + */ + public static int headerSize(long payloadLength, boolean masked) { + int size; + if (payloadLength <= 125) { + size = 2; + } else if (payloadLength <= 65535) { + size = 4; + } else { + size = 10; + } + return masked ? size + 4 : size; + } + + /** + * Masks payload data in place using XOR with the given mask key. + * + * @param buf the payload buffer + * @param len the payload length + * @param maskKey the 4-byte mask key + */ + public static void maskPayload(long buf, long len, int maskKey) { + // Process 8 bytes at a time when possible + long i = 0; + long longMask = ((long) maskKey << 32) | (maskKey & 0xFFFFFFFFL); + + // Process 8-byte chunks + while (i + 8 <= len) { + long value = Unsafe.getUnsafe().getLong(buf + i); + Unsafe.getUnsafe().putLong(buf + i, value ^ longMask); + i += 8; + } + + // Process 4-byte chunk if remaining + if (i + 4 <= len) { + int value = Unsafe.getUnsafe().getInt(buf + i); + Unsafe.getUnsafe().putInt(buf + i, value ^ maskKey); + i += 4; + } + + // Process remaining bytes (0-3 bytes) - extract mask byte inline to avoid allocation + while (i < len) { + byte b = Unsafe.getUnsafe().getByte(buf + i); + int maskByte = (maskKey >> (((int) i & 3) << 3)) & 0xFF; + Unsafe.getUnsafe().putByte(buf + i, (byte) (b ^ maskByte)); + i++; + } + } + + /** + * Writes a WebSocket frame header to the buffer. + * + * @param buf the buffer to write to + * @param fin true if this is the final frame + * @param opcode the frame opcode + * @param payloadLength the payload length + * @param masked true if the payload should be masked + * @return the number of bytes written (header size) + */ + public static int writeHeader(long buf, boolean fin, int opcode, long payloadLength, boolean masked) { + int offset = 0; + + // First byte: FIN + opcode + int byte0 = (fin ? FIN_BIT : 0) | (opcode & 0x0F); + Unsafe.getUnsafe().putByte(buf + offset++, (byte) byte0); + + // Second byte: MASK + payload length + int maskBit = masked ? MASK_BIT : 0; + + if (payloadLength <= 125) { + Unsafe.getUnsafe().putByte(buf + offset++, (byte) (maskBit | payloadLength)); + } else if (payloadLength <= 65535) { + Unsafe.getUnsafe().putByte(buf + offset++, (byte) (maskBit | 126)); + Unsafe.getUnsafe().putByte(buf + offset++, (byte) ((payloadLength >> 8) & 0xFF)); + Unsafe.getUnsafe().putByte(buf + offset++, (byte) (payloadLength & 0xFF)); + } else { + Unsafe.getUnsafe().putByte(buf + offset++, (byte) (maskBit | 127)); + Unsafe.getUnsafe().putLong(buf + offset, Long.reverseBytes(payloadLength)); + offset += 8; + } + + return offset; + } + + /** + * Writes a WebSocket frame header with optional mask key. + * + * @param buf the buffer to write to + * @param fin true if this is the final frame + * @param opcode the frame opcode + * @param payloadLength the payload length + * @param maskKey the mask key (only used if masked is true) + * @return the number of bytes written (header size including mask key) + */ + public static int writeHeader(long buf, boolean fin, int opcode, long payloadLength, int maskKey) { + int offset = writeHeader(buf, fin, opcode, payloadLength, true); + Unsafe.getUnsafe().putInt(buf + offset, maskKey); + return offset + 4; + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/websocket/WebSocketOpcode.java b/core/src/main/java/io/questdb/client/cutlass/qwp/websocket/WebSocketOpcode.java new file mode 100644 index 0000000..8668644 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/websocket/WebSocketOpcode.java @@ -0,0 +1,107 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.websocket; + +/** + * WebSocket frame opcodes as defined in RFC 6455. + */ +public final class WebSocketOpcode { + /** + * Binary frame (0x2). + * Payload is arbitrary binary data. + */ + public static final int BINARY = 0x02; + /** + * Connection close frame (0x8). + * Indicates that the endpoint wants to close the connection. + */ + public static final int CLOSE = 0x08; + /** + * Continuation frame (0x0). + * Used for fragmented messages after the initial frame. + */ + public static final int CONTINUATION = 0x00; + + // Reserved non-control frames: 0x3-0x7 + /** + * Ping frame (0x9). + * Used for keep-alive and connection health checks. + */ + public static final int PING = 0x09; + /** + * Pong frame (0xA). + * Response to a ping frame. + */ + public static final int PONG = 0x0A; + /** + * Text frame (0x1). + * Payload is UTF-8 encoded text. + */ + public static final int TEXT = 0x01; + + // Reserved control frames: 0xB-0xF + + private WebSocketOpcode() { + // Constants class + } + + /** + * Checks if the opcode is a control frame. + * Control frames are CLOSE (0x8), PING (0x9), and PONG (0xA). + * + * @param opcode the opcode to check + * @return true if the opcode is a control frame + */ + public static boolean isControlFrame(int opcode) { + return (opcode & 0x08) != 0; + } + + /** + * Checks if the opcode is a data frame. + * Data frames are CONTINUATION (0x0), TEXT (0x1), and BINARY (0x2). + * + * @param opcode the opcode to check + * @return true if the opcode is a data frame + */ + public static boolean isDataFrame(int opcode) { + return opcode <= 0x02; + } + + /** + * Checks if the opcode is valid according to RFC 6455. + * + * @param opcode the opcode to check + * @return true if the opcode is valid + */ + public static boolean isValid(int opcode) { + return opcode == CONTINUATION + || opcode == TEXT + || opcode == BINARY + || opcode == CLOSE + || opcode == PING + || opcode == PONG; + } + +} diff --git a/core/src/main/java/io/questdb/client/network/IOOperation.java b/core/src/main/java/io/questdb/client/network/IOOperation.java index dca5ac3..b600280 100644 --- a/core/src/main/java/io/questdb/client/network/IOOperation.java +++ b/core/src/main/java/io/questdb/client/network/IOOperation.java @@ -25,7 +25,6 @@ package io.questdb.client.network; public final class IOOperation { - public static final int HEARTBEAT = 8; public static final int READ = 1; public static final int WRITE = 4; diff --git a/core/src/main/java/io/questdb/client/network/Net.java b/core/src/main/java/io/questdb/client/network/Net.java index 1f35299..b1c4721 100644 --- a/core/src/main/java/io/questdb/client/network/Net.java +++ b/core/src/main/java/io/questdb/client/network/Net.java @@ -80,9 +80,9 @@ public static void configureKeepAlive(int fd) { public static native int configureNonBlocking(int fd); - public native static int connect(int fd, long sockaddr); + public static native int connect(int fd, long sockaddr); - public native static int connectAddrInfo(int fd, long lpAddrInfo); + public static native int connectAddrInfo(int fd, long lpAddrInfo); public static void freeAddrInfo(long pAddrInfo) { if (pAddrInfo != 0) { @@ -106,63 +106,59 @@ public static long getAddrInfo(CharSequence host, int port) { } } - private static long getAddrInfo(DirectUtf8Sequence host, int port) { - return getAddrInfo(host.ptr(), port); - } - - private static long getAddrInfo(long lpszHost, int port) { - long addrInfo = getAddrInfo0(lpszHost, port); - if (addrInfo != -1) { - ADDR_INFO_COUNTER.incrementAndGet(); - } - return addrInfo; - } - - public native static int getSndBuf(int fd); + public static native int getSndBuf(int fd); public static void init() { // no-op } - public native static boolean join(int fd, int bindIPv4Address, int groupIPv4Address); + public static native boolean join(int fd, int bindIPv4Address, int groupIPv4Address); public static native int peek(int fd, long ptr, int len); public static native int recv(int fd, long ptr, int len); - public static int send(long fd, long ptr, int len) { - return send(fd, ptr, len); - } - public static native int send(int fd, long ptr, int len); - public native static int sendTo(int fd, long ptr, int len, long sockaddr); + public static native int sendTo(int fd, long ptr, int len, long sockaddr); public static native int setKeepAlive0(int fd, int seconds); - public native static int setMulticastInterface(int fd, int ipv4address); + public static native int setMulticastInterface(int fd, int ipv4address); - public native static int setMulticastTtl(int fd, int ttl); + public static native int setMulticastTtl(int fd, int ttl); - public native static int setSndBuf(int fd, int size); + public static native int setSndBuf(int fd, int size); - public native static int setTcpNoDelay(int fd, boolean noDelay); + public static native int setTcpNoDelay(int fd, boolean noDelay); public static long sockaddr(int ipv4address, int port) { SOCK_ADDR_COUNTER.incrementAndGet(); return sockaddr0(ipv4address, port); } - public native static long sockaddr0(int ipv4address, int port); + public static native long sockaddr0(int ipv4address, int port); - public native static int socketTcp(boolean blocking); + public static native int socketTcp(boolean blocking); - public native static int socketUdp(); + public static native int socketUdp(); private static native void freeAddrInfo0(long pAddrInfo); private static native void freeSockAddr0(long sockaddr); + private static long getAddrInfo(DirectUtf8Sequence host, int port) { + return getAddrInfo(host.ptr(), port); + } + + private static long getAddrInfo(long lpszHost, int port) { + long addrInfo = getAddrInfo0(lpszHost, port); + if (addrInfo != -1) { + ADDR_INFO_COUNTER.incrementAndGet(); + } + return addrInfo; + } + private static native long getAddrInfo0(long lpszHost, int port); private static native int getEwouldblock(); @@ -186,4 +182,4 @@ public static long sockaddr(int ipv4address, int port) { MMSGHDR_BUFFER_LENGTH_OFFSET = -1L; } } -} \ No newline at end of file +} diff --git a/core/src/main/java/io/questdb/client/std/AbstractLowerCaseCharSequenceHashSet.java b/core/src/main/java/io/questdb/client/std/AbstractLowerCaseCharSequenceHashSet.java deleted file mode 100644 index 4bb8cf9..0000000 --- a/core/src/main/java/io/questdb/client/std/AbstractLowerCaseCharSequenceHashSet.java +++ /dev/null @@ -1,89 +0,0 @@ -/******************************************************************************* - * ___ _ ____ ____ - * / _ \ _ _ ___ ___| |_| _ \| __ ) - * | | | | | | |/ _ \/ __| __| | | | _ \ - * | |_| | |_| | __/\__ \ |_| |_| | |_) | - * \__\_\\__,_|\___||___/\__|____/|____/ - * - * Copyright (c) 2014-2019 Appsicle - * Copyright (c) 2019-2026 QuestDB - * - * Licensed 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. - * - ******************************************************************************/ - -package io.questdb.client.std; - -import java.util.Arrays; - -public abstract class AbstractLowerCaseCharSequenceHashSet implements Mutable { - protected static final int MIN_INITIAL_CAPACITY = 16; - protected static final CharSequence noEntryKey = null; - protected final double loadFactor; - protected int capacity; - protected int free; - protected CharSequence[] keys; - protected int mask; - - public AbstractLowerCaseCharSequenceHashSet(int initialCapacity, double loadFactor) { - if (loadFactor <= 0d || loadFactor >= 1d) { - throw new IllegalArgumentException("0 < loadFactor < 1"); - } - - free = this.capacity = Math.max(initialCapacity, MIN_INITIAL_CAPACITY); - this.loadFactor = loadFactor; - keys = new CharSequence[Numbers.ceilPow2((int) (this.capacity / loadFactor))]; - mask = keys.length - 1; - } - - @Override - public void clear() { - Arrays.fill(keys, noEntryKey); - free = capacity; - } - - public boolean contains(CharSequence key) { - return keyIndex(key) < 0; - } - - public int keyIndex(CharSequence key) { - int index = Chars.lowerCaseHashCode(key) & mask; - - if (keys[index] == noEntryKey) { - return index; - } - - if (Chars.equalsIgnoreCase(key, keys[index])) { - return -index - 1; - } - - return probe(key, index); - } - - public int size() { - return capacity - free; - } - - private int probe(CharSequence key, int index) { - do { - index = (index + 1) & mask; - if (keys[index] == noEntryKey) { - return index; - } - if (Chars.equalsIgnoreCase(key, keys[index])) { - return -index - 1; - } - } while (true); - } - -} \ No newline at end of file diff --git a/core/src/main/java/io/questdb/client/std/Base64Helper.java b/core/src/main/java/io/questdb/client/std/Base64Helper.java deleted file mode 100644 index 27d6772..0000000 --- a/core/src/main/java/io/questdb/client/std/Base64Helper.java +++ /dev/null @@ -1,116 +0,0 @@ -/******************************************************************************* - * ___ _ ____ ____ - * / _ \ _ _ ___ ___| |_| _ \| __ ) - * | | | | | | |/ _ \/ __| __| | | | _ \ - * | |_| | |_| | __/\__ \ |_| |_| | |_) | - * \__\_\\__,_|\___||___/\__|____/|____/ - * - * Copyright (c) 2014-2019 Appsicle - * Copyright (c) 2019-2026 QuestDB - * - * Licensed 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. - * - ******************************************************************************/ - -// Written by Gil Tene of Azul Systems, and released to the public domain, -// as explained at http://creativecommons.org/publicdomain/zero/1.0/ -// -// @author Gil Tene - -package io.questdb.client.std; - -import java.lang.reflect.Method; - -/** - * Base64Helper exists to bridge inconsistencies in Java SE support of Base64 encoding and decoding. - * Earlier Java SE platforms (up to and including Java SE 8) supported base64 encode/decode via the - * javax.xml.bind.DatatypeConverter class, which was deprecated and eventually removed in Java SE 9. - * Later Java SE platforms (Java SE 8 and later) support base64 encode/decode via the - * java.util.Base64 class (first introduced in Java SE 8, and not available on e.g. Java SE 6 or 7). - *

    - * This makes it "hard" to write a single piece of source code that deals with base64 encodings and - * will compile and run on e.g. Java SE 7 AND Java SE 9. And such common source is a common need for - * libraries. This class is intended to encapsulate this "hard"-ness and hide the ugly pretzle-twising - * needed under the covers. - *

    - * Base64Helper provides a common API that works across Java SE 6..9 (and beyond hopefully), and - * uses late binding (Reflection) internally to avoid javac-compile-time dependencies on a specific - * Java SE version (e.g. beyond 7 or before 9). - */ -public class Base64Helper { - - private static Method decodeMethod; - // encoderObj and decoderObj are used in non-static method forms, and - // irrelevant for static method forms: - private static Object decoderObj; - private static Method encodeMethod; - private static Object encoderObj; - - /** - * Converts a Base64 encoded String to a byte array - * - * @param base64input A base64-encoded input String - * @return a byte array containing the binary representation equivalent of the Base64 encoded input - */ - public static byte[] parseBase64Binary(String base64input) { - try { - return (byte[]) decodeMethod.invoke(decoderObj, base64input); - } catch (Throwable e) { - throw new UnsupportedOperationException("Failed to use platform's base64 decode method"); - } - } - - /** - * Converts an array of bytes into a Base64 string. - * - * @param binaryArray A binary encoded input array - * @return a String containing the Base64 encoded equivalent of the binary input - */ - static String printBase64Binary(byte[] binaryArray) { - try { - return (String) encodeMethod.invoke(encoderObj, binaryArray); - } catch (Throwable e) { - throw new UnsupportedOperationException("Failed to use platform's base64 encode method"); - } - } - - static { - try { - Class javaUtilBase64Class = Class.forName("java.util.Base64"); - - Method getDecoderMethod = javaUtilBase64Class.getMethod("getDecoder"); - decoderObj = getDecoderMethod.invoke(null); - decodeMethod = decoderObj.getClass().getMethod("decode", String.class); - - Method getEncoderMethod = javaUtilBase64Class.getMethod("getEncoder"); - encoderObj = getEncoderMethod.invoke(null); - encodeMethod = encoderObj.getClass().getMethod("encodeToString", byte[].class); - } catch (Throwable e) { - decodeMethod = null; - encodeMethod = null; - } - - if (encodeMethod == null) { - decoderObj = null; - encoderObj = null; - try { - Class javaxXmlBindDatatypeConverterClass = Class.forName("javax.xml.bind.DatatypeConverter"); - decodeMethod = javaxXmlBindDatatypeConverterClass.getMethod("parseBase64Binary", String.class); - encodeMethod = javaxXmlBindDatatypeConverterClass.getMethod("printBase64Binary", byte[].class); - } catch (Throwable e) { - decodeMethod = null; - encodeMethod = null; - } - } - } -} diff --git a/core/src/main/java/io/questdb/client/std/BiIntFunction.java b/core/src/main/java/io/questdb/client/std/BiIntFunction.java deleted file mode 100644 index 5de884f..0000000 --- a/core/src/main/java/io/questdb/client/std/BiIntFunction.java +++ /dev/null @@ -1,30 +0,0 @@ -/******************************************************************************* - * ___ _ ____ ____ - * / _ \ _ _ ___ ___| |_| _ \| __ ) - * | | | | | | |/ _ \/ __| __| | | | _ \ - * | |_| | |_| | __/\__ \ |_| |_| | |_) | - * \__\_\\__,_|\___||___/\__|____/|____/ - * - * Copyright (c) 2014-2019 Appsicle - * Copyright (c) 2019-2026 QuestDB - * - * Licensed 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. - * - ******************************************************************************/ - -package io.questdb.client.std; - -@FunctionalInterface -public interface BiIntFunction { - R apply(int val1, U val2); -} diff --git a/core/src/main/java/io/questdb/client/std/BufferWindowCharSequence.java b/core/src/main/java/io/questdb/client/std/BufferWindowCharSequence.java deleted file mode 100644 index fdc6ef0..0000000 --- a/core/src/main/java/io/questdb/client/std/BufferWindowCharSequence.java +++ /dev/null @@ -1,29 +0,0 @@ -/******************************************************************************* - * ___ _ ____ ____ - * / _ \ _ _ ___ ___| |_| _ \| __ ) - * | | | | | | |/ _ \/ __| __| | | | _ \ - * | |_| | |_| | __/\__ \ |_| |_| | |_) | - * \__\_\\__,_|\___||___/\__|____/|____/ - * - * Copyright (c) 2014-2019 Appsicle - * Copyright (c) 2019-2026 QuestDB - * - * Licensed 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. - * - ******************************************************************************/ - -package io.questdb.client.std; - -public interface BufferWindowCharSequence extends CharSequence { - -} \ No newline at end of file diff --git a/core/src/main/java/io/questdb/client/std/CharSequenceIntHashMap.java b/core/src/main/java/io/questdb/client/std/CharSequenceIntHashMap.java new file mode 100644 index 0000000..d56c181 --- /dev/null +++ b/core/src/main/java/io/questdb/client/std/CharSequenceIntHashMap.java @@ -0,0 +1,211 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.std; + +import org.jetbrains.annotations.NotNull; + +import java.util.Arrays; + + +public class CharSequenceIntHashMap extends AbstractCharSequenceHashSet { + public static final int NO_ENTRY_VALUE = -1; + private final ObjList list; + private final int noEntryValue; + private int[] values; + + public CharSequenceIntHashMap() { + this(8); + } + + public CharSequenceIntHashMap(int initialCapacity) { + this(initialCapacity, 0.4, NO_ENTRY_VALUE); + } + + public CharSequenceIntHashMap(int initialCapacity, double loadFactor, int noEntryValue) { + super(initialCapacity, loadFactor); + this.noEntryValue = noEntryValue; + this.list = new ObjList<>(capacity); + values = new int[keys.length]; + clear(); + } + + @Override + public final void clear() { + super.clear(); + list.clear(); + Arrays.fill(values, noEntryValue); + } + + public int get(@NotNull CharSequence key) { + return valueAt(keyIndex(key)); + } + + public void inc(@NotNull CharSequence key) { + int index = keyIndex(key); + if (index < 0) { + values[-index - 1]++; + } else { + String keyString = Chars.toString(key); + putAt0(index, keyString, 1); + list.add(keyString); + } + } + + public ObjList keys() { + return list; + } + + public boolean put(@NotNull CharSequence key, int value) { + return putAt(keyIndex(key), key, value); + } + + public void putAll(@NotNull CharSequenceIntHashMap other) { + CharSequence[] otherKeys = other.keys; + int[] otherValues = other.values; + for (int i = 0, n = otherKeys.length; i < n; i++) { + if (otherKeys[i] != noEntryKey) { + put(otherKeys[i], otherValues[i]); + } + } + } + + public boolean putAt(int index, @NotNull CharSequence key, int value) { + if (index < 0) { + values[-index - 1] = value; + return false; + } + final String keyString = Chars.toString(key); + putAt0(index, keyString, value); + list.add(keyString); + return true; + } + + public void putIfAbsent(@NotNull CharSequence key, int value) { + int index = keyIndex(key); + if (index > -1) { + String keyString = Chars.toString(key); + putAt0(index, keyString, value); + list.add(keyString); + } + } + + public void removeAt(int index) { + if (index < 0) { + int from = -index - 1; + CharSequence key = keys[from]; + erase(from); + free++; + + // after we have freed up a slot + // consider non-empty keys directly below + // they may have been a direct hit but because + // directly hit slot wasn't empty these keys would + // have moved. + // + // After slot is freed these keys require re-hash + from = (from + 1) & mask; + for ( + CharSequence k = keys[from]; + k != noEntryKey; + from = (from + 1) & mask, k = keys[from] + ) { + int idealHit = Hash.spread(Chars.hashCode(k)) & mask; + if (idealHit != from) { + int to; + if (keys[idealHit] != noEntryKey) { + to = probe0(k, idealHit); + } else { + to = idealHit; + } + + if (to > -1) { + move(from, to); + } + } + } + + list.remove(key); + } + } + + public int valueAt(int index) { + int index1 = -index - 1; + return index < 0 ? values[index1] : noEntryValue; + } + + public int valueQuick(int index) { + return get(list.getQuick(index)); + } + + private void erase(int index) { + keys[index] = noEntryKey; + values[index] = noEntryValue; + } + + private void move(int from, int to) { + keys[to] = keys[from]; + values[to] = values[from]; + erase(from); + } + + private int probe0(CharSequence key, int index) { + do { + index = (index + 1) & mask; + if (keys[index] == noEntryKey) { + return index; + } + if (Chars.equals(key, keys[index])) { + return -index - 1; + } + } while (true); + } + + private void putAt0(int index, CharSequence key, int value) { + keys[index] = key; + values[index] = value; + if (--free == 0) { + rehash(); + } + } + + private void rehash() { + int[] oldValues = values; + CharSequence[] oldKeys = keys; + int size = capacity - free; + capacity = capacity * 2; + free = capacity - size; + mask = Numbers.ceilPow2((int) (capacity / loadFactor)) - 1; + this.keys = new CharSequence[mask + 1]; + this.values = new int[mask + 1]; + for (int i = oldKeys.length - 1; i > -1; i--) { + CharSequence key = oldKeys[i]; + if (key != null) { + final int index = keyIndex(key); + keys[index] = key; + values[index] = oldValues[i]; + } + } + } +} diff --git a/core/src/main/java/io/questdb/client/std/Chars.java b/core/src/main/java/io/questdb/client/std/Chars.java index 84985c5..91bf7d7 100644 --- a/core/src/main/java/io/questdb/client/std/Chars.java +++ b/core/src/main/java/io/questdb/client/std/Chars.java @@ -51,10 +51,6 @@ public static void base64Encode(@Nullable BinarySequence sequence, int maxLength } } - public static boolean contains(@NotNull CharSequence sequence, @NotNull CharSequence term) { - return indexOf(sequence, 0, sequence.length(), term) != -1; - } - public static boolean equals(@NotNull CharSequence l, @NotNull CharSequence r) { if (l == r) { return true; @@ -356,18 +352,6 @@ public static int lowerCaseHashCode(CharSequence value) { return h; } - public static boolean noMatch(CharSequence l, int llo, int lhi, CharSequence r, int rlo, int rhi) { - int lp = llo; - int rp = rlo; - while (lp < lhi && rp < rhi) { - if (Character.toLowerCase(l.charAt(lp++)) != r.charAt(rp++)) { - return true; - } - - } - return lp != lhi || rp != rhi; - } - public static boolean startsWith(@Nullable CharSequence cs, @Nullable CharSequence starts) { if (cs == null || starts == null) { return false; diff --git a/core/src/main/java/io/questdb/client/std/ConcurrentHashMap.java b/core/src/main/java/io/questdb/client/std/ConcurrentHashMap.java deleted file mode 100644 index 5f2238f..0000000 --- a/core/src/main/java/io/questdb/client/std/ConcurrentHashMap.java +++ /dev/null @@ -1,3791 +0,0 @@ -/******************************************************************************* - * ___ _ ____ ____ - * / _ \ _ _ ___ ___| |_| _ \| __ ) - * | | | | | | |/ _ \/ __| __| | | | _ \ - * | |_| | |_| | __/\__ \ |_| |_| | |_) | - * \__\_\\__,_|\___||___/\__|____/|____/ - * - * Copyright (c) 2014-2019 Appsicle - * Copyright (c) 2019-2026 QuestDB - * - * Licensed 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. - * - ******************************************************************************/ - -package io.questdb.client.std; - -/* - * ORACLE PROPRIETARY/CONFIDENTIAL. Use is subject to license terms. - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - */ - -/* - * - * - * - * - * - * Written by Doug Lea with assistance from members of JCP JSR-166 - * Expert Group and released to the public domain, as explained at - * http://creativecommons.org/publicdomain/zero/1.0/ - */ - -import io.questdb.client.std.str.CloneableMutable; -import org.jetbrains.annotations.NotNull; - -import java.io.ObjectStreamField; -import java.io.Serializable; -import java.lang.ThreadLocal; -import java.lang.reflect.ParameterizedType; -import java.lang.reflect.Type; -import java.util.AbstractMap; -import java.util.Arrays; -import java.util.Collection; -import java.util.HashMap; -import java.util.Hashtable; -import java.util.Iterator; -import java.util.Map; -import java.util.NoSuchElementException; -import java.util.Set; -import java.util.concurrent.ConcurrentMap; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.locks.LockSupport; -import java.util.concurrent.locks.ReentrantLock; -import java.util.function.BiFunction; -import java.util.function.Function; - -/** - * A hash table supporting full concurrency of retrievals and - * high expected concurrency for updates. This class obeys the - * same functional specification as {@link java.util.Hashtable}, and - * includes versions of methods corresponding to each method of - * {@code Hashtable}. However, even though all operations are - * thread-safe, retrieval operations do not entail locking, - * and there is not any support for locking the entire table - * in a way that prevents all access. This class is fully - * interoperable with {@code Hashtable} in programs that rely on its - * thread safety but not on its synchronization details. - *

    Retrieval operations (including {@code get}) generally do not - * block, so may overlap with update operations (including {@code put} - * and {@code remove}). Retrievals reflect the results of the most - * recently completed update operations holding upon their - * onset. (More formally, an update operation for a given key bears a - * happens-before relation with any (non-null) retrieval for - * that key reporting the updated value.) For aggregate operations - * such as {@code putAll} and {@code clear}, concurrent retrievals may - * reflect insertion or removal of only some entries. Similarly, - * Iterators, Spliterators and Enumerations return elements reflecting the - * state of the hash table at some point at or since the creation of the - * iterator/enumeration. They do not throw {@link - * java.util.ConcurrentModificationException ConcurrentModificationException}. - * However, iterators are designed to be used by only one thread at a time. - * Bear in mind that the results of aggregate status methods including - * {@code size}, {@code isEmpty}, and {@code containsValue} are typically - * useful only when a map is not undergoing concurrent updates in other threads. - * Otherwise the results of these methods reflect transient states - * that may be adequate for monitoring or estimation purposes, but not - * for program control. - *

    The table is dynamically expanded when there are too many - * collisions (i.e., keys that have distinct hash codes but fall into - * the same slot modulo the table size), with the expected average - * effect of maintaining roughly two bins per mapping (corresponding - * to a 0.75 load factor threshold for resizing). There may be much - * variance around this average as mappings are added and removed, but - * overall, this maintains a commonly accepted time/space tradeoff for - * hash tables. However, resizing this or any other kind of hash - * table may be a relatively slow operation. When possible, it is a - * good idea to provide a size estimate as an optional {@code - * initialCapacity} constructor argument. An additional optional - * {@code loadFactor} constructor argument provides a further means of - * customizing initial table capacity by specifying the table density - * to be used in calculating the amount of space to allocate for the - * given number of elements. Also, for compatibility with previous - * versions of this class, constructors may optionally specify an - * expected {@code concurrencyLevel} as an additional hint for - * internal sizing. Note that using many keys with exactly the same - * {@code hashCode()} is a sure way to slow down performance of any - * hash table. To ameliorate impact, when keys are {@link Comparable}, - * this class may use comparison order among keys to help break ties. - *

    A {@link Set} projection of a ConcurrentHashMap may be created - * (using {@link #newKeySet()} or {@link #newKeySet(int)}), or viewed - * (using {@link #keySet(Object)} when only keys are of interest, and the - * mapped values are (perhaps transiently) not used or all take the - * same mapping value. - *

    This class and its views and iterators implement all of the - * optional methods of the {@link Map} and {@link Iterator} - * interfaces. - *

    Like {@link Hashtable} but unlike {@link HashMap}, this class - * does not allow {@code null} to be used as a key or value. - *

    ConcurrentHashMaps support a set of sequential and parallel bulk - * operations that are designed - * to be safely, and often sensibly, applied even with maps that are - * being concurrently updated by other threads; for example, when - * computing a snapshot summary of the values in a shared registry. - * There are three kinds of operation, each with four forms, accepting - * functions with Keys, Values, Entries, and (Key, Value) arguments - * and/or return values. Because the elements of a ConcurrentHashMap - * are not ordered in any particular way, and may be processed in - * different orders in different parallel executions, the correctness - * of supplied functions should not depend on any ordering, or on any - * other objects or values that may transiently change while - * computation is in progress; and except for forEach actions, should - * ideally be side-effect-free. Bulk operations on {@link java.util.Map.Entry} - * objects do not support method {@code setValue}. - *

      - *
    • forEach: Perform a given action on each element. - * A variant form applies a given transformation on each element - * before performing the action.
    • - *
    • search: Return the first available non-null result of - * applying a given function on each element; skipping further - * search when a result is found.
    • - *
    • reduce: Accumulate each element. The supplied reduction - * function cannot rely on ordering (more formally, it should be - * both associative and commutative). There are five variants: - *
        - *
      • Plain reductions. (There is not a form of this method for - * (key, value) function arguments since there is no corresponding - * return type.)
      • - *
      • Mapped reductions that accumulate the results of a given - * function applied to each element.
      • - *
      • Reductions to scalar doubles, longs, and ints, using a - * given basis value.
      • - *
      - *
    • - *
    - *

    The concurrency properties of bulk operations follow - * from those of ConcurrentHashMap: Any non-null result returned - * from {@code get(key)} and related access methods bears a - * happens-before relation with the associated insertion or - * update. The result of any bulk operation reflects the - * composition of these per-element relations (but is not - * necessarily atomic with respect to the map as a whole unless it - * is somehow known to be quiescent). Conversely, because keys - * and values in the map are never null, null serves as a reliable - * atomic indicator of the current lack of any result. To - * maintain this property, null serves as an implicit basis for - * all non-scalar reduction operations. For the double, long, and - * int versions, the basis should be one that, when combined with - * any other value, returns that other value (more formally, it - * should be the identity element for the reduction). Most common - * reductions have these properties; for example, computing a sum - * with basis 0 or a minimum with basis MAX_VALUE. - *

    Search and transformation functions provided as arguments - * should similarly return null to indicate the lack of any result - * (in which case it is not used). In the case of mapped - * reductions, this also enables transformations to serve as - * filters, returning null (or, in the case of primitive - * specializations, the identity basis) if the element should not - * be combined. You can create compound transformations and - * filterings by composing them yourself under this "null means - * there is nothing there now" rule before using them in search or - * reduce operations. - *

    Methods accepting and/or returning Entry arguments maintain - * key-value associations. They may be useful for example when - * finding the key for the greatest value. Note that "plain" Entry - * arguments can be supplied using {@code new - * AbstractMap.SimpleEntry(k,v)}. - *

    Bulk operations may complete abruptly, throwing an - * exception encountered in the application of a supplied - * function. Bear in mind when handling such exceptions that other - * concurrently executing functions could also have thrown - * exceptions, or would have done so if the first exception had - * not occurred. - *

    Speedups for parallel compared to sequential forms are common - * but not guaranteed. Parallel operations involving brief functions - * on small maps may execute more slowly than sequential forms if the - * underlying work to parallelize the computation is more expensive - * than the computation itself. Similarly, parallelization may not - * lead to much actual parallelism if all processors are busy - * performing unrelated tasks. - *

    All arguments to all task methods must be non-null. - *

    This class is a member of the - * - * Java Collections Framework. - * - * @param the type of mapped values - * @author Doug Lea - * @since 1.5 - */ -@SuppressWarnings("SynchronizationOnLocalVariableOrMethodParameter") -public class ConcurrentHashMap extends AbstractMap - implements ConcurrentMap, Serializable { - static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash - - /* - * Overview: - * - * The primary design goal of this hash table is to maintain - * concurrent readability (typically method get(), but also - * iterators and related methods) while minimizing update - * contention. Secondary goals are to keep space consumption about - * the same or better than java.util.HashMap, and to support high - * initial insertion rates on an empty table by many threads. - * - * This map usually acts as a binned (bucketed) hash table. Each - * key-value mapping is held in a Node. Most nodes are instances - * of the basic Node class with hash, key, value, and next - * fields. However, various subclasses exist: TreeNodes are - * arranged in balanced trees, not lists. TreeBins hold the roots - * of sets of TreeNodes. ForwardingNodes are placed at the heads - * of bins during resizing. ReservationNodes are used as - * placeholders while establishing values in computeIfAbsent and - * related methods. The types TreeBin, ForwardingNode, and - * ReservationNode do not hold normal user keys, values, or - * hashes, and are readily distinguishable during search etc - * because they have negative hash fields and null key and value - * fields. (These special nodes are either uncommon or transient, - * so the impact of carrying around some unused fields is - * insignificant.) - * - * The table is lazily initialized to a power-of-two size upon the - * first insertion. Each bin in the table normally contains a - * list of Nodes (most often, the list has only zero or one Node). - * Table accesses require volatile/atomic reads, writes, and - * CASes. Because there is no other way to arrange this without - * adding further indirections, we use intrinsics - * (sun.misc.Unsafe) operations. - * - * We use the top (sign) bit of Node hash fields for control - * purposes -- it is available anyway because of addressing - * constraints. Nodes with negative hash fields are specially - * handled or ignored in map methods. - * - * Insertion (via put or its variants) of the first node in an - * empty bin is performed by just CASing it to the bin. This is - * by far the most common case for put operations under most - * key/hash distributions. Other update operations (insert, - * delete, and replace) require locks. We do not want to waste - * the space required to associate a distinct lock object with - * each bin, so instead use the first node of a bin list itself as - * a lock. Locking support for these locks relies on builtin - * "synchronized" monitors. - * - * Using the first node of a list as a lock does not by itself - * suffice though: When a node is locked, any update must first - * validate that it is still the first node after locking it, and - * retry if not. Because new nodes are always appended to lists, - * once a node is first in a bin, it remains first until deleted - * or the bin becomes invalidated (upon resizing). - * - * The main disadvantage of per-bin locks is that other update - * operations on other nodes in a bin list protected by the same - * lock can stall, for example when user equals() or mapping - * functions take a long time. However, statistically, under - * random hash codes, this is not a common problem. Ideally, the - * frequency of nodes in bins follows a Poisson distribution - * (http://en.wikipedia.org/wiki/Poisson_distribution) with a - * parameter of about 0.5 on average, given the resizing threshold - * of 0.75, although with a large variance because of resizing - * granularity. Ignoring variance, the expected occurrences of - * list size k are (exp(-0.5) * pow(0.5, k) / factorial(k)). The - * first values are: - * - * 0: 0.60653066 - * 1: 0.30326533 - * 2: 0.07581633 - * 3: 0.01263606 - * 4: 0.00157952 - * 5: 0.00015795 - * 6: 0.00001316 - * 7: 0.00000094 - * 8: 0.00000006 - * more: less than 1 in ten million - * - * Lock contention probability for two threads accessing distinct - * elements is roughly 1 / (8 * #elements) under random hashes. - * - * Actual hash code distributions encountered in practice - * sometimes deviate significantly from uniform randomness. This - * includes the case when N > (1<<30), so some keys MUST collide. - * Similarly for dumb or hostile usages in which multiple keys are - * designed to have identical hash codes or ones that differs only - * in masked-out high bits. So we use a secondary strategy that - * applies when the number of nodes in a bin exceeds a - * threshold. These TreeBins use a balanced tree to hold nodes (a - * specialized form of red-black trees), bounding search time to - * O(log N). Each search step in a TreeBin is at least twice as - * slow as in a regular list, but given that N cannot exceed - * (1<<64) (before running out of addresses) this bounds search - * steps, lock hold times, etc, to reasonable constants (roughly - * 100 nodes inspected per operation worst case) so long as keys - * are Comparable (which is very common -- String, Long, etc). - * TreeBin nodes (TreeNodes) also maintain the same "next" - * traversal pointers as regular nodes, so can be traversed in - * iterators in the same way. - * - * The table is resized when occupancy exceeds a percentage - * threshold (nominally, 0.75, but see below). Any thread - * noticing an overfull bin may assist in resizing after the - * initiating thread allocates and sets up the replacement array. - * However, rather than stalling, these other threads may proceed - * with insertions etc. The use of TreeBins shields us from the - * worst case effects of overfilling while resizes are in - * progress. Resizing proceeds by transferring bins, one by one, - * from the table to the next table. However, threads claim small - * blocks of indices to transfer (via field transferIndex) before - * doing so, reducing contention. A generation stamp in field - * sizeCtl ensures that resizings do not overlap. Because we are - * using power-of-two expansion, the elements from each bin must - * either stay at same index, or move with a power of two - * offset. We eliminate unnecessary node creation by catching - * cases where old nodes can be reused because their next fields - * won't change. On average, only about one-sixth of them need - * cloning when a table doubles. The nodes they replace will be - * garbage collectable as soon as they are no longer referenced by - * any reader thread that may be in the midst of concurrently - * traversing table. Upon transfer, the old table bin contains - * only a special forwarding node (with hash field "MOVED") that - * contains the next table as its key. On encountering a - * forwarding node, access and update operations restart, using - * the new table. - * - * Each bin transfer requires its bin lock, which can stall - * waiting for locks while resizing. However, because other - * threads can join in and help resize rather than contend for - * locks, average aggregate waits become shorter as resizing - * progresses. The transfer operation must also ensure that all - * accessible bins in both the old and new table are usable by any - * traversal. This is arranged in part by proceeding from the - * last bin (table.length - 1) up towards the first. Upon seeing - * a forwarding node, traversals (see class Traverser) arrange to - * move to the new table without revisiting nodes. To ensure that - * no intervening nodes are skipped even when moved out of order, - * a stack (see class TableStack) is created on first encounter of - * a forwarding node during a traversal, to maintain its place if - * later processing the current table. The need for these - * save/restore mechanics is relatively rare, but when one - * forwarding node is encountered, typically many more will be. - * So Traversers use a simple caching scheme to avoid creating so - * many new TableStack nodes. (Thanks to Peter Levart for - * suggesting use of a stack here.) - * - * The traversal scheme also applies to partial traversals of - * ranges of bins (via an alternate Traverser constructor) - * to support partitioned aggregate operations. Also, read-only - * operations give up if ever forwarded to a null table, which - * provides support for shutdown-style clearing, which is also not - * currently implemented. - * - * Lazy table initialization minimizes footprint until first use, - * and also avoids resizings when the first operation is from a - * putAll, constructor with map argument, or deserialization. - * These cases attempt to override the initial capacity settings, - * but harmlessly fail to take effect in cases of races. - * - * The element count is maintained using a specialization of - * LongAdder. We need to incorporate a specialization rather than - * just use a LongAdder in order to access implicit - * contention-sensing that leads to creation of multiple - * CounterCells. The counter mechanics avoid contention on - * updates but can encounter cache thrashing if read too - * frequently during concurrent access. To avoid reading so often, - * resizing under contention is attempted only upon adding to a - * bin already holding two or more nodes. Under uniform hash - * distributions, the probability of this occurring at threshold - * is around 13%, meaning that only about 1 in 8 puts check - * threshold (and after resizing, many fewer do so). - * - * TreeBins use a special form of comparison for search and - * related operations (which is the main reason we cannot use - * existing collections such as TreeMaps). TreeBins contain - * Comparable elements, but may contain others, as well as - * elements that are Comparable but not necessarily Comparable for - * the same T, so we cannot invoke compareTo among them. To handle - * this, the tree is ordered primarily by hash value, then by - * Comparable.compareTo order if applicable. On lookup at a node, - * if elements are not comparable or compare as 0 then both left - * and right children may need to be searched in the case of tied - * hash values. (This corresponds to the full list search that - * would be necessary if all elements were non-Comparable and had - * tied hashes.) On insertion, to keep a total ordering (or as - * close as is required here) across rebalancings, we compare - * classes and identityHashCodes as tie-breakers. The red-black - * balancing code is updated from pre-jdk-collections - * (http://gee.cs.oswego.edu/dl/classes/collections/RBCell.java) - * based in turn on Cormen, Leiserson, and Rivest "Introduction to - * Algorithms" (CLR). - * - * TreeBins also require an additional locking mechanism. While - * list traversal is always possible by readers even during - * updates, tree traversal is not, mainly because of tree-rotations - * that may change the root node and/or its linkages. TreeBins - * include a simple read-write lock mechanism parasitic on the - * main bin-synchronization strategy: Structural adjustments - * associated with an insertion or removal are already bin-locked - * (and so cannot conflict with other writers) but must wait for - * ongoing readers to finish. Since there can be only one such - * waiter, we use a simple scheme using a single "waiter" field to - * block writers. However, readers need never block. If the root - * lock is held, they proceed along the slow traversal path (via - * next-pointers) until the lock becomes available or the list is - * exhausted, whichever comes first. These cases are not fast, but - * maximize aggregate expected throughput. - * - * Maintaining API and serialization compatibility with previous - * versions of this class introduces several oddities. Mainly: We - * leave untouched but unused constructor arguments referring to - * concurrencyLevel. We accept a loadFactor constructor argument, - * but apply it only to initial table capacity (which is the only - * time that we can guarantee to honor it.) We also declare an - * unused "Segment" class that is instantiated in minimal form - * only when serializing. - * - * Also, solely for compatibility with previous versions of this - * class, it extends AbstractMap, even though all of its methods - * are overridden, so it is just useless baggage. - * - * This file is organized to make things a little easier to follow - * while reading than they might otherwise: First the main static - * declarations and utilities, then fields, then main public - * methods (with a few factorings of multiple public methods into - * internal ones), then sizing methods, trees, traversers, and - * bulk operations. - */ - - /* ---------------- Constants -------------- */ - /** - * The largest possible (non-power of two) array size. - * Needed by toArray and related methods. - */ - static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; - /** - * The smallest table capacity for which bins may be treeified. - * (Otherwise the table is resized if too many nodes in a bin.) - * The value should be at least 4 * TREEIFY_THRESHOLD to avoid - * conflicts between resizing and treeification thresholds. - */ - static final int MIN_TREEIFY_CAPACITY = 64; - /* - * Encodings for Node hash fields. See above for explanation. - */ - static final int MOVED = -1; // hash for forwarding nodes - /** - * Number of CPUS, to place bounds on some sizings - */ - static final int NCPU = Runtime.getRuntime().availableProcessors(); - static final int RESERVED = -3; // hash for transient reservations - static final int TREEBIN = -2; // hash for roots of trees - /** - * The bin count threshold for using a tree rather than list for a - * bin. Bins are converted to trees when adding an element to a - * bin with at least this many nodes. The value must be greater - * than 2, and should be at least 8 to mesh with assumptions in - * tree removal about conversion back to plain bins upon - * shrinkage. - */ - static final int TREEIFY_THRESHOLD = 8; - /** - * The bin count threshold for untreeifying a (split) bin during a - * resize operation. Should be less than TREEIFY_THRESHOLD, and at - * most 6 to mesh with shrinkage detection under removal. - */ - static final int UNTREEIFY_THRESHOLD = 6; - /* ---------------- Fields -------------- */ - private static final long ABASE; - private static final int ASHIFT; - /* - * Volatile access methods are used for table elements as well as - * elements of in-progress next table while resizing. All uses of - * the tab arguments must be null checked by callers. All callers - * also paranoically precheck that tab's length is not zero (or an - * equivalent check), thus ensuring that any index argument taking - * the form of a hash value anded with (length - 1) is a valid - * index. Note that, to be correct wrt arbitrary concurrency - * errors by users, these checks must operate on local variables, - * which accounts for some odd-looking inline assignments below. - * Note that calls to setTabAt always occur within locked regions, - * and so in principle require only release ordering, not - * full volatile semantics, but are currently coded as volatile - * writes to be conservative. - */ - private static final long BASECOUNT; - private static final long CELLSBUSY; - private static final long CELLVALUE; - /** - * The default initial table capacity. Must be a power of 2 - * (i.e., at least 1) and at most MAXIMUM_CAPACITY. - */ - private static final int DEFAULT_CAPACITY = 16; - /** - * The load factor for this table. Overrides of this value in - * constructors affect only the initial table capacity. The - * actual floating point value isn't normally used -- it is - * simpler to use expressions such as {@code n - (n >>> 2)} for - * the associated resizing threshold. - */ - private static final float LOAD_FACTOR = 0.75f; - /** - * The largest possible table capacity. This value must be - * exactly 1<<30 to stay within Java array allocation and indexing - * bounds for power of two table sizes, and is further required - * because the top two bits of 32bit hash fields are used for - * control purposes. - */ - private static final int MAXIMUM_CAPACITY = 1 << 30; - /** - * Minimum number of rebinnings per transfer step. Ranges are - * subdivided to allow multiple resizer threads. This value - * serves as a lower bound to avoid resizers encountering - * excessive memory contention. The value should be at least - * DEFAULT_CAPACITY. - */ - private static final int MIN_TRANSFER_STRIDE = 16; - private static final long PROBE; - - /* ---------------- Nodes -------------- */ - /** - * The increment for generating probe values - */ - private static final int PROBE_INCREMENT = 0x9e3779b9; - - /* ---------------- Static utilities -------------- */ - /** - * The number of bits used for generation stamp in sizeCtl. - * Must be at least 6 for 32bit arrays. - */ - private static final int RESIZE_STAMP_BITS = 16; - /** - * The maximum number of threads that can help resize. - * Must fit in 32 - RESIZE_STAMP_BITS bits. - */ - private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1; - /** - * The bit shift for recording size stamp in sizeCtl. - */ - private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS; - private static final long SEED; - - /* ---------------- Table element access -------------- */ - /** - * The increment of seeder per new instance - */ - private static final long SEEDER_INCREMENT = 0xbb67ae8584caa73bL; - private static final long SIZECTL; - private static final long TRANSFERINDEX; - /** - * Generates per-thread initialization/probe field - */ - private static final AtomicInteger probeGenerator = new AtomicInteger(); - /** - * The next seed for default constructors. - */ - private static final AtomicLong seeder = new AtomicLong(initialSeed()); - /** - * For serialization compatibility. - */ - private static final ObjectStreamField[] serialPersistentFields = { - new ObjectStreamField("segments", Segment[].class), - new ObjectStreamField("segmentMask", Integer.TYPE), - new ObjectStreamField("segmentShift", Integer.TYPE) - }; - private static final long serialVersionUID = 7249069246763182397L; - private final java.lang.ThreadLocal> tlTraverser = ThreadLocal.withInitial(Traverser::new); - /** - * The array of bins. Lazily initialized upon first insertion. - * Size is always a power of two. Accessed directly by iterators. - */ - transient volatile Node[] table; - /** - * Base counter value, used mainly when there is no contention, - * but also as a fallback during table initialization - * races. Updated via CAS. - */ - private transient volatile long baseCount; - /** - * Spinlock (locked via CAS) used when resizing and/or creating CounterCells. - */ - private transient volatile int cellsBusy; - /** - * Table of counter cells. When non-null, size is a power of 2. - */ - private transient volatile CounterCell[] counterCells; - // Original (since JDK1.2) Map methods - private transient EntrySetView entrySet; - private transient boolean ics = true; - /* ---------------- Public operations -------------- */ - // views - private transient KeySetView keySet; - /** - * The next table to use; non-null only while resizing. - */ - private transient volatile Node[] nextTable; - /** - * Table initialization and resizing control. When negative, the - * table is being initialized or resized: -1 for initialization, - * else -(1 + the number of active resizing threads). Otherwise, - * when table is null, holds the initial table size to use upon - * creation, or 0 for default. After initialization, holds the - * next element count value upon which to resize the table. - */ - private transient volatile int sizeCtl; - /** - * The next table index (plus one) to split while resizing. - */ - private transient volatile int transferIndex; - private transient ValuesView values; - - /** - * Creates a new, empty map with the default initial table size (16). - */ - public ConcurrentHashMap(boolean isCaseSensitive) { - this.ics = isCaseSensitive; - } - - public ConcurrentHashMap() { - this(true); - } - - /** - * Creates a new, empty map with an initial table size - * accommodating the specified number of elements without the need - * to dynamically resize. - * - * @param initialCapacity The implementation performs internal - * sizing to accommodate this many elements. - * @throws IllegalArgumentException if the initial capacity of - * elements is negative - */ - public ConcurrentHashMap(int initialCapacity, boolean isCaseSensitive) { - this.ics = isCaseSensitive; - if (initialCapacity < 0) - throw new IllegalArgumentException(); - this.sizeCtl = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ? - MAXIMUM_CAPACITY : - tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1)); - } - - public ConcurrentHashMap(int initialCapacity) { - this(initialCapacity, true); - } - - /** - * Creates a new map with the same mappings as the given map. - * - * @param m the map - */ - public ConcurrentHashMap(Map m, boolean isCaseSensitive) { - this.ics = isCaseSensitive; - this.sizeCtl = DEFAULT_CAPACITY; - putAll(m); - } - - public ConcurrentHashMap(Map m) { - this(m, true); - } - - /** - * Creates a new, empty map with an initial table size based on - * the given number of elements ({@code initialCapacity}), table - * density ({@code loadFactor}), and number of concurrently - * updating threads ({@code concurrencyLevel}). - * - * @param initialCapacity the initial capacity. The implementation - * performs internal sizing to accommodate this many elements, - * given the specified load factor. - * @param loadFactor the load factor (table density) for - * establishing the initial table size - * @throws IllegalArgumentException if the initial capacity is - * negative or the load factor or concurrencyLevel are - * nonpositive - */ - public ConcurrentHashMap(int initialCapacity, float loadFactor, boolean isCaseSensitive) { - this.ics = isCaseSensitive; - if (!(loadFactor > 0.0f) || initialCapacity < 0) - throw new IllegalArgumentException(); - if (initialCapacity < 1) // Use at least as many bins - initialCapacity = 1; // as estimated threads - long size = (long) (1.0 + (long) initialCapacity / loadFactor); - this.sizeCtl = (size >= (long) MAXIMUM_CAPACITY) ? - MAXIMUM_CAPACITY : tableSizeFor((int) size); - } - - public ConcurrentHashMap(int initialCapacity, float loadFactor) { - this(initialCapacity, loadFactor, true); - } - - /** - * Creates a new {@link Set} backed by a ConcurrentHashMap - * from the given type to {@code Boolean.TRUE}. - * - * @return the new set - * @since 1.8 - */ - public static KeySetView newKeySet(boolean isCaseSensitive) { - return new KeySetView<>(new ConcurrentHashMap<>(isCaseSensitive), Boolean.TRUE); - } - - public static KeySetView newKeySet() { - return new KeySetView<>(new ConcurrentHashMap<>(), Boolean.TRUE); - } - - /** - * Creates a new {@link Set} backed by a ConcurrentHashMap - * from the given type to {@code Boolean.TRUE}. - * - * @param initialCapacity The implementation performs internal - * sizing to accommodate this many elements. - * @return the new set - * @throws IllegalArgumentException if the initial capacity of - * elements is negative - * @since 1.8 - */ - public static KeySetView newKeySet(int initialCapacity, boolean isCaseSensitive) { - return new KeySetView<>(new ConcurrentHashMap<>(initialCapacity, isCaseSensitive), Boolean.TRUE); - } - - public static KeySetView newKeySet(int initialCapacity) { - return new KeySetView<>(new ConcurrentHashMap<>(initialCapacity), Boolean.TRUE); - } - - /** - * Removes all of the mappings from this map. - */ - public void clear() { - long delta = 0L; // negative number of deletions - int i = 0; - Node[] tab = table; - while (tab != null && i < tab.length) { - int fh; - Node f = tabAt(tab, i); - if (f == null) - ++i; - else if ((fh = f.hash) == MOVED) { - tab = helpTransfer(tab, f); - i = 0; // restart - } else { - synchronized (f) { - if (tabAt(tab, i) == f) { - Node p = (fh >= 0 ? f : - (f instanceof TreeBin) ? - ((TreeBin) f).first : null); - while (p != null) { - --delta; - p = p.next; - } - setTabAt(tab, i++, null); - } - } - } - } - if (delta != 0L) - addCount(delta, -1); - } - - /** - * Attempts to compute a mapping for the specified key and its - * current mapped value (or {@code null} if there is no current - * mapping). The entire method invocation is performed atomically. - * Some attempted update operations on this map by other threads - * may be blocked while computation is in progress, so the - * computation should be short and simple, and must not attempt to - * update any other mappings of this Map. - * - * @param key key with which the specified value is to be associated - * @param remappingFunction the function to compute a value - * @return the new value associated with the specified key, or null if none - * @throws NullPointerException if the specified key or remappingFunction - * is null - * @throws IllegalStateException if the computation detectably - * attempts a recursive update to this map that would - * otherwise never complete - * @throws RuntimeException or Error if the remappingFunction does so, - * in which case the mapping is unchanged - */ - public V compute(CharSequence key, BiFunction remappingFunction) { - if (key == null || remappingFunction == null) - throw new NullPointerException(); - int h = spread(keyHashCode(key)); - V val = null; - int delta = 0; - int binCount = 0; - for (Node[] tab = table; ; ) { - Node f; - int n, i, fh; - if (tab == null || (n = tab.length) == 0) - tab = initTable(); - else if ((f = tabAt(tab, i = (n - 1) & h)) == null) { - Node r = new ReservationNode<>(ics); - synchronized (r) { - if (casTabAt(tab, i, r)) { - binCount = 1; - Node node = null; - try { - if ((val = remappingFunction.apply(key, null)) != null) { - delta = 1; - node = new Node<>(h, maybeCopyKey(key), val, null, ics); - } - } finally { - setTabAt(tab, i, node); - } - } - } - if (binCount != 0) - break; - } else if ((fh = f.hash) == MOVED) - tab = helpTransfer(tab, f); - else { - synchronized (f) { - if (tabAt(tab, i) == f) { - if (fh >= 0) { - binCount = 1; - for (Node e = f, pred = null; ; ++binCount) { - CharSequence ek; - if (e.hash == h && - ((ek = e.key) == key || (keyEquals(key, ek)))) { - val = remappingFunction.apply(key, e.val); - if (val != null) - e.val = val; - else { - delta = -1; - Node en = e.next; - if (pred != null) - pred.next = en; - else - setTabAt(tab, i, en); - } - break; - } - pred = e; - if ((e = e.next) == null) { - val = remappingFunction.apply(key, null); - if (val != null) { - delta = 1; - pred.next = - new Node<>(h, maybeCopyKey(key), val, null, ics); - } - break; - } - } - } else if (f instanceof TreeBin) { - binCount = 1; - TreeBin t = (TreeBin) f; - TreeNode r, p; - if ((r = t.root) != null) - p = r.findTreeNode(h, key, null); - else - p = null; - V pv = (p == null) ? null : p.val; - val = remappingFunction.apply(key, pv); - if (val != null) { - if (p != null) - p.val = val; - else { - delta = 1; - t.putTreeVal(h, key, val); - } - } else if (p != null) { - delta = -1; - if (t.removeTreeNode(p)) - setTabAt(tab, i, untreeify(t.first)); - } - } - } - } - if (binCount != 0) { - if (binCount >= TREEIFY_THRESHOLD) - treeifyBin(tab, i); - break; - } - } - } - if (delta != 0) - addCount(delta, binCount); - return val; - } - - /** - * If the specified key is not already associated with a value, - * attempts to compute its value using the given mapping function - * and enters it into this map unless {@code null}. The entire - * method invocation is performed atomically, so the function is - * applied at most once per key. Some attempted update operations - * on this map by other threads may be blocked while computation - * is in progress, so the computation should be short and simple, - * and must not attempt to update any other mappings of this map. - * - * @param key key with which the specified value is to be associated - * @param token token to pass to the mapping function - * @param mappingFunction the function to compute a value - * @return the current (existing or computed) value associated with - * the specified key, or null if the computed value is null - * @throws NullPointerException if the specified key or mappingFunction - * is null - * @throws IllegalStateException if the computation detectably - * attempts a recursive update to this map that would - * otherwise never complete - * @throws RuntimeException or Error if the mappingFunction does so, - * in which case the mapping is left unestablished - */ - public V computeIfAbsent(CharSequence key, Object token, BiFunction mappingFunction) { - if (key == null || mappingFunction == null) - throw new NullPointerException(); - int h = spread(keyHashCode(key)); - V val = null; - int binCount = 0; - for (Node[] tab = table; ; ) { - Node f; - int n, i, fh; - if (tab == null || (n = tab.length) == 0) - tab = initTable(); - else if ((f = tabAt(tab, i = (n - 1) & h)) == null) { - Node r = new ReservationNode<>(ics); - synchronized (r) { - if (casTabAt(tab, i, r)) { - binCount = 1; - Node node = null; - try { - if ((val = mappingFunction.apply(key, token)) != null) - node = new Node<>(h, maybeCopyKey(key), val, null, ics); - } finally { - setTabAt(tab, i, node); - } - } - } - if (binCount != 0) - break; - } else if ((fh = f.hash) == MOVED) - tab = helpTransfer(tab, f); - else { - boolean added = false; - synchronized (f) { - if (tabAt(tab, i) == f) { - if (fh >= 0) { - binCount = 1; - for (Node e = f; ; ++binCount) { - CharSequence ek; - if (e.hash == h && - ((ek = e.key) == key || (keyEquals(key, ek)))) { - val = e.val; - break; - } - Node pred = e; - if ((e = e.next) == null) { - if ((val = mappingFunction.apply(key, token)) != null) { - added = true; - pred.next = new Node(h, key, val, null, ics); - } - break; - } - } - } else if (f instanceof TreeBin) { - binCount = 2; - TreeBin t = (TreeBin) f; - TreeNode r, p; - if ((r = t.root) != null && - (p = r.findTreeNode(h, key, null)) != null) - val = p.val; - else if ((val = mappingFunction.apply(key, token)) != null) { - added = true; - t.putTreeVal(h, key, val); - } - } - } - } - if (binCount != 0) { - if (binCount >= TREEIFY_THRESHOLD) - treeifyBin(tab, i); - if (!added) - return val; - break; - } - } - } - if (val != null) - addCount(1L, binCount); - return val; - } - - /** - * If the specified key is not already associated with a value, - * attempts to compute its value using the given mapping function - * and enters it into this map unless {@code null}. The entire - * method invocation is performed atomically, so the function is - * applied at most once per key. Some attempted update operations - * on this map by other threads may be blocked while computation - * is in progress, so the computation should be short and simple, - * and must not attempt to update any other mappings of this map. - * - * @param key key with which the specified value is to be associated - * @param mappingFunction the function to compute a value - * @return the current (existing or computed) value associated with - * the specified key, or null if the computed value is null - * @throws NullPointerException if the specified key or mappingFunction - * is null - * @throws IllegalStateException if the computation detectably - * attempts a recursive update to this map that would - * otherwise never complete - * @throws RuntimeException or Error if the mappingFunction does so, - * in which case the mapping is left unestablished - */ - public V computeIfAbsent(CharSequence key, Function mappingFunction) { - if (key == null || mappingFunction == null) - throw new NullPointerException(); - int h = spread(keyHashCode(key)); - V val = null; - int binCount = 0; - for (Node[] tab = table; ; ) { - Node f; - int n, i, fh; - if (tab == null || (n = tab.length) == 0) - tab = initTable(); - else if ((f = tabAt(tab, i = (n - 1) & h)) == null) { - Node r = new ReservationNode<>(ics); - synchronized (r) { - if (casTabAt(tab, i, r)) { - binCount = 1; - Node node = null; - try { - if ((val = mappingFunction.apply(key)) != null) - node = new Node<>(h, maybeCopyKey(key), val, null, ics); - } finally { - setTabAt(tab, i, node); - } - } - } - if (binCount != 0) - break; - } else if ((fh = f.hash) == MOVED) - tab = helpTransfer(tab, f); - else { - boolean added = false; - synchronized (f) { - if (tabAt(tab, i) == f) { - if (fh >= 0) { - binCount = 1; - for (Node e = f; ; ++binCount) { - CharSequence ek; - if (e.hash == h && - ((ek = e.key) == key || (keyEquals(key, ek)))) { - val = e.val; - break; - } - Node pred = e; - if ((e = e.next) == null) { - if ((val = mappingFunction.apply(key)) != null) { - added = true; - pred.next = new Node<>(h, maybeCopyKey(key), val, null, ics); - } - break; - } - } - } else if (f instanceof TreeBin) { - binCount = 2; - TreeBin t = (TreeBin) f; - TreeNode r, p; - if ((r = t.root) != null && - (p = r.findTreeNode(h, key, null)) != null) - val = p.val; - else if ((val = mappingFunction.apply(key)) != null) { - added = true; - t.putTreeVal(h, key, val); - } - } - } - } - if (binCount != 0) { - if (binCount >= TREEIFY_THRESHOLD) - treeifyBin(tab, i); - if (!added) - return val; - break; - } - } - } - if (val != null) - addCount(1L, binCount); - return val; - } - - /** - * If the value for the specified key is present, attempts to - * compute a new mapping given the key and its current mapped - * value. The entire method invocation is performed atomically. - * Some attempted update operations on this map by other threads - * may be blocked while computation is in progress, so the - * computation should be short and simple, and must not attempt to - * update any other mappings of this map. - * - * @param key key with which a value may be associated - * @param remappingFunction the function to compute a value - * @return the new value associated with the specified key, or null if none - * @throws NullPointerException if the specified key or remappingFunction - * is null - * @throws IllegalStateException if the computation detectably - * attempts a recursive update to this map that would - * otherwise never complete - * @throws RuntimeException or Error if the remappingFunction does so, - * in which case the mapping is unchanged - */ - public V computeIfPresent(CharSequence key, BiFunction remappingFunction) { - if (key == null || remappingFunction == null) - throw new NullPointerException(); - int h = spread(keyHashCode(key)); - V val = null; - int delta = 0; - int binCount = 0; - for (Node[] tab = table; ; ) { - Node f; - int n, i, fh; - if (tab == null || (n = tab.length) == 0) - tab = initTable(); - else if ((f = tabAt(tab, i = (n - 1) & h)) == null) - break; - else if ((fh = f.hash) == MOVED) - tab = helpTransfer(tab, f); - else { - synchronized (f) { - if (tabAt(tab, i) == f) { - if (fh >= 0) { - binCount = 1; - for (Node e = f, pred = null; ; ++binCount) { - CharSequence ek; - if (e.hash == h && - ((ek = e.key) == key || (keyEquals(key, ek)))) { - val = remappingFunction.apply(key, e.val); - if (val != null) - e.val = val; - else { - delta = -1; - Node en = e.next; - if (pred != null) - pred.next = en; - else - setTabAt(tab, i, en); - } - break; - } - pred = e; - if ((e = e.next) == null) - break; - } - } else if (f instanceof TreeBin) { - binCount = 2; - TreeBin t = (TreeBin) f; - TreeNode r, p; - if ((r = t.root) != null && - (p = r.findTreeNode(h, key, null)) != null) { - val = remappingFunction.apply(key, p.val); - if (val != null) - p.val = val; - else { - delta = -1; - if (t.removeTreeNode(p)) - setTabAt(tab, i, untreeify(t.first)); - } - } - } - } - } - if (binCount != 0) - break; - } - } - if (delta != 0) - addCount(delta, binCount); - return val; - } - - /** - * Tests if the specified object is a key in this table. - * - * @param key possible key - * @return {@code true} if and only if the specified object - * is a key in this table, as determined by the - * {@code equals} method; {@code false} otherwise - * @throws NullPointerException if the specified key is null - */ - public boolean containsKey(Object key) { - return get(key) != null; - } - - /** - * Returns {@code true} if this map maps one or more keys to the - * specified value. Note: This method may require a full traversal - * of the map, and is much slower than method {@code containsKey}. - * - * @param value value whose presence in this map is to be tested - * @return {@code true} if this map maps one or more keys to the - * specified value - * @throws NullPointerException if the specified value is null - */ - public boolean containsValue(Object value) { - if (value == null) - throw new NullPointerException(); - Node[] t = table; - if (t != null) { - Traverser it = getTraverser(t); - for (Node p; (p = it.advance()) != null; ) { - V v; - if ((v = p.val) == value || (value.equals(v))) - return true; - } - } - return false; - } - - /** - * Returns a {@link Set} view of the mappings contained in this map. - * The set is backed by the map, so changes to the map are - * reflected in the set, and vice-versa. The set supports element - * removal, which removes the corresponding mapping from the map, - * via the {@code Iterator.remove}, {@code Set.remove}, - * {@code removeAll}, {@code retainAll}, and {@code clear} - * operations. - *

    The view's iterators and spliterators are - * weakly consistent. - * - * @return the set view - */ - @NotNull - public Set> entrySet() { - EntrySetView es; - return (es = entrySet) != null ? es : (entrySet = new EntrySetView<>(this)); - } - - /** - * Compares the specified object with this map for equality. - * Returns {@code true} if the given object is a map with the same - * mappings as this map. This operation may return misleading - * results if either map is concurrently modified during execution - * of this method. - * - * @param o object to be compared for equality with this map - * @return {@code true} if the specified object is equal to this map - */ - public boolean equals(Object o) { - if (o != this) { - if (!(o instanceof Map)) - return false; - Map m = (Map) o; - Traverser it = getTraverser(table); - for (Node p; (p = it.advance()) != null; ) { - V val = p.val; - Object v = m.get(p.key); - if (v == null || (v != val && !v.equals(val))) - return false; - } - for (Map.Entry e : m.entrySet()) { - Object mk, mv, v; - if ((mk = e.getKey()) == null || - (mv = e.getValue()) == null || - (v = get(mk)) == null || - (mv != v && !mv.equals(v))) - return false; - } - } - return true; - } - - /** - * Returns the value to which the specified key is mapped, - * or {@code null} if this map contains no mapping for the key. - *

    More formally, if this map contains a mapping from a key - * {@code k} to a value {@code v} such that {@code key.equals(k)}, - * then this method returns {@code v}; otherwise it returns - * {@code null}. (There can be at most one such mapping.) - * - * @param key map key value - * @return value to which specified key is mapped - * @throws NullPointerException if the specified key is null - */ - @Override - public V get(Object key) { - if (key instanceof CharSequence) { - return get((CharSequence) key); - } - return null; - } - - public V get(CharSequence key) { - Node[] tab; - Node e, p; - int n, eh; - CharSequence ek; - int h = spread(keyHashCode(key)); - if ((tab = table) != null && (n = tab.length) > 0 && - (e = tabAt(tab, (n - 1) & h)) != null) { - if ((eh = e.hash) == h) { - if ((ek = e.key) == key || (ek != null && keyEquals(key, ek))) - return e.val; - } else if (eh < 0) - return (p = e.find(h, key)) != null ? p.val : null; - while ((e = e.next) != null) { - if (e.hash == h && - ((ek = e.key) == key || (ek != null && keyEquals(key, ek)))) - return e.val; - } - } - return null; - } - - /** - * Returns the value to which the specified key is mapped, or the - * given default value if this map contains no mapping for the - * key. - * - * @param key the key whose associated value is to be returned - * @param defaultValue the value to return if this map contains - * no mapping for the given key - * @return the mapping for the key, if present; else the default value - * @throws NullPointerException if the specified key is null - */ - public V getOrDefault(Object key, V defaultValue) { - V v; - return (v = get(key)) == null ? defaultValue : v; - } - - // ConcurrentMap methods - - /** - * Returns the hash code value for this {@link Map}, i.e., - * the sum of, for each key-value pair in the map, - * {@code key.hashCode() ^ value.hashCode()}. - * - * @return the hash code value for this map - */ - public int hashCode() { - int h = 0; - Node[] t = table; - if (t != null) { - Traverser it = getTraverser(t); - for (Node p; (p = it.advance()) != null; ) - h += keyHashCode(p.key) ^ p.val.hashCode(); - } - return h; - } - - /** - * {@inheritDoc} - */ - public boolean isEmpty() { - return sumCount() <= 0L; // ignore transient negative values - } - - /** - * Returns a {@link Set} view of the keys in this map, using the - * given common mapped value for any additions (i.e., {@link - * Collection#add} and {@link Collection#addAll(Collection)}). - * This is of course only appropriate if it is acceptable to use - * the same value for all additions from this view. - * - * @param mappedValue the mapped value to use for any additions - * @return the set view - * @throws NullPointerException if the mappedValue is null - */ - public KeySetView keySet(V mappedValue) { - if (mappedValue == null) - throw new NullPointerException(); - return new KeySetView<>(this, mappedValue); - } - - /** - * Returns a {@link Set} view of the keys contained in this map. - * The set is backed by the map, so changes to the map are - * reflected in the set, and vice-versa. The set supports element - * removal, which removes the corresponding mapping from this map, - * via the {@code Iterator.remove}, {@code Set.remove}, - * {@code removeAll}, {@code retainAll}, and {@code clear} - * operations. It does not support the {@code add} or - * {@code addAll} operations. - *

    The view's iterators and spliterators are - * weakly consistent. - *

    - * - * @return the set view - */ - @NotNull - public KeySetView keySet() { - KeySetView ks; - return (ks = keySet) != null ? ks : (keySet = new KeySetView<>(this, null)); - } - - // Overrides of JDK8+ Map extension method defaults - - /** - * Returns the number of mappings. This method should be used - * instead of {@link #size} because a ConcurrentHashMap may - * contain more mappings than can be represented as an int. The - * value returned is an estimate; the actual count may differ if - * there are concurrent insertions or removals. - * - * @return the number of mappings - * @since 1.8 - */ - public long mappingCount() { - return Math.max(sumCount(), 0L); // ignore transient negative values - } - - /** - * Maps the specified key to the specified value in this table. - * Neither the key nor the value can be null. - *

    The value can be retrieved by calling the {@code get} method - * with a key that is equal to the original key. - * - * @param key key with which the specified value is to be associated - * @param value value to be associated with the specified key - * @return the previous value associated with {@code key}, or - * {@code null} if there was no mapping for {@code key} - * @throws NullPointerException if the specified key or value is null - */ - public V put(CharSequence key, V value) { - return putVal(key, value, false); - } - - /** - * Copies all of the mappings from the specified map to this one. - * These mappings replace any mappings that this map had for any of the - * keys currently in the specified map. - * - * @param m mappings to be stored in this map - */ - public void putAll(@NotNull Map m) { - tryPresize(m.size()); - for (Map.Entry e : m.entrySet()) - putVal(e.getKey(), e.getValue(), false); - } - - /** - * {@inheritDoc} - * - * @return the previous value associated with the specified key, - * or {@code null} if there was no mapping for the key - * @throws NullPointerException if the specified key or value is null - */ - public V putIfAbsent(@NotNull CharSequence key, V value) { - return putVal(key, value, true); - } - - /** - * {@inheritDoc} - * - * @throws NullPointerException if the specified key is null - */ - @SuppressWarnings("unchecked") - public boolean remove(@NotNull Object key, Object value) { - return value != null && replaceNode((CharSequence) key, null, (V) value) != null; - } - // Hashtable legacy methods - - /** - * Removes the key (and its corresponding value) from this map. - * This method does nothing if the key is not in the map. - * - * @param key the key that needs to be removed - * @return the previous value associated with {@code key}, or - * {@code null} if there was no mapping for {@code key} - * @throws NullPointerException if the specified key is null - */ - public V remove(CharSequence key) { - return replaceNode(key, null, null); - } - - // ConcurrentHashMap-only methods - - /** - * {@inheritDoc} - * - * @throws NullPointerException if any of the arguments are null - */ - public boolean replace(@NotNull CharSequence key, @NotNull V oldValue, @NotNull V newValue) { - return replaceNode(key, newValue, oldValue) != null; - } - - /** - * {@inheritDoc} - * - * @return the previous value associated with the specified key, - * or {@code null} if there was no mapping for the key - * @throws NullPointerException if the specified key or value is null - */ - public V replace(@NotNull CharSequence key, @NotNull V value) { - return replaceNode(key, value, null); - } - - /** - * {@inheritDoc} - */ - public int size() { - long n = sumCount(); - return ((n < 0L) ? 0 : - (n > (long) Integer.MAX_VALUE) ? Integer.MAX_VALUE : - (int) n); - } - - /** - * Returns a string representation of this map. The string - * representation consists of a list of key-value mappings (in no - * particular order) enclosed in braces ("{@code {}}"). Adjacent - * mappings are separated by the characters {@code ", "} (comma - * and space). Each key-value mapping is rendered as the key - * followed by an equals sign ("{@code =}") followed by the - * associated value. - * - * @return a string representation of this map - */ - public String toString() { - Traverser it = getTraverser(table); - StringBuilder sb = new StringBuilder(); - sb.append('{'); - Node p; - if ((p = it.advance()) != null) { - for (; ; ) { - CharSequence k = p.key; - V v = p.val; - sb.append(k == this ? "(this Map)" : k); - sb.append('='); - sb.append(v == this ? "(this Map)" : v); - if ((p = it.advance()) == null) - break; - sb.append(',').append(' '); - } - } - return sb.append('}').toString(); - } - - /* ---------------- Special Nodes -------------- */ - - /** - * Returns a {@link Collection} view of the values contained in this map. - * The collection is backed by the map, so changes to the map are - * reflected in the collection, and vice-versa. The collection - * supports element removal, which removes the corresponding - * mapping from this map, via the {@code Iterator.remove}, - * {@code Collection.remove}, {@code removeAll}, - * {@code retainAll}, and {@code clear} operations. It does not - * support the {@code add} or {@code addAll} operations. - *

    The view's iterators and spliterators are - * weakly consistent. - * - * @return the collection view - */ - @NotNull - public Collection values() { - ValuesView vs; - return (vs = values) != null ? vs : (values = new ValuesView<>(this)); - } - - /* ---------------- Table Initialization and Resizing -------------- */ - - private static long initialSeed() { - String pp = System.getProperty("java.util.secureRandomSeed"); - - if (pp != null && pp.equalsIgnoreCase("true")) { - byte[] seedBytes = java.security.SecureRandom.getSeed(8); - long s = (long) (seedBytes[0]) & 0xffL; - for (int i = 1; i < 8; ++i) - s = (s << 8) | ((long) (seedBytes[i]) & 0xffL); - return s; - } - return (mix64(System.currentTimeMillis()) ^ - mix64(System.nanoTime())); - } - - private static boolean keyEquals(final CharSequence lhs, final CharSequence rhs, boolean isCaseSensitive) { - return isCaseSensitive ? Chars.equals(lhs, rhs) : Chars.equalsIgnoreCase(lhs, rhs); - } - - private static int keyHashCode(final CharSequence key, boolean isCaseSensitive) { - return isCaseSensitive ? Chars.hashCode(key) : Chars.lowerCaseHashCode(key); - } - - private static CharSequence maybeCopyKey(CharSequence key) { - return key instanceof CloneableMutable ? ((CloneableMutable) key).copy() : key; - } - - private static long mix64(long z) { - z = (z ^ (z >>> 33)) * 0xff51afd7ed558ccdL; - z = (z ^ (z >>> 33)) * 0xc4ceb9fe1a85ec53L; - return z ^ (z >>> 33); - } - - /** - * Returns a power of two table size for the given desired capacity. - * See Hackers Delight, sec 3.2 - */ - private static int tableSizeFor(int c) { - int n = c - 1; - n |= n >>> 1; - n |= n >>> 2; - n |= n >>> 4; - n |= n >>> 8; - n |= n >>> 16; - return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; - } - - /** - * Adds to count, and if table is too small and not already - * resizing, initiates transfer. If already resizing, helps - * perform transfer if work is available. Rechecks occupancy - * after a transfer to see if another resize is already needed - * because resizings are lagging additions. - * - * @param x the count to add - * @param check if <0, don't check resize, if <= 1 only check if uncontended - */ - private void addCount(long x, int check) { - CounterCell[] as; - long b, s; - if ((as = counterCells) != null || !Unsafe.cas(this, BASECOUNT, b = baseCount, s = b + x)) { - CounterCell a; - long v; - int m; - boolean uncontended = true; - if (as == null || (m = as.length - 1) < 0 || - (a = as[getProbe() & m]) == null || - !(uncontended = Unsafe.cas(a, CELLVALUE, v = a.value, v + x))) { - fullAddCount(x, uncontended); - return; - } - if (check <= 1) - return; - s = sumCount(); - } - if (check >= 0) { - Node[] tab, nt; - int n, sc; - while (s >= (long) (sc = sizeCtl) && (tab = table) != null && - (n = tab.length) < MAXIMUM_CAPACITY) { - int rs = resizeStamp(n); - if (sc < 0) { - if (sc >>> RESIZE_STAMP_SHIFT != rs || sc == rs + MAX_RESIZERS || (nt = nextTable) == null || transferIndex <= 0) - break; - if (Unsafe.getUnsafe().compareAndSwapInt(this, SIZECTL, sc, sc + 1)) - transfer(tab, nt); - } else if (Unsafe.getUnsafe().compareAndSwapInt(this, SIZECTL, sc, - (rs << RESIZE_STAMP_SHIFT) + 2)) - transfer(tab, null); - s = sumCount(); - } - } - } - - // See LongAdder version for explanation - private void fullAddCount(long x, boolean wasUncontended) { - int h; - if ((h = getProbe()) == 0) { - localInit(); // force initialization - h = getProbe(); - wasUncontended = true; - } - boolean collide = false; // True if last slot nonempty - for (; ; ) { - CounterCell[] as; - CounterCell a; - int n; - long v; - if ((as = counterCells) != null && (n = as.length) > 0) { - if ((a = as[(n - 1) & h]) == null) { - if (cellsBusy == 0) { // Try to attach new Cell - CounterCell r = new CounterCell(x); // Optimistic create - if (cellsBusy == 0 && - Unsafe.getUnsafe().compareAndSwapInt(this, CELLSBUSY, 0, 1)) { - boolean created = false; - try { // Recheck under lock - CounterCell[] rs; - int m, j; - if ((rs = counterCells) != null && - (m = rs.length) > 0 && - rs[j = (m - 1) & h] == null) { - rs[j] = r; - created = true; - } - } finally { - cellsBusy = 0; - } - if (created) - break; - continue; // Slot is now non-empty - } - } - collide = false; - } else if (!wasUncontended) // CAS already known to fail - wasUncontended = true; // Continue after rehash - else if (Unsafe.cas(a, CELLVALUE, v = a.value, v + x)) - break; - else if (counterCells != as || n >= NCPU) - collide = false; // At max size or stale - else if (!collide) - collide = true; - else if (cellsBusy == 0 && - Unsafe.getUnsafe().compareAndSwapInt(this, CELLSBUSY, 0, 1)) { - try { - if (counterCells == as) {// Expand table unless stale - CounterCell[] rs = new CounterCell[n << 1]; - System.arraycopy(as, 0, rs, 0, n); - counterCells = rs; - } - } finally { - cellsBusy = 0; - } - collide = false; - continue; // Retry with expanded table - } - h = advanceProbe(h); - } else if (cellsBusy == 0 && counterCells == as && - Unsafe.getUnsafe().compareAndSwapInt(this, CELLSBUSY, 0, 1)) { - boolean init = false; - try { // Initialize table - if (counterCells == as) { - CounterCell[] rs = new CounterCell[2]; - rs[h & 1] = new CounterCell(x); - counterCells = rs; - init = true; - } - } finally { - cellsBusy = 0; - } - if (init) - break; - } else if (Unsafe.cas(this, BASECOUNT, v = baseCount, v + x)) - break; // Fall back on using base - } - } - - private Traverser getTraverser(Node[] tab) { - Traverser traverser = tlTraverser.get(); - int len = tab == null ? 0 : tab.length; - traverser.of(tab, len, len); - return traverser; - } - - /** - * Initializes table, using the size recorded in sizeCtl. - */ - private Node[] initTable() { - Node[] tab; - int sc; - while ((tab = table) == null || tab.length == 0) { - if ((sc = sizeCtl) < 0) - Os.pause(); // lost initialization race; just spin - else if (Unsafe.getUnsafe().compareAndSwapInt(this, SIZECTL, sc, -1)) { - try { - if ((tab = table) == null || tab.length == 0) { - int n = (sc > 0) ? sc : DEFAULT_CAPACITY; - @SuppressWarnings("unchecked") - Node[] nt = (Node[]) new Node[n]; - table = tab = nt; - sc = n - (n >>> 2); - } - } finally { - sizeCtl = sc; - } - break; - } - } - return tab; - } - - private boolean keyEquals(final CharSequence lhs, final CharSequence rhs) { - return keyEquals(lhs, rhs, ics); - } - /* ---------------- Counter support -------------- */ - - private int keyHashCode(final CharSequence key) { - return keyHashCode(key, ics); - } - - /** - * Moves and/or copies the nodes in each bin to new table. See - * above for explanation. - */ - private void transfer(Node[] tab, Node[] nextTab) { - int n = tab.length, stride; - if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE) - stride = MIN_TRANSFER_STRIDE; // subdivide range - if (nextTab == null) { // initiating - try { - @SuppressWarnings("unchecked") - Node[] nt = (Node[]) new Node[n << 1]; - nextTab = nt; - } catch (Throwable ex) { // try to cope with OOME - sizeCtl = Integer.MAX_VALUE; - return; - } - nextTable = nextTab; - transferIndex = n; - } - int nextn = nextTab.length; - ForwardingNode fwd = new ForwardingNode<>(nextTab, ics); - boolean advance = true; - boolean finishing = false; // to ensure sweep before committing nextTab - for (int i = 0, bound = 0; ; ) { - Node f; - int fh; - while (advance) { - int nextIndex, nextBound; - if (--i >= bound || finishing) - advance = false; - else if ((nextIndex = transferIndex) <= 0) { - i = -1; - advance = false; - } else if (Unsafe.getUnsafe().compareAndSwapInt - (this, TRANSFERINDEX, nextIndex, - nextBound = (nextIndex > stride ? - nextIndex - stride : 0))) { - bound = nextBound; - i = nextIndex - 1; - advance = false; - } - } - if (i < 0 || i >= n || i + n >= nextn) { - int sc; - if (finishing) { - nextTable = null; - table = nextTab; - sizeCtl = (n << 1) - (n >>> 1); - return; - } - if (Unsafe.getUnsafe().compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) { - if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT) - return; - finishing = advance = true; - i = n; // recheck before commit - } - } else if ((f = tabAt(tab, i)) == null) - advance = casTabAt(tab, i, fwd); - else if ((fh = f.hash) == MOVED) - advance = true; // already processed - else { - synchronized (f) { - if (tabAt(tab, i) == f) { - Node ln, hn; - if (fh >= 0) { - int runBit = fh & n; - Node lastRun = f; - for (Node p = f.next; p != null; p = p.next) { - int b = p.hash & n; - if (b != runBit) { - runBit = b; - lastRun = p; - } - } - if (runBit == 0) { - ln = lastRun; - hn = null; - } else { - hn = lastRun; - ln = null; - } - for (Node p = f; p != lastRun; p = p.next) { - int ph = p.hash; - CharSequence pk = p.key; - V pv = p.val; - if ((ph & n) == 0) - ln = new Node<>(ph, pk, pv, ln, ics); - else - hn = new Node<>(ph, pk, pv, hn, ics); - } - setTabAt(nextTab, i, ln); - setTabAt(nextTab, i + n, hn); - setTabAt(tab, i, fwd); - advance = true; - } else if (f instanceof TreeBin) { - TreeBin t = (TreeBin) f; - TreeNode lo = null, loTail = null; - TreeNode hi = null, hiTail = null; - int lc = 0, hc = 0; - for (Node e = t.first; e != null; e = e.next) { - int h = e.hash; - TreeNode p = new TreeNode<> - (h, e.key, e.val, null, null, ics); - if ((h & n) == 0) { - if ((p.prev = loTail) == null) - lo = p; - else - loTail.next = p; - loTail = p; - ++lc; - } else { - if ((p.prev = hiTail) == null) - hi = p; - else - hiTail.next = p; - hiTail = p; - ++hc; - } - } - ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) : - (hc != 0) ? new TreeBin<>(lo, ics) : t; - hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) : - (lc != 0) ? new TreeBin<>(hi, ics) : t; - setTabAt(nextTab, i, ln); - setTabAt(nextTab, i + n, hn); - setTabAt(tab, i, fwd); - advance = true; - } - } - } - } - } - } - - /** - * Replaces all linked nodes in bin at given index unless table is - * too small, in which case resizes instead. - */ - private void treeifyBin(Node[] tab, int index) { - Node b; - int n; - if (tab != null) { - if ((n = tab.length) < MIN_TREEIFY_CAPACITY) - tryPresize(n << 1); - else if ((b = tabAt(tab, index)) != null && b.hash >= 0) { - synchronized (b) { - if (tabAt(tab, index) == b) { - TreeNode hd = null, tl = null; - for (Node e = b; e != null; e = e.next) { - TreeNode p = - new TreeNode<>(e.hash, e.key, e.val, null, null, ics); - if ((p.prev = tl) == null) - hd = p; - else - tl.next = p; - tl = p; - } - setTabAt(tab, index, new TreeBin<>(hd, ics)); - } - } - } - } - } - - /* ---------------- Conversion from/to TreeBins -------------- */ - - /** - * Tries to presize table to accommodate the given number of elements. - * - * @param size number of elements (doesn't need to be perfectly accurate) - */ - private void tryPresize(int size) { - int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY : - tableSizeFor(size + (size >>> 1) + 1); - int sc; - while ((sc = sizeCtl) >= 0) { - Node[] tab = table; - int n; - if (tab == null || (n = tab.length) == 0) { - n = Math.max(sc, c); - if (Unsafe.getUnsafe().compareAndSwapInt(this, SIZECTL, sc, -1)) { - try { - if (table == tab) { - @SuppressWarnings("unchecked") - Node[] nt = (Node[]) new Node[n]; - table = nt; - sc = n - (n >>> 2); - } - } finally { - sizeCtl = sc; - } - } - } else if (c <= sc || n >= MAXIMUM_CAPACITY) - break; - else if (tab == table) { - int rs = resizeStamp(n); - if (sc < 0) { - Node[] nt; - if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || - sc == rs + MAX_RESIZERS || (nt = nextTable) == null || - transferIndex <= 0) - break; - if (Unsafe.getUnsafe().compareAndSwapInt(this, SIZECTL, sc, sc + 1)) - transfer(tab, nt); - } else if (Unsafe.getUnsafe().compareAndSwapInt(this, SIZECTL, sc, - (rs << RESIZE_STAMP_SHIFT) + 2)) - transfer(tab, null); - } - } - } - - static int advanceProbe(int probe) { - probe ^= probe << 13; // xorshift - probe ^= probe >>> 17; - probe ^= probe << 5; - Unsafe.getUnsafe().putInt(Thread.currentThread(), PROBE, probe); - return probe; - } - - /* ---------------- TreeNodes -------------- */ - - static boolean casTabAt(Node[] tab, int i, - Node v) { - return Unsafe.getUnsafe().compareAndSwapObject(tab, ((long) i << ASHIFT) + ABASE, null, v); - } - - /* ---------------- TreeBins -------------- */ - - /** - * Returns x's Class if it is of the form "class C implements - * Comparable", else null. - */ - static Class comparableClassFor(Object x) { - if (x instanceof Comparable) { - Class c; - Type[] ts, as; - Type t; - ParameterizedType p; - if ((c = x.getClass()) == String.class) // bypass checks - return c; - if ((ts = c.getGenericInterfaces()) != null) { - for (int i = 0; i < ts.length; ++i) { - if (((t = ts[i]) instanceof ParameterizedType) && - ((p = (ParameterizedType) t).getRawType() == - Comparable.class) && - (as = p.getActualTypeArguments()) != null && - as.length == 1 && as[0] == c) // type arg is c - return c; - } - } - } - return null; - } - - /* ----------------Table Traversal -------------- */ - - /** - * Returns k.compareTo(x) if x matches kc (k's screened comparable - * class), else 0. - */ - @SuppressWarnings({"rawtypes", "unchecked"}) // for cast to Comparable - static int compareComparables(Class kc, Object k, Object x) { - return (x == null || x.getClass() != kc ? 0 : - ((Comparable) k).compareTo(x)); - } - - static int getProbe() { - return Unsafe.getUnsafe().getInt(Thread.currentThread(), PROBE); - } - - /** - * Initialize Thread fields for the current thread. Called only - * when Thread.threadLocalRandomProbe is zero, indicating that a - * thread local seed value needs to be generated. Note that even - * though the initialization is purely thread-local, we need to - * rely on (static) atomic generators to initialize the values. - */ - static void localInit() { - int p = probeGenerator.addAndGet(PROBE_INCREMENT); - int probe = (p == 0) ? 1 : p; // skip 0 - long seed = mix64(seeder.getAndAdd(SEEDER_INCREMENT)); - Thread t = Thread.currentThread(); - Unsafe.getUnsafe().putLong(t, SEED, seed); - Unsafe.getUnsafe().putInt(t, PROBE, probe); - } - - /** - * Returns the stamp bits for resizing a table of size n. - * Must be negative when shifted left by RESIZE_STAMP_SHIFT. - */ - static int resizeStamp(int n) { - return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1)); - } - - static void setTabAt(Node[] tab, int i, Node v) { - Unsafe.getUnsafe().putObjectVolatile(tab, ((long) i << ASHIFT) + ABASE, v); - } - - /** - * Spreads (XORs) higher bits of hash to lower and also forces top - * bit to 0. Because the table uses power-of-two masking, sets of - * hashes that vary only in bits above the current mask will - * always collide. (Among known examples are sets of Float keys - * holding consecutive whole numbers in small tables.) So we - * apply a transform that spreads the impact of higher bits - * downward. There is a tradeoff between speed, utility, and - * quality of bit-spreading. Because many common sets of hashes - * are already reasonably distributed (so don't benefit from - * spreading), and because we use trees to handle large sets of - * collisions in bins, we just XOR some shifted bits in the - * cheapest possible way to reduce systematic lossage, as well as - * to incorporate impact of the highest bits that would otherwise - * never be used in index calculations because of table bounds. - */ - static int spread(int h) { - return (h ^ (h >>> 16)) & HASH_BITS; - } - - @SuppressWarnings("unchecked") - static Node tabAt(Node[] tab, int i) { - return (Node) Unsafe.getUnsafe().getObjectVolatile(tab, ((long) i << ASHIFT) + ABASE); - } - - /* ----------------Views -------------- */ - - /** - * Returns a list on non-TreeNodes replacing those in given list. - */ - static Node untreeify(Node b) { - Node hd = null, tl = null; - for (Node q = b; q != null; q = q.next) { - Node p = new Node<>(q.hash, q.key, q.val, null, q.ics); - if (tl == null) - hd = p; - else - tl.next = p; - tl = p; - } - return hd; - } - - /** - * Helps transfer if a resize is in progress. - */ - final Node[] helpTransfer(Node[] tab, Node f) { - Node[] nextTab; - int sc; - if (tab != null && (f instanceof ForwardingNode) && - (nextTab = ((ForwardingNode) f).nextTable) != null) { - int rs = resizeStamp(tab.length); - while (nextTab == nextTable && table == tab && - (sc = sizeCtl) < 0) { - if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || - sc == rs + MAX_RESIZERS || transferIndex <= 0) - break; - if (Unsafe.getUnsafe().compareAndSwapInt(this, SIZECTL, sc, sc + 1)) { - transfer(tab, nextTab); - break; - } - } - return nextTab; - } - return table; - } - - /** - * Implementation for put and putIfAbsent - */ - final V putVal(CharSequence key, V value, boolean onlyIfAbsent) { - if (key == null || value == null) throw new NullPointerException(); - int hash = spread(keyHashCode(key)); - int binCount = 0; - Node _new = null; - - for (Node[] tab = table; ; ) { - Node f; - int n, i, fh; - if (tab == null || (n = tab.length) == 0) - tab = initTable(); - else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { - if (_new == null) { - _new = new Node<>(hash, maybeCopyKey(key), value, null, ics); - } - if (casTabAt(tab, i, _new)) { - break; // no lock when adding to empty bin - } - } else if ((fh = f.hash) == MOVED) - tab = helpTransfer(tab, f); - else { - V oldVal = null; - synchronized (f) { - if (tabAt(tab, i) == f) { - if (fh >= 0) { - binCount = 1; - for (Node e = f; ; ++binCount) { - CharSequence ek; - if (e.hash == hash && - ((ek = e.key) == key || - (ek != null && keyEquals(key, ek)))) { - oldVal = e.val; - if (!onlyIfAbsent) - e.val = value; - break; - } - Node pred = e; - if ((e = e.next) == null) { - if (_new == null) { - pred.next = new Node<>(hash, maybeCopyKey(key), value, null, ics); - } else { - pred.next = _new; - } - break; - } - } - } else if (f instanceof TreeBin) { - Node p; - binCount = 2; - if ((p = ((TreeBin) f).putTreeVal(hash, maybeCopyKey(key), value)) != null) { - oldVal = p.val; - if (!onlyIfAbsent) - p.val = value; - } - } - } - } - if (binCount != 0) { - if (binCount >= TREEIFY_THRESHOLD) - treeifyBin(tab, i); - if (oldVal != null) - return oldVal; - break; - } - } - } - addCount(1L, binCount); - return null; - } - - /** - * Implementation for the four public remove/replace methods: - * Replaces node value with v, conditional upon match of cv if - * non-null. If resulting value is null, delete. - */ - final V replaceNode(CharSequence key, V value, V cv) { - int hash = spread(keyHashCode(key)); - for (Node[] tab = table; ; ) { - Node f; - int n, i, fh; - if (tab == null || (n = tab.length) == 0 || - (f = tabAt(tab, i = (n - 1) & hash)) == null) - break; - else if ((fh = f.hash) == MOVED) - tab = helpTransfer(tab, f); - else { - V oldVal = null; - boolean validated = false; - synchronized (f) { - if (tabAt(tab, i) == f) { - if (fh >= 0) { - validated = true; - for (Node e = f, pred = null; ; ) { - CharSequence ek; - if (e.hash == hash && - ((ek = e.key) == key || - (ek != null && keyEquals(key, ek)))) { - V ev = e.val; - if (cv == null || cv == ev || (cv.equals(ev))) { - oldVal = ev; - if (value != null) - e.val = value; - else if (pred != null) - pred.next = e.next; - else - setTabAt(tab, i, e.next); - } - break; - } - pred = e; - if ((e = e.next) == null) - break; - } - } else if (f instanceof TreeBin) { - validated = true; - TreeBin t = (TreeBin) f; - TreeNode r, p; - if ((r = t.root) != null && - (p = r.findTreeNode(hash, key, null)) != null) { - V pv = p.val; - if (cv == null || cv == pv || cv.equals(pv)) { - oldVal = pv; - if (value != null) - p.val = value; - else if (t.removeTreeNode(p)) - setTabAt(tab, i, untreeify(t.first)); - } - } - } - } - } - if (validated) { - if (oldVal != null) { - if (value == null) - addCount(-1L, -1); - return oldVal; - } - break; - } - } - } - return null; - } - - final long sumCount() { - CounterCell[] as = counterCells; - CounterCell a; - long sum = baseCount; - if (as != null) { - for (int i = 0; i < as.length; ++i) { - if ((a = as[i]) != null) - sum += a.value; - } - } - return sum; - } - - /** - * Base of key, value, and entry Iterators. Adds fields to - * Traverser to support iterator.remove. - */ - static class BaseIterator extends Traverser { - Node lastReturned; - ConcurrentHashMap map; - - public final boolean hasNext() { - return next != null; - } - - public final void remove() { - Node p; - if ((p = lastReturned) == null) - throw new IllegalStateException(); - lastReturned = null; - map.replaceNode(p.key, null, null); - } - - void of(ConcurrentHashMap map) { - Node[] tab = map.table; - int l = tab == null ? 0 : tab.length; - super.of(tab, l, l); - this.map = map; - advance(); - } - } - - /** - * Base class for views. - */ - abstract static class CollectionView - implements Collection, java.io.Serializable { - private static final String oomeMsg = "Required array size too large"; - private static final long serialVersionUID = 7249069246763182397L; - final ConcurrentHashMap map; - - CollectionView(ConcurrentHashMap map) { - this.map = map; - } - - /** - * Removes all of the elements from this view, by removing all - * the mappings from the map backing this view. - */ - public final void clear() { - map.clear(); - } - - public abstract boolean contains(Object o); - - public final boolean containsAll(@NotNull Collection c) { - if (c != this) { - for (Object e : c) { - if (e == null || !contains(e)) - return false; - } - } - return true; - } - - /** - * Returns the map backing this view. - * - * @return the map backing this view - */ - public ConcurrentHashMap getMap() { - return map; - } - - public final boolean isEmpty() { - return map.isEmpty(); - } - - /** - * Returns an iterator over the elements in this collection. - *

    The returned iterator is - * weakly consistent. - * - * @return an iterator over the elements in this collection - */ - @NotNull - public abstract Iterator iterator(); - - public abstract boolean remove(Object o); - - public final boolean removeAll(@NotNull Collection c) { - boolean modified = false; - for (Iterator it = iterator(); it.hasNext(); ) { - if (c.contains(it.next())) { - it.remove(); - modified = true; - } - } - return modified; - } - - - // implementations below rely on concrete classes supplying these - // abstract methods - - public final boolean retainAll(@NotNull Collection c) { - boolean modified = false; - for (Iterator it = iterator(); it.hasNext(); ) { - if (!c.contains(it.next())) { - it.remove(); - modified = true; - } - } - return modified; - } - - public final int size() { - return map.size(); - } - - @NotNull - public final Object[] toArray() { - long sz = map.mappingCount(); - if (sz > MAX_ARRAY_SIZE) - throw new OutOfMemoryError(oomeMsg); - int n = (int) sz; - Object[] r = new Object[n]; - int i = 0; - for (E e : this) { - if (i == n) { - if (n >= MAX_ARRAY_SIZE) - throw new OutOfMemoryError(oomeMsg); - if (n >= MAX_ARRAY_SIZE - (MAX_ARRAY_SIZE >>> 1) - 1) - n = MAX_ARRAY_SIZE; - else - n += (n >>> 1) + 1; - r = Arrays.copyOf(r, n); - } - r[i++] = e; - } - return (i == n) ? r : Arrays.copyOf(r, i); - } - - @NotNull - @SuppressWarnings("unchecked") - public final T[] toArray(@NotNull T[] a) { - long sz = map.mappingCount(); - if (sz > MAX_ARRAY_SIZE) - throw new OutOfMemoryError(oomeMsg); - int m = (int) sz; - T[] r = (a.length >= m) ? a : - (T[]) java.lang.reflect.Array - .newInstance(a.getClass().getComponentType(), m); - int n = r.length; - int i = 0; - for (E e : this) { - if (i == n) { - if (n >= MAX_ARRAY_SIZE) - throw new OutOfMemoryError(oomeMsg); - if (n >= MAX_ARRAY_SIZE - (MAX_ARRAY_SIZE >>> 1) - 1) - n = MAX_ARRAY_SIZE; - else - n += (n >>> 1) + 1; - r = Arrays.copyOf(r, n); - } - r[i++] = (T) e; - } - if (a == r && i < n) { - r[i] = null; // null-terminate - return r; - } - return (i == n) ? r : Arrays.copyOf(r, i); - } - - /** - * Returns a string representation of this collection. - * The string representation consists of the string representations - * of the collection's elements in the order they are returned by - * its iterator, enclosed in square brackets ({@code "[]"}). - * Adjacent elements are separated by the characters {@code ", "} - * (comma and space). Elements are converted to strings as by - * {@link String#valueOf(Object)}. - * - * @return a string representation of this collection - */ - public final String toString() { - StringBuilder sb = new StringBuilder(); - sb.append('['); - Iterator it = iterator(); - if (it.hasNext()) { - for (; ; ) { - Object e = it.next(); - sb.append(e == this ? "(this Collection)" : e); - if (!it.hasNext()) - break; - sb.append(',').append(' '); - } - } - return sb.append(']').toString(); - } - - } - - /** - * A padded cell for distributing counts. Adapted from LongAdder - * and Striped64. See their internal docs for explanation. - */ - static final class CounterCell { - final long value; - - CounterCell(long x) { - value = x; - } - } - - static final class EntryIterator extends BaseIterator - implements Iterator> { - - public Map.Entry next() { - Node p; - if ((p = next) == null) - throw new NoSuchElementException(); - CharSequence k = p.key; - V v = p.val; - lastReturned = p; - advance(); - return new MapEntry<>(k, v, map); - } - } - - /** - * A view of a ConcurrentHashMap as a {@link Set} of (key, value) - * entries. This class cannot be directly instantiated. See - * {@link #entrySet()}. - */ - static final class EntrySetView extends CollectionView> - implements Set>, java.io.Serializable { - private static final long serialVersionUID = 2249069246763182397L; - - private final ThreadLocal> tlEntryIterator = ThreadLocal.withInitial(EntryIterator::new); - - EntrySetView(ConcurrentHashMap map) { - super(map); - } - - public boolean add(Entry e) { - return map.putVal(e.getKey(), e.getValue(), false) == null; - } - - public boolean addAll(@NotNull Collection> c) { - boolean added = false; - for (Entry e : c) { - if (add(e)) - added = true; - } - return added; - } - - public boolean contains(Object o) { - Object k, v, r; - Map.Entry e; - return ((o instanceof Map.Entry) && - (k = (e = (Map.Entry) o).getKey()) != null && - (r = map.get(k)) != null && - (v = e.getValue()) != null && - (v == r || v.equals(r))); - } - - public boolean equals(Object o) { - Set c; - return ((o instanceof Set) && - ((c = (Set) o) == this || - (containsAll(c) && c.containsAll(this)))); - } - - public int hashCode() { - int h = 0; - Node[] t = map.table; - if (t != null) { - Traverser it = map.getTraverser(t); - for (Node p; (p = it.advance()) != null; ) { - h += p.hashCode(); - } - } - return h; - } - - /** - * @return an iterator over the entries of the backing map - */ - @NotNull - public Iterator> iterator() { - EntryIterator it = tlEntryIterator.get(); - it.of(map); - return it; - } - - public boolean remove(Object o) { - Object k, v; - Map.Entry e; - return ((o instanceof Map.Entry) && - (k = (e = (Map.Entry) o).getKey()) != null && - (v = e.getValue()) != null && - map.remove(k, v)); - } - } - - /** - * A node inserted at head of bins during transfer operations. - */ - static final class ForwardingNode extends Node { - final Node[] nextTable; - - ForwardingNode(Node[] tab, boolean ics) { - super(MOVED, null, null, null, ics); - this.nextTable = tab; - } - - Node find(int h, CharSequence k) { - // loop to avoid arbitrarily deep recursion on forwarding nodes - outer: - for (Node[] tab = nextTable; ; ) { - Node e; - int n; - if (k == null || tab == null || (n = tab.length) == 0 || - (e = tabAt(tab, (n - 1) & h)) == null) - return null; - for (; ; ) { - int eh; - CharSequence ek; - if ((eh = e.hash) == h && - ((ek = e.key) == k || (ek != null && keyEquals(k, ek, ics)))) - return e; - if (eh < 0) { - if (e instanceof ForwardingNode) { - tab = ((ForwardingNode) e).nextTable; - continue outer; - } else - return e.find(h, k); - } - if ((e = e.next) == null) - return null; - } - } - } - } - - static final class KeyIterator extends BaseIterator - implements Iterator { - - public CharSequence next() { - Node p; - if ((p = next) == null) - throw new NoSuchElementException(); - CharSequence k = p.key; - lastReturned = p; - advance(); - return k; - } - } - - /** - * A view of a ConcurrentHashMap as a {@link Set} of keys, in - * which additions may optionally be enabled by mapping to a - * common value. This class cannot be directly instantiated. - * See {@link #keySet() keySet()}, - * {@link #keySet(Object) keySet(V)}, - * {@link #newKeySet() newKeySet()}, - * {@link #newKeySet(int) newKeySet(int)}. - * - * @since 1.8 - */ - public static class KeySetView extends CollectionView - implements Set, java.io.Serializable { - private static final long serialVersionUID = 7249069246763182397L; - private final ThreadLocal> tlKeyIterator = ThreadLocal.withInitial(KeyIterator::new); - private final V value; - - KeySetView(ConcurrentHashMap map, V value) { // non-public - super(map); - this.value = value; - } - - /** - * Adds the specified key to this set view by mapping the key to - * the default mapped value in the backing map, if defined. - * - * @param e key to be added - * @return {@code true} if this set changed as a result of the call - * @throws NullPointerException if the specified key is null - * @throws UnsupportedOperationException if no default mapped value - * for additions was provided - */ - public boolean add(CharSequence e) { - V v; - if ((v = value) == null) - throw new UnsupportedOperationException(); - return map.putVal(e, v, true) == null; - } - - /** - * Adds all of the elements in the specified collection to this set, - * as if by calling {@link #add} on each one. - * - * @param c the elements to be inserted into this set - * @return {@code true} if this set changed as a result of the call - * @throws NullPointerException if the collection or any of its - * elements are {@code null} - * @throws UnsupportedOperationException if no default mapped value - * for additions was provided - */ - public boolean addAll(@NotNull Collection c) { - boolean added = false; - V v; - if ((v = value) == null) - throw new UnsupportedOperationException(); - for (CharSequence e : c) { - if (map.putVal(e, v, true) == null) - added = true; - } - return added; - } - - /** - * {@inheritDoc} - * - * @throws NullPointerException if the specified key is null - */ - public boolean contains(Object o) { - return map.containsKey(o); - } - - public boolean equals(Object o) { - Set c; - return ((o instanceof Set) && - ((c = (Set) o) == this || - (containsAll(c) && c.containsAll(this)))); - } - - /** - * Returns the default mapped value for additions, - * or {@code null} if additions are not supported. - * - * @return the default mapped value for additions, or {@code null} - * if not supported - */ - public V getMappedValue() { - return value; - } - - public int hashCode() { - int h = 0; - for (CharSequence e : this) - h += e.hashCode(); - return h; - } - - /** - * @return an iterator over the keys of the backing map - */ - @NotNull - public Iterator iterator() { - KeyIterator it = tlKeyIterator.get(); - it.of(map); - return it; - } - - /** - * Removes the key from this map view, by removing the key (and its - * corresponding value) from the backing map. This method does - * nothing if the key is not in the map. - * - * @param o the key to be removed from the backing map - * @return {@code true} if the backing map contained the specified key - * @throws NullPointerException if the specified key is null - */ - public boolean remove(Object o) { - return map.remove(o) != null; - } - } - - /** - * Exported Entry for EntryIterator - */ - static final class MapEntry implements Map.Entry { - final CharSequence key; // non-null - final ConcurrentHashMap map; - V val; // non-null - - MapEntry(CharSequence key, V val, ConcurrentHashMap map) { - this.key = key; - this.val = val; - this.map = map; - } - - public boolean equals(Object o) { - Object k, v; - Map.Entry e; - return ((o instanceof Map.Entry) && - (k = (e = (Map.Entry) o).getKey()) != null && - (v = e.getValue()) != null && - (k == key || map.keyEquals((CharSequence) k, key)) && - (v == val || v.equals(val))); - } - - public CharSequence getKey() { - return key; - } - - public V getValue() { - return val; - } - - public int hashCode() { - return map.keyHashCode(key) ^ val.hashCode(); - } - - /** - * Sets our entry's value and writes through to the map. The - * value to return is somewhat arbitrary here. Since we do not - * necessarily track asynchronous changes, the most recent - * "previous" value could be different from what we return (or - * could even have been removed, in which case the put will - * re-establish). We do not and cannot guarantee more. - */ - public V setValue(V value) { - if (value == null) throw new NullPointerException(); - V v = val; - val = value; - map.put(key, value); - return v; - } - - public String toString() { - return key + "=" + val; - } - } - - /** - * Key-value entry. This class is never exported out as a - * user-mutable Map.Entry (i.e., one supporting setValue; see - * MapEntry below), but can be used for read-only traversals used - * in bulk tasks. Subclasses of Node with a negative hash field - * are special, and contain null keys and values (but are never - * exported). Otherwise, keys and vals are never null. - */ - static class Node implements Map.Entry { - final int hash; - final boolean ics; - final CharSequence key; - volatile Node next; - volatile V val; - - Node(int hash, CharSequence key, V val, Node next, boolean ics) { - this.hash = hash; - this.key = key; - this.val = val; - this.next = next; - this.ics = ics; - } - - public final boolean equals(Object o) { - Object k, v, u; - Map.Entry e; - return ((o instanceof Map.Entry) && - (k = (e = (Map.Entry) o).getKey()) != null && - (v = e.getValue()) != null && - (k == key || keyEquals((CharSequence) k, key, ics)) && - (v == (u = val) || v.equals(u))); - } - - public final CharSequence getKey() { - return key; - } - - public final V getValue() { - return val; - } - - public final int hashCode() { - return keyHashCode(key, ics) ^ val.hashCode(); - } - - public final V setValue(V value) { - throw new UnsupportedOperationException(); - } - - public final String toString() { - return key + "=" + val; - } - - /** - * Virtualized support for map.get(); overridden in subclasses. - */ - Node find(int h, CharSequence k) { - Node e = this; - if (k != null) { - do { - CharSequence ek; - if (e.hash == h && - ((ek = e.key) == k || (ek != null && keyEquals(k, ek, ics)))) - return e; - } while ((e = e.next) != null); - } - return null; - } - } - - /** - * A place-holder node used in computeIfAbsent and compute - */ - static final class ReservationNode extends Node { - ReservationNode(boolean ics) { - super(RESERVED, null, null, null, ics); - } - - Node find(int h, Object k) { - return null; - } - } - - /** - * Stripped-down version of helper class used in previous version, - * declared for the sake of serialization compatibility - */ - static class Segment extends ReentrantLock implements Serializable { - private static final long serialVersionUID = 2249069246763182397L; - final float loadFactor; - - Segment() { - this.loadFactor = ConcurrentHashMap.LOAD_FACTOR; - } - } - - /** - * Records the table, its length, and current traversal index for a - * traverser that must process a region of a forwarded table before - * proceeding with current table. - */ - static final class TableStack { - int index; - int length; - TableStack next; - Node[] tab; - } - - /** - * Encapsulates traversal for methods such as containsValue; also - * serves as a base class for other iterators and spliterators. - *

    - * Method advance visits once each still-valid node that was - * reachable upon iterator construction. It might miss some that - * were added to a bin after the bin was visited, which is OK wrt - * consistency guarantees. Maintaining this property in the face - * of possible ongoing resizes requires a fair amount of - * bookkeeping state that is difficult to optimize away amidst - * volatile accesses. Even so, traversal maintains reasonable - * throughput. - *

    - * Normally, iteration proceeds bin-by-bin traversing lists. - * However, if the table has been resized, then all future steps - * must traverse both the bin at the current index as well as at - * (index + baseSize); and so on for further resizings. To - * paranoically cope with potential sharing by users of iterators - * across threads, iteration terminates if a bounds checks fails - * for a table read. - */ - static class Traverser { - int baseIndex; // current index of initial table - int baseLimit; // index bound for initial table - int baseSize; // initial table size - int index; // index of bin to use next - Node next; // the next entry to use - TableStack stack, spare; // to save/restore on ForwardingNodes - Node[] tab; // current table; updated if resized - - /** - * Saves traversal state upon encountering a forwarding node. - */ - private void pushState(Node[] t, int i, int n) { - TableStack s = spare; // reuse if possible - if (s != null) - spare = s.next; - else - s = new TableStack<>(); - s.tab = t; - s.length = n; - s.index = i; - s.next = stack; - stack = s; - } - - /** - * Possibly pops traversal state. - * - * @param n length of current table - */ - private void recoverState(int n) { - TableStack s; - int len; - while ((s = stack) != null && (index += (len = s.length)) >= n) { - n = len; - index = s.index; - tab = s.tab; - s.tab = null; - TableStack next = s.next; - s.next = spare; // save for reuse - stack = next; - spare = s; - } - if (s == null && (index += baseSize) >= n) - index = ++baseIndex; - } - - /** - * Advances if possible, returning next valid node, or null if none. - */ - final Node advance() { - Node e; - if ((e = next) != null) - e = e.next; - for (; ; ) { - Node[] t; - int i, n; // must use locals in checks - if (e != null) - return next = e; - if (baseIndex >= baseLimit || (t = tab) == null || - (n = t.length) <= (i = index) || i < 0) - return next = null; - if ((e = tabAt(t, i)) != null && e.hash < 0) { - if (e instanceof ForwardingNode) { - tab = ((ForwardingNode) e).nextTable; - e = null; - pushState(t, i, n); - continue; - } else if (e instanceof TreeBin) - e = ((TreeBin) e).first; - else - e = null; - } - if (stack != null) - recoverState(n); - else if ((index = i + baseSize) >= n) - index = ++baseIndex; // visit upper slots if present - } - } - - void of(Node[] tab, int size, int limit) { - this.tab = tab; - this.baseSize = size; - this.baseIndex = this.index = 0; - this.baseLimit = limit; - this.next = null; - } - } - - /** - * TreeNodes used at the heads of bins. TreeBins do not hold user - * keys or values, but instead point to list of TreeNodes and - * their root. They also maintain a parasitic read-write lock - * forcing writers (who hold bin lock) to wait for readers (who do - * not) to complete before tree restructuring operations. - */ - static final class TreeBin extends Node { - static final int READER = 4; // increment value for setting read lock - static final int WAITER = 2; // set when waiting for write lock - // values for lockState - static final int WRITER = 1; // set while holding write lock - private static final long LOCKSTATE; - private static final sun.misc.Unsafe U; - volatile TreeNode first; - volatile int lockState; - TreeNode root; - volatile Thread waiter; - - /** - * Creates bin with initial set of nodes headed by b. - */ - TreeBin(TreeNode b, boolean ics) { - super(TREEBIN, null, null, null, ics); - this.first = b; - TreeNode r = null; - for (TreeNode x = b, next; x != null; x = next) { - next = (TreeNode) x.next; - x.left = x.right = null; - if (r == null) { - x.parent = null; - x.red = false; - r = x; - } else { - CharSequence k = x.key; - int h = x.hash; - Class kc = null; - for (TreeNode p = r; ; ) { - int dir, ph; - CharSequence pk = p.key; - if ((ph = p.hash) > h) - dir = -1; - else if (ph < h) - dir = 1; - else if ((kc == null && - (kc = comparableClassFor(k)) == null) || - (dir = compareComparables(kc, k, pk)) == 0) - dir = tieBreakOrder(k, pk); - TreeNode xp = p; - if ((p = (dir <= 0) ? p.left : p.right) == null) { - x.parent = xp; - if (dir <= 0) - xp.left = x; - else - xp.right = x; - r = balanceInsertion(r, x); - break; - } - } - } - } - this.root = r; - assert checkInvariants(root); - } - - /** - * Possibly blocks awaiting root lock. - */ - private void contendedLock() { - boolean waiting = false; - for (int s; ; ) { - if (((s = lockState) & ~WAITER) == 0) { - if (U.compareAndSwapInt(this, LOCKSTATE, s, WRITER)) { - if (waiting) - waiter = null; - return; - } - } else if ((s & WAITER) == 0) { - if (U.compareAndSwapInt(this, LOCKSTATE, s, s | WAITER)) { - waiting = true; - waiter = Thread.currentThread(); - } - } else if (waiting) - LockSupport.park(this); - } - } - - /** - * Acquires write lock for tree restructuring. - */ - private void lockRoot() { - if (!U.compareAndSwapInt(this, LOCKSTATE, 0, WRITER)) - contendedLock(); // offload to separate method - } - - /** - * Releases write lock for tree restructuring. - */ - private void unlockRoot() { - lockState = 0; - } - - static TreeNode balanceDeletion(TreeNode root, - TreeNode x) { - for (TreeNode xp, xpl, xpr; ; ) { - if (x == null || x == root) - return root; - else if ((xp = x.parent) == null) { - x.red = false; - return x; - } else if (x.red) { - x.red = false; - return root; - } else if ((xpl = xp.left) == x) { - if ((xpr = xp.right) != null && xpr.red) { - xpr.red = false; - xp.red = true; - root = rotateLeft(root, xp); - xpr = (xp = x.parent) == null ? null : xp.right; - } - if (xpr == null) - x = xp; - else { - TreeNode sl = xpr.left, sr = xpr.right; - if ((sr == null || !sr.red) && - (sl == null || !sl.red)) { - xpr.red = true; - x = xp; - } else { - if (sr == null || !sr.red) { - sl.red = false; - xpr.red = true; - root = rotateRight(root, xpr); - xpr = (xp = x.parent) == null ? - null : xp.right; - } - if (xpr != null) { - xpr.red = xp.red; - if ((sr = xpr.right) != null) - sr.red = false; - } - if (xp != null) { - xp.red = false; - root = rotateLeft(root, xp); - } - x = root; - } - } - } else { // symmetric - if (xpl != null && xpl.red) { - xpl.red = false; - xp.red = true; - root = rotateRight(root, xp); - xpl = (xp = x.parent) == null ? null : xp.left; - } - if (xpl == null) - x = xp; - else { - TreeNode sl = xpl.left, sr = xpl.right; - if ((sl == null || !sl.red) && - (sr == null || !sr.red)) { - xpl.red = true; - x = xp; - } else { - if (sl == null || !sl.red) { - sr.red = false; - xpl.red = true; - root = rotateLeft(root, xpl); - xpl = (xp = x.parent) == null ? - null : xp.left; - } - if (xpl != null) { - xpl.red = xp.red; - if ((sl = xpl.left) != null) - sl.red = false; - } - if (xp != null) { - xp.red = false; - root = rotateRight(root, xp); - } - x = root; - } - } - } - } - } - - static TreeNode balanceInsertion(TreeNode root, - TreeNode x) { - x.red = true; - for (TreeNode xp, xpp, xppl, xppr; ; ) { - if ((xp = x.parent) == null) { - x.red = false; - return x; - } else if (!xp.red || (xpp = xp.parent) == null) - return root; - if (xp == (xppl = xpp.left)) { - if ((xppr = xpp.right) != null && xppr.red) { - xppr.red = false; - xp.red = false; - xpp.red = true; - x = xpp; - } else { - if (x == xp.right) { - root = rotateLeft(root, x = xp); - xpp = (xp = x.parent) == null ? null : xp.parent; - } - if (xp != null) { - xp.red = false; - if (xpp != null) { - xpp.red = true; - root = rotateRight(root, xpp); - } - } - } - } else { - if (xppl != null && xppl.red) { - xppl.red = false; - xp.red = false; - xpp.red = true; - x = xpp; - } else { - if (x == xp.left) { - root = rotateRight(root, x = xp); - xpp = (xp = x.parent) == null ? null : xp.parent; - } - if (xp != null) { - xp.red = false; - if (xpp != null) { - xpp.red = true; - root = rotateLeft(root, xpp); - } - } - } - } - } - } - - /** - * Recursive invariant check - */ - @SuppressWarnings("SimplifiableIfStatement") - static boolean checkInvariants(TreeNode t) { - TreeNode tp = t.parent, tl = t.left, tr = t.right, - tb = t.prev, tn = (TreeNode) t.next; - if (tb != null && tb.next != t) - return false; - if (tn != null && tn.prev != t) - return false; - if (tp != null && t != tp.left && t != tp.right) - return false; - if (tl != null && (tl.parent != t || tl.hash > t.hash)) - return false; - if (tr != null && (tr.parent != t || tr.hash < t.hash)) - return false; - if (t.red && tl != null && tl.red && tr != null && tr.red) - return false; - if (tl != null && !checkInvariants(tl)) - return false; - return !(tr != null && !checkInvariants(tr)); - } - - static TreeNode rotateLeft(TreeNode root, TreeNode p) { - TreeNode r, pp, rl; - if (p != null && (r = p.right) != null) { - if ((rl = p.right = r.left) != null) - rl.parent = p; - if ((pp = r.parent = p.parent) == null) - (root = r).red = false; - else if (pp.left == p) - pp.left = r; - else - pp.right = r; - r.left = p; - p.parent = r; - } - return root; - } - - static TreeNode rotateRight(TreeNode root, TreeNode p) { - TreeNode l, pp, lr; - if (p != null && (l = p.left) != null) { - if ((lr = p.left = l.right) != null) - lr.parent = p; - if ((pp = l.parent = p.parent) == null) - (root = l).red = false; - else if (pp.right == p) - pp.right = l; - else - pp.left = l; - l.right = p; - p.parent = l; - } - return root; - } - - /** - * Tie-breaking utility for ordering insertions when equal - * hashCodes and non-comparable. We don't require a total - * order, just a consistent insertion rule to maintain - * equivalence across rebalancings. Tie-breaking further than - * necessary simplifies testing a bit. - */ - static int tieBreakOrder(Object a, Object b) { - int d; - if (a == null || b == null || - (d = a.getClass().getName(). - compareTo(b.getClass().getName())) == 0) - d = (System.identityHashCode(a) <= System.identityHashCode(b) ? - -1 : 1); - return d; - } - - /** - * Returns matching node or null if none. Tries to search - * using tree comparisons from root, but continues linear - * search when lock not available. - */ - Node find(int h, CharSequence k) { - if (k != null) { - for (Node e = first; e != null; ) { - int s; - CharSequence ek; - if (((s = lockState) & (WAITER | WRITER)) != 0) { - if (e.hash == h && - ((ek = e.key) == k || (ek != null && keyEquals(k, ek, ics)))) - return e; - e = e.next; - } else if (U.compareAndSwapInt(this, LOCKSTATE, s, - s + READER)) { - TreeNode r, p; - try { - p = ((r = root) == null ? null : - r.findTreeNode(h, k, null)); - } finally { - Thread w; - if (U.getAndAddInt(this, LOCKSTATE, -READER) == - (READER | WAITER) && (w = waiter) != null) - LockSupport.unpark(w); - } - return p; - } - } - } - return null; - } - - /** - * Finds or adds a node. - * - * @return null if added - */ - TreeNode putTreeVal(int h, CharSequence k, V v) { - Class kc = null; - boolean searched = false; - for (TreeNode p = root; ; ) { - int dir, ph; - CharSequence pk; - if (p == null) { - first = root = new TreeNode<>(h, k, v, null, null, ics); - break; - } else if ((ph = p.hash) > h) - dir = -1; - else if (ph < h) - dir = 1; - else if ((pk = p.key) == k || (pk != null && keyEquals(k, pk, ics))) - return p; - else if ((kc == null && - (kc = comparableClassFor(k)) == null) || - (dir = compareComparables(kc, k, pk)) == 0) { - if (!searched) { - TreeNode q, ch; - searched = true; - if (((ch = p.left) != null && - (q = ch.findTreeNode(h, k, kc)) != null) || - ((ch = p.right) != null && - (q = ch.findTreeNode(h, k, kc)) != null)) - return q; - } - dir = tieBreakOrder(k, pk); - } - - TreeNode xp = p; - if ((p = (dir <= 0) ? p.left : p.right) == null) { - TreeNode x, f = first; - first = x = new TreeNode<>(h, k, v, f, xp, ics); - if (f != null) - f.prev = x; - if (dir <= 0) - xp.left = x; - else - xp.right = x; - if (!xp.red) - x.red = true; - else { - lockRoot(); - try { - root = balanceInsertion(root, x); - } finally { - unlockRoot(); - } - } - break; - } - } - assert checkInvariants(root); - return null; - } - - /** - * Removes the given node, that must be present before this - * call. This is messier than typical red-black deletion code - * because we cannot swap the contents of an interior node - * with a leaf successor that is pinned by "next" pointers - * that are accessible independently of lock. So instead we - * swap the tree linkages. - * - * @return true if now too small, so should be untreeified - */ - boolean removeTreeNode(TreeNode p) { - TreeNode next = (TreeNode) p.next; - TreeNode pred = p.prev; // unlink traversal pointers - TreeNode r, rl; - if (pred == null) - first = next; - else - pred.next = next; - if (next != null) - next.prev = pred; - if (first == null) { - root = null; - return true; - } - if ((r = root) == null || r.right == null || // too small - (rl = r.left) == null || rl.left == null) - return true; - lockRoot(); - try { - TreeNode replacement; - TreeNode pl = p.left; - TreeNode pr = p.right; - if (pl != null && pr != null) { - TreeNode s = pr, sl; - while ((sl = s.left) != null) // find successor - s = sl; - boolean c = s.red; - s.red = p.red; - p.red = c; // swap colors - TreeNode sr = s.right; - TreeNode pp = p.parent; - if (s == pr) { // p was s's direct parent - p.parent = s; - s.right = p; - } else { - TreeNode sp = s.parent; - if ((p.parent = sp) != null) { - if (s == sp.left) - sp.left = p; - else - sp.right = p; - } - s.right = pr; - pr.parent = s; - } - p.left = null; - if ((p.right = sr) != null) - sr.parent = p; - s.left = pl; - pl.parent = s; - if ((s.parent = pp) == null) - r = s; - else if (p == pp.left) - pp.left = s; - else - pp.right = s; - if (sr != null) - replacement = sr; - else - replacement = p; - } else if (pl != null) - replacement = pl; - else if (pr != null) - replacement = pr; - else - replacement = p; - if (replacement != p) { - TreeNode pp = replacement.parent = p.parent; - if (pp == null) - r = replacement; - else if (p == pp.left) - pp.left = replacement; - else - pp.right = replacement; - p.left = p.right = p.parent = null; - } - - root = (p.red) ? r : balanceDeletion(r, replacement); - - if (p == replacement) { // detach pointers - TreeNode pp; - if ((pp = p.parent) != null) { - if (p == pp.left) - pp.left = null; - else if (p == pp.right) - pp.right = null; - p.parent = null; - } - } - } finally { - unlockRoot(); - } - assert checkInvariants(root); - return false; - } - - static { - try { - U = Unsafe.getUnsafe(); - Class k = TreeBin.class; - LOCKSTATE = U.objectFieldOffset - (k.getDeclaredField("lockState")); - } catch (Exception e) { - throw new Error(e); - } - } - - - - - - - - - - - - - - - - /* ------------------------------------------------------------ */ - // Red-black tree methods, all adapted from CLR - } - - /** - * Nodes for use in TreeBins - */ - static final class TreeNode extends Node { - TreeNode left; - TreeNode parent; // red-black tree links - TreeNode prev; // needed to unlink next upon deletion - boolean red; - TreeNode right; - - TreeNode(int hash, CharSequence key, V val, Node next, - TreeNode parent, boolean ics) { - super(hash, key, val, next, ics); - this.parent = parent; - } - - Node find(int h, CharSequence k) { - return findTreeNode(h, k, null); - } - - /** - * Returns the TreeNode (or null if not found) for the given key - * starting at given root. - */ - TreeNode findTreeNode(int h, CharSequence k, Class kc) { - if (k != null) { - TreeNode p = this; - do { - int ph, dir; - CharSequence pk; - TreeNode q; - TreeNode pl = p.left, pr = p.right; - if ((ph = p.hash) > h) - p = pl; - else if (ph < h) - p = pr; - else if ((pk = p.key) == k || (pk != null && keyEquals(k, pk, ics))) - return p; - else if (pl == null) - p = pr; - else if (pr == null) - p = pl; - else if ((kc != null || - (kc = comparableClassFor(k)) != null) && - (dir = compareComparables(kc, k, pk)) != 0) - p = (dir < 0) ? pl : pr; - else if ((q = pr.findTreeNode(h, k, kc)) != null) - return q; - else - p = pl; - } while (p != null); - } - return null; - } - - - } - - static final class ValueIterator extends BaseIterator - implements Iterator { - public V next() { - Node p; - if ((p = next) == null) - throw new NoSuchElementException(); - V v = p.val; - lastReturned = p; - advance(); - return v; - } - } - - /** - * A view of a ConcurrentHashMap as a {@link Collection} of - * values, in which additions are disabled. This class cannot be - * directly instantiated. See {@link #values()}. - */ - static final class ValuesView extends CollectionView - implements Collection, java.io.Serializable { - private static final long serialVersionUID = 2249069246763182397L; - private final ThreadLocal> tlValueIterator = ThreadLocal.withInitial(ValueIterator::new); - - ValuesView(ConcurrentHashMap map) { - super(map); - } - - public boolean add(V e) { - throw new UnsupportedOperationException(); - } - - public boolean addAll(@NotNull Collection c) { - throw new UnsupportedOperationException(); - } - - public boolean contains(Object o) { - return map.containsValue(o); - } - - @NotNull - public Iterator iterator() { - ValueIterator it = tlValueIterator.get(); - it.of(map); - return it; - } - - public boolean remove(Object o) { - if (o != null) { - for (Iterator it = iterator(); it.hasNext(); ) { - if (o.equals(it.next())) { - it.remove(); - return true; - } - } - } - return false; - } - } - - static { - try { - Class tk = Thread.class; - SEED = Unsafe.getUnsafe().objectFieldOffset(tk.getDeclaredField("threadLocalRandomSeed")); - PROBE = Unsafe.getUnsafe().objectFieldOffset(tk.getDeclaredField("threadLocalRandomProbe")); - } catch (Exception e) { - throw new Error(e); - } - } - - static { - try { - Class k = ConcurrentHashMap.class; - SIZECTL = Unsafe.getUnsafe().objectFieldOffset - (k.getDeclaredField("sizeCtl")); - TRANSFERINDEX = Unsafe.getUnsafe().objectFieldOffset - (k.getDeclaredField("transferIndex")); - BASECOUNT = Unsafe.getUnsafe().objectFieldOffset - (k.getDeclaredField("baseCount")); - CELLSBUSY = Unsafe.getUnsafe().objectFieldOffset - (k.getDeclaredField("cellsBusy")); - Class ck = CounterCell.class; - CELLVALUE = Unsafe.getUnsafe().objectFieldOffset - (ck.getDeclaredField("value")); - Class ak = Node[].class; - ABASE = Unsafe.getUnsafe().arrayBaseOffset(ak); - int scale = Unsafe.getUnsafe().arrayIndexScale(ak); - if ((scale & (scale - 1)) != 0) - throw new Error("data type scale not a power of two"); - ASHIFT = 31 - Integer.numberOfLeadingZeros(scale); - } catch (Exception e) { - throw new Error(e); - } - } -} diff --git a/core/src/main/java/io/questdb/client/std/ConcurrentIntHashMap.java b/core/src/main/java/io/questdb/client/std/ConcurrentIntHashMap.java deleted file mode 100644 index f4dffe3..0000000 --- a/core/src/main/java/io/questdb/client/std/ConcurrentIntHashMap.java +++ /dev/null @@ -1,3612 +0,0 @@ -/******************************************************************************* - * ___ _ ____ ____ - * / _ \ _ _ ___ ___| |_| _ \| __ ) - * | | | | | | |/ _ \/ __| __| | | | _ \ - * | |_| | |_| | __/\__ \ |_| |_| | |_) | - * \__\_\\__,_|\___||___/\__|____/|____/ - * - * Copyright (c) 2014-2019 Appsicle - * Copyright (c) 2019-2026 QuestDB - * - * Licensed 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. - * - ******************************************************************************/ - -package io.questdb.client.std; - -/* - * ORACLE PROPRIETARY/CONFIDENTIAL. Use is subject to license terms. - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - */ - -/* - * - * - * - * - * - * Written by Doug Lea with assistance from members of JCP JSR-166 - * Expert Group and released to the public domain, as explained at - * http://creativecommons.org/publicdomain/zero/1.0/ - */ - -import org.jetbrains.annotations.NotNull; - -import java.io.ObjectStreamField; -import java.io.Serializable; -import java.lang.ThreadLocal; -import java.lang.reflect.ParameterizedType; -import java.lang.reflect.Type; -import java.util.Arrays; -import java.util.Collection; -import java.util.Iterator; -import java.util.Map; -import java.util.NoSuchElementException; -import java.util.Set; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.locks.LockSupport; -import java.util.concurrent.locks.ReentrantLock; -import java.util.function.IntFunction; - -/** - * Same as {@link ConcurrentHashMap}, but with primitive type int keys. - */ -@SuppressWarnings("SynchronizationOnLocalVariableOrMethodParameter") -public class ConcurrentIntHashMap implements Serializable { - static final int EMPTY_KEY = Integer.MIN_VALUE; - - /* - * Overview: - * - * The primary design goal of this hash table is to maintain - * concurrent readability (typically method get(), but also - * iterators and related methods) while minimizing update - * contention. Secondary goals are to keep space consumption about - * the same or better than java.util.HashMap, and to support high - * initial insertion rates on an empty table by many threads. - * - * This map usually acts as a binned (bucketed) hash table. Each - * key-value mapping is held in a Node. Most nodes are instances - * of the basic Node class with hash, key, value, and next - * fields. However, various subclasses exist: TreeNodes are - * arranged in balanced trees, not lists. TreeBins hold the roots - * of sets of TreeNodes. ForwardingNodes are placed at the heads - * of bins during resizing. ReservationNodes are used as - * placeholders while establishing values in computeIfAbsent and - * related methods. The types TreeBin, ForwardingNode, and - * ReservationNode do not hold normal user keys, values, or - * hashes, and are readily distinguishable during search etc - * because they have negative hash fields and null key and value - * fields. (These special nodes are either uncommon or transient, - * so the impact of carrying around some unused fields is - * insignificant.) - * - * The table is lazily initialized to a power-of-two size upon the - * first insertion. Each bin in the table normally contains a - * list of Nodes (most often, the list has only zero or one Node). - * Table accesses require volatile/atomic reads, writes, and - * CASes. Because there is no other way to arrange this without - * adding further indirections, we use intrinsics - * (sun.misc.Unsafe) operations. - * - * We use the top (sign) bit of Node hash fields for control - * purposes -- it is available anyway because of addressing - * constraints. Nodes with negative hash fields are specially - * handled or ignored in map methods. - * - * Insertion (via put or its variants) of the first node in an - * empty bin is performed by just CASing it to the bin. This is - * by far the most common case for put operations under most - * key/hash distributions. Other update operations (insert, - * delete, and replace) require locks. We do not want to waste - * the space required to associate a distinct lock object with - * each bin, so instead use the first node of a bin list itself as - * a lock. Locking support for these locks relies on builtin - * "synchronized" monitors. - * - * Using the first node of a list as a lock does not by itself - * suffice though: When a node is locked, any update must first - * validate that it is still the first node after locking it, and - * retry if not. Because new nodes are always appended to lists, - * once a node is first in a bin, it remains first until deleted - * or the bin becomes invalidated (upon resizing). - * - * The main disadvantage of per-bin locks is that other update - * operations on other nodes in a bin list protected by the same - * lock can stall, for example when user equals() or mapping - * functions take a long time. However, statistically, under - * random hash codes, this is not a common problem. Ideally, the - * frequency of nodes in bins follows a Poisson distribution - * (http://en.wikipedia.org/wiki/Poisson_distribution) with a - * parameter of about 0.5 on average, given the resizing threshold - * of 0.75, although with a large variance because of resizing - * granularity. Ignoring variance, the expected occurrences of - * list size k are (exp(-0.5) * pow(0.5, k) / factorial(k)). The - * first values are: - * - * 0: 0.60653066 - * 1: 0.30326533 - * 2: 0.07581633 - * 3: 0.01263606 - * 4: 0.00157952 - * 5: 0.00015795 - * 6: 0.00001316 - * 7: 0.00000094 - * 8: 0.00000006 - * more: less than 1 in ten million - * - * Lock contention probability for two threads accessing distinct - * elements is roughly 1 / (8 * #elements) under random hashes. - * - * Actual hash code distributions encountered in practice - * sometimes deviate significantly from uniform randomness. This - * includes the case when N > (1<<30), so some keys MUST collide. - * Similarly for dumb or hostile usages in which multiple keys are - * designed to have identical hash codes or ones that differs only - * in masked-out high bits. So we use a secondary strategy that - * applies when the number of nodes in a bin exceeds a - * threshold. These TreeBins use a balanced tree to hold nodes (a - * specialized form of red-black trees), bounding search time to - * O(log N). Each search step in a TreeBin is at least twice as - * slow as in a regular list, but given that N cannot exceed - * (1<<64) (before running out of addresses) this bounds search - * steps, lock hold times, etc, to reasonable constants (roughly - * 100 nodes inspected per operation worst case) so long as keys - * are Comparable (which is very common -- String, Long, etc). - * TreeBin nodes (TreeNodes) also maintain the same "next" - * traversal pointers as regular nodes, so can be traversed in - * iterators in the same way. - * - * The table is resized when occupancy exceeds a percentage - * threshold (nominally, 0.75, but see below). Any thread - * noticing an overfull bin may assist in resizing after the - * initiating thread allocates and sets up the replacement array. - * However, rather than stalling, these other threads may proceed - * with insertions etc. The use of TreeBins shields us from the - * worst case effects of overfilling while resizes are in - * progress. Resizing proceeds by transferring bins, one by one, - * from the table to the next table. However, threads claim small - * blocks of indices to transfer (via field transferIndex) before - * doing so, reducing contention. A generation stamp in field - * sizeCtl ensures that resizings do not overlap. Because we are - * using power-of-two expansion, the elements from each bin must - * either stay at same index, or move with a power of two - * offset. We eliminate unnecessary node creation by catching - * cases where old nodes can be reused because their next fields - * won't change. On average, only about one-sixth of them need - * cloning when a table doubles. The nodes they replace will be - * garbage collectable as soon as they are no longer referenced by - * any reader thread that may be in the midst of concurrently - * traversing table. Upon transfer, the old table bin contains - * only a special forwarding node (with hash field "MOVED") that - * contains the next table as its key. On encountering a - * forwarding node, access and update operations restart, using - * the new table. - * - * Each bin transfer requires its bin lock, which can stall - * waiting for locks while resizing. However, because other - * threads can join in and help resize rather than contend for - * locks, average aggregate waits become shorter as resizing - * progresses. The transfer operation must also ensure that all - * accessible bins in both the old and new table are usable by any - * traversal. This is arranged in part by proceeding from the - * last bin (table.length - 1) up towards the first. Upon seeing - * a forwarding node, traversals (see class Traverser) arrange to - * move to the new table without revisiting nodes. To ensure that - * no intervening nodes are skipped even when moved out of order, - * a stack (see class TableStack) is created on first encounter of - * a forwarding node during a traversal, to maintain its place if - * later processing the current table. The need for these - * save/restore mechanics is relatively rare, but when one - * forwarding node is encountered, typically many more will be. - * So Traversers use a simple caching scheme to avoid creating so - * many new TableStack nodes. (Thanks to Peter Levart for - * suggesting use of a stack here.) - * - * The traversal scheme also applies to partial traversals of - * ranges of bins (via an alternate Traverser constructor) - * to support partitioned aggregate operations. Also, read-only - * operations give up if ever forwarded to a null table, which - * provides support for shutdown-style clearing, which is also not - * currently implemented. - * - * Lazy table initialization minimizes footprint until first use, - * and also avoids resizings when the first operation is from a - * putAll, constructor with map argument, or deserialization. - * These cases attempt to override the initial capacity settings, - * but harmlessly fail to take effect in cases of races. - * - * The element count is maintained using a specialization of - * LongAdder. We need to incorporate a specialization rather than - * just use a LongAdder in order to access implicit - * contention-sensing that leads to creation of multiple - * CounterCells. The counter mechanics avoid contention on - * updates but can encounter cache thrashing if read too - * frequently during concurrent access. To avoid reading so often, - * resizing under contention is attempted only upon adding to a - * bin already holding two or more nodes. Under uniform hash - * distributions, the probability of this occurring at threshold - * is around 13%, meaning that only about 1 in 8 puts check - * threshold (and after resizing, many fewer do so). - * - * TreeBins use a special form of comparison for search and - * related operations (which is the main reason we cannot use - * existing collections such as TreeMaps). TreeBins contain - * Comparable elements, but may contain others, as well as - * elements that are Comparable but not necessarily Comparable for - * the same T, so we cannot invoke compareTo among them. To handle - * this, the tree is ordered primarily by hash value, then by - * Comparable.compareTo order if applicable. On lookup at a node, - * if elements are not comparable or compare as 0 then both left - * and right children may need to be searched in the case of tied - * hash values. (This corresponds to the full list search that - * would be necessary if all elements were non-Comparable and had - * tied hashes.) On insertion, to keep a total ordering (or as - * close as is required here) across rebalancings, we compare - * classes and identityHashCodes as tie-breakers. The red-black - * balancing code is updated from pre-jdk-collections - * (http://gee.cs.oswego.edu/dl/classes/collections/RBCell.java) - * based in turn on Cormen, Leiserson, and Rivest "Introduction to - * Algorithms" (CLR). - * - * TreeBins also require an additional locking mechanism. While - * list traversal is always possible by readers even during - * updates, tree traversal is not, mainly because of tree-rotations - * that may change the root node and/or its linkages. TreeBins - * include a simple read-write lock mechanism parasitic on the - * main bin-synchronization strategy: Structural adjustments - * associated with an insertion or removal are already bin-locked - * (and so cannot conflict with other writers) but must wait for - * ongoing readers to finish. Since there can be only one such - * waiter, we use a simple scheme using a single "waiter" field to - * block writers. However, readers need never block. If the root - * lock is held, they proceed along the slow traversal path (via - * next-pointers) until the lock becomes available or the list is - * exhausted, whichever comes first. These cases are not fast, but - * maximize aggregate expected throughput. - * - * Maintaining API and serialization compatibility with previous - * versions of this class introduces several oddities. Mainly: We - * leave untouched but unused constructor arguments referring to - * concurrencyLevel. We accept a loadFactor constructor argument, - * but apply it only to initial table capacity (which is the only - * time that we can guarantee to honor it.) We also declare an - * unused "Segment" class that is instantiated in minimal form - * only when serializing. - * - * Also, solely for compatibility with previous versions of this - * class, it extends AbstractMap, even though all of its methods - * are overridden, so it is just useless baggage. - * - * This file is organized to make things a little easier to follow - * while reading than they might otherwise: First the main static - * declarations and utilities, then fields, then main public - * methods (with a few factorings of multiple public methods into - * internal ones), then sizing methods, trees, traversers, and - * bulk operations. - */ - - /* ---------------- Constants -------------- */ - static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash - /** - * The largest possible (non-power of two) array size. - * Needed by toArray and related methods. - */ - static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; - /** - * The smallest table capacity for which bins may be treeified. - * (Otherwise the table is resized if too many nodes in a bin.) - * The value should be at least 4 * TREEIFY_THRESHOLD to avoid - * conflicts between resizing and treeification thresholds. - */ - static final int MIN_TREEIFY_CAPACITY = 64; - /* - * Encodings for Node hash fields. See above for explanation. - */ - static final int MOVED = -1; // hash for forwarding nodes - /** - * Number of CPUS, to place bounds on some sizings - */ - static final int NCPU = Runtime.getRuntime().availableProcessors(); - static final int RESERVED = -3; // hash for transient reservations - static final int TREEBIN = -2; // hash for roots of trees - /** - * The bin count threshold for using a tree rather than list for a - * bin. Bins are converted to trees when adding an element to a - * bin with at least this many nodes. The value must be greater - * than 2, and should be at least 8 to mesh with assumptions in - * tree removal about conversion back to plain bins upon - * shrinkage. - */ - static final int TREEIFY_THRESHOLD = 8; - /** - * The bin count threshold for untreeifying a (split) bin during a - * resize operation. Should be less than TREEIFY_THRESHOLD, and at - * most 6 to mesh with shrinkage detection under removal. - */ - static final int UNTREEIFY_THRESHOLD = 6; - /* ---------------- Fields -------------- */ - private static final long ABASE; - private static final int ASHIFT; - /* - * Volatile access methods are used for table elements as well as - * elements of in-progress next table while resizing. All uses of - * the tab arguments must be null checked by callers. All callers - * also paranoically precheck that tab's length is not zero (or an - * equivalent check), thus ensuring that any index argument taking - * the form of a hash value anded with (length - 1) is a valid - * index. Note that, to be correct wrt arbitrary concurrency - * errors by users, these checks must operate on local variables, - * which accounts for some odd-looking inline assignments below. - * Note that calls to setTabAt always occur within locked regions, - * and so in principle require only release ordering, not - * full volatile semantics, but are currently coded as volatile - * writes to be conservative. - */ - private static final long BASECOUNT; - private static final long CELLSBUSY; - private static final long CELLVALUE; - /** - * The default initial table capacity. Must be a power of 2 - * (i.e., at least 1) and at most MAXIMUM_CAPACITY. - */ - private static final int DEFAULT_CAPACITY = 16; - /** - * The load factor for this table. Overrides of this value in - * constructors affect only the initial table capacity. The - * actual floating point value isn't normally used -- it is - * simpler to use expressions such as {@code n - (n >>> 2)} for - * the associated resizing threshold. - */ - private static final float LOAD_FACTOR = 0.75f; - /** - * The largest possible table capacity. This value must be - * exactly 1<<30 to stay within Java array allocation and indexing - * bounds for power of two table sizes, and is further required - * because the top two bits of 32bit hash fields are used for - * control purposes. - */ - private static final int MAXIMUM_CAPACITY = 1 << 30; - /** - * Minimum number of rebinnings per transfer step. Ranges are - * subdivided to allow multiple resizer threads. This value - * serves as a lower bound to avoid resizers encountering - * excessive memory contention. The value should be at least - * DEFAULT_CAPACITY. - */ - private static final int MIN_TRANSFER_STRIDE = 16; - private static final long PROBE; - - /* ---------------- Nodes -------------- */ - /** - * The increment for generating probe values - */ - private static final int PROBE_INCREMENT = 0x9e3779b9; - - /* ---------------- Static utilities -------------- */ - /** - * The number of bits used for generation stamp in sizeCtl. - * Must be at least 6 for 32bit arrays. - */ - private static final int RESIZE_STAMP_BITS = 16; - /** - * The maximum number of threads that can help resize. - * Must fit in 32 - RESIZE_STAMP_BITS bits. - */ - private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1; - /** - * The bit shift for recording size stamp in sizeCtl. - */ - private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS; - private static final long SEED; - - /* ---------------- Table element access -------------- */ - /** - * The increment of seeder per new instance - */ - private static final long SEEDER_INCREMENT = 0xbb67ae8584caa73bL; - private static final long SIZECTL; - private static final long TRANSFERINDEX; - /** - * Generates per-thread initialization/probe field - */ - private static final AtomicInteger probeGenerator = new AtomicInteger(); - /** - * The next seed for default constructors. - */ - private static final AtomicLong seeder = new AtomicLong(initialSeed()); - /** - * For serialization compatibility. - */ - private static final ObjectStreamField[] serialPersistentFields = { - new ObjectStreamField("segments", Segment[].class), - new ObjectStreamField("segmentMask", Integer.TYPE), - new ObjectStreamField("segmentShift", Integer.TYPE) - }; - private static final long serialVersionUID = 7249069246763182397L; - private final ThreadLocal> tlTraverser = ThreadLocal.withInitial(Traverser::new); - /** - * The array of bins. Lazily initialized upon first insertion. - * Size is always a power of two. Accessed directly by iterators. - */ - transient volatile Node[] table; - /** - * Base counter value, used mainly when there is no contention, - * but also as a fallback during table initialization - * races. Updated via CAS. - */ - private transient volatile long baseCount; - /** - * Spinlock (locked via CAS) used when resizing and/or creating CounterCells. - */ - private transient volatile int cellsBusy; - /** - * Table of counter cells. When non-null, size is a power of 2. - */ - private transient volatile CounterCell[] counterCells; - // Original (since JDK1.2) Map methods - private transient EntrySetView entrySet; - /* ---------------- Public operations -------------- */ - // views - private transient KeySetView keySet; - /** - * The next table to use; non-null only while resizing. - */ - private transient volatile Node[] nextTable; - /** - * Table initialization and resizing control. When negative, the - * table is being initialized or resized: -1 for initialization, - * else -(1 + the number of active resizing threads). Otherwise, - * when table is null, holds the initial table size to use upon - * creation, or 0 for default. After initialization, holds the - * next element count value upon which to resize the table. - */ - private transient volatile int sizeCtl; - /** - * The next table index (plus one) to split while resizing. - */ - private transient volatile int transferIndex; - private transient ValuesView values; - - /** - * Creates a new, empty map with an initial table size based on - * the given number of elements ({@code initialCapacity}), table - * density ({@code loadFactor}), and number of concurrently - * updating threads ({@code concurrencyLevel}). - * - * @param initialCapacity the initial capacity. The implementation - * performs internal sizing to accommodate this many elements, - * given the specified load factor. - * @param loadFactor the load factor (table density) for - * establishing the initial table size - * @throws IllegalArgumentException if the initial capacity is - * negative or the load factor or concurrencyLevel are - * nonpositive - */ - public ConcurrentIntHashMap(int initialCapacity, float loadFactor) { - if (!(loadFactor > 0.0f) || initialCapacity < 0) - throw new IllegalArgumentException(); - if (initialCapacity < 1) // Use at least as many bins - initialCapacity = 1; // as estimated threads - long size = (long) (1.0 + (long) initialCapacity / loadFactor); - this.sizeCtl = (size >= (long) MAXIMUM_CAPACITY) ? - MAXIMUM_CAPACITY : tableSizeFor((int) size); - } - - /** - * Creates a new map with the same mappings as the given map. - * - * @param m the map - */ - public ConcurrentIntHashMap(ConcurrentIntHashMap m) { - this.sizeCtl = DEFAULT_CAPACITY; - putAll(m); - } - - /** - * Creates a new, empty map with the default initial table size (16). - */ - public ConcurrentIntHashMap() { - } - - /** - * Creates a new, empty map with an initial table size - * accommodating the specified number of elements without the need - * to dynamically resize. - * - * @param initialCapacity The implementation performs internal - * sizing to accommodate this many elements. - * @throws IllegalArgumentException if the initial capacity of - * elements is negative - */ - public ConcurrentIntHashMap(int initialCapacity) { - if (initialCapacity < 0) - throw new IllegalArgumentException(); - this.sizeCtl = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ? - MAXIMUM_CAPACITY : - tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1)); - } - - /** - * Creates a new {@link Set} backed by a ConcurrentLongHashMap - * from the given type to {@code Boolean.TRUE}. - * - * @return the new set - * @since 1.8 - */ - public static KeySetView newKeySet() { - return new KeySetView<>(new ConcurrentIntHashMap<>(), Boolean.TRUE); - } - - /** - * Creates a new {@link Set} backed by a ConcurrentLongHashMap - * from the given type to {@code Boolean.TRUE}. - * - * @param initialCapacity The implementation performs internal - * sizing to accommodate this many elements. - * @return the new set - * @throws IllegalArgumentException if the initial capacity of - * elements is negative - * @since 1.8 - */ - public static KeySetView newKeySet(int initialCapacity) { - return new KeySetView<>(new ConcurrentIntHashMap<>(initialCapacity), Boolean.TRUE); - } - - /** - * Removes all of the mappings from this map. - */ - public void clear() { - long delta = 0L; // negative number of deletions - int i = 0; - Node[] tab = table; - while (tab != null && i < tab.length) { - int fh; - Node f = tabAt(tab, i); - if (f == null) - ++i; - else if ((fh = f.hash) == MOVED) { - tab = helpTransfer(tab, f); - i = 0; // restart - } else { - synchronized (f) { - if (tabAt(tab, i) == f) { - Node p = (fh >= 0 ? f : - (f instanceof TreeBin) ? - ((TreeBin) f).first : null); - while (p != null) { - --delta; - p = p.next; - } - setTabAt(tab, i++, null); - } - } - } - } - if (delta != 0L) - addCount(delta, -1); - } - - /** - * Attempts to compute a mapping for the specified key and its - * current mapped value (or {@code null} if there is no current - * mapping). The entire method invocation is performed atomically. - * Some attempted update operations on this map by other threads - * may be blocked while computation is in progress, so the - * computation should be short and simple, and must not attempt to - * update any other mappings of this Map. - * - * @param key key with which the specified value is to be associated - * @param remappingFunction the function to compute a value - * @return the new value associated with the specified key, or null if none - * @throws IllegalArgumentException if the specified key is negative - * @throws NullPointerException if the specified remappingFunction is null - * @throws IllegalStateException if the computation detectably - * attempts a recursive update to this map that would - * otherwise never complete - * @throws RuntimeException or Error if the remappingFunction does so, - * in which case the mapping is unchanged - */ - public V compute(int key, BiIntFunction remappingFunction) { - if (key < 0) - throw new IllegalArgumentException(); - if (remappingFunction == null) - throw new NullPointerException(); - int h = spread(keyHashCode(key)); - V val = null; - int delta = 0; - int binCount = 0; - for (Node[] tab = table; ; ) { - Node f; - int n, i, fh; - if (tab == null || (n = tab.length) == 0) - tab = initTable(); - else if ((f = tabAt(tab, i = (n - 1) & h)) == null) { - Node r = new ReservationNode<>(); - synchronized (r) { - if (casTabAt(tab, i, r)) { - binCount = 1; - Node node = null; - try { - if ((val = remappingFunction.apply(key, null)) != null) { - delta = 1; - node = new Node<>(h, key, val, null); - } - } finally { - setTabAt(tab, i, node); - } - } - } - if (binCount != 0) - break; - } else if ((fh = f.hash) == MOVED) - tab = helpTransfer(tab, f); - else { - synchronized (f) { - if (tabAt(tab, i) == f) { - if (fh >= 0) { - binCount = 1; - for (Node e = f, pred = null; ; ++binCount) { - if (e.hash == h && (e.key == key)) { - val = remappingFunction.apply(key, e.val); - if (val != null) - e.val = val; - else { - delta = -1; - Node en = e.next; - if (pred != null) - pred.next = en; - else - setTabAt(tab, i, en); - } - break; - } - pred = e; - if ((e = e.next) == null) { - val = remappingFunction.apply(key, null); - if (val != null) { - delta = 1; - pred.next = new Node<>(h, key, val, null); - } - break; - } - } - } else if (f instanceof TreeBin) { - binCount = 1; - TreeBin t = (TreeBin) f; - TreeNode r, p; - if ((r = t.root) != null) - p = r.findTreeNode(h, key); - else - p = null; - V pv = (p == null) ? null : p.val; - val = remappingFunction.apply(key, pv); - if (val != null) { - if (p != null) - p.val = val; - else { - delta = 1; - t.putTreeVal(h, key, val); - } - } else if (p != null) { - delta = -1; - if (t.removeTreeNode(p)) - setTabAt(tab, i, untreeify(t.first)); - } - } - } - } - if (binCount != 0) { - if (binCount >= TREEIFY_THRESHOLD) - treeifyBin(tab, i); - break; - } - } - } - if (delta != 0) - addCount(delta, binCount); - return val; - } - - /** - * If the specified key is not already associated with a value, - * attempts to compute its value using the given mapping function - * and enters it into this map unless {@code null}. The entire - * method invocation is performed atomically, so the function is - * applied at most once per key. Some attempted update operations - * on this map by other threads may be blocked while computation - * is in progress, so the computation should be short and simple, - * and must not attempt to update any other mappings of this map. - * - * @param key key with which the specified value is to be associated - * @param token token to pass to the mapping function - * @param mappingFunction the function to compute a value - * @return the current (existing or computed) value associated with - * the specified key, or null if the computed value is null - * @throws IllegalArgumentException if the specified key is negative - * @throws NullPointerException if the specified mappingFunction is null - * @throws IllegalStateException if the computation detectably - * attempts a recursive update to this map that would - * otherwise never complete - * @throws RuntimeException or Error if the mappingFunction does so, - * in which case the mapping is left unestablished - */ - public V computeIfAbsent(int key, Object token, BiIntFunction mappingFunction) { - if (key < 0) - throw new IllegalArgumentException(); - if (mappingFunction == null) - throw new NullPointerException(); - int h = spread(keyHashCode(key)); - V val = null; - int binCount = 0; - for (Node[] tab = table; ; ) { - Node f; - int n, i, fh; - if (tab == null || (n = tab.length) == 0) - tab = initTable(); - else if ((f = tabAt(tab, i = (n - 1) & h)) == null) { - Node r = new ReservationNode<>(); - synchronized (r) { - if (casTabAt(tab, i, r)) { - binCount = 1; - Node node = null; - try { - if ((val = mappingFunction.apply(key, token)) != null) - node = new Node<>(h, key, val, null); - } finally { - setTabAt(tab, i, node); - } - } - } - if (binCount != 0) - break; - } else if ((fh = f.hash) == MOVED) - tab = helpTransfer(tab, f); - else { - boolean added = false; - synchronized (f) { - if (tabAt(tab, i) == f) { - if (fh >= 0) { - binCount = 1; - for (Node e = f; ; ++binCount) { - if (e.hash == h && e.key == key) { - val = e.val; - break; - } - Node pred = e; - if ((e = e.next) == null) { - if ((val = mappingFunction.apply(key, token)) != null) { - added = true; - pred.next = new Node<>(h, key, val, null); - } - break; - } - } - } else if (f instanceof TreeBin) { - binCount = 2; - TreeBin t = (TreeBin) f; - TreeNode r, p; - if ((r = t.root) != null && - (p = r.findTreeNode(h, key)) != null) - val = p.val; - else if ((val = mappingFunction.apply(key, token)) != null) { - added = true; - t.putTreeVal(h, key, val); - } - } - } - } - if (binCount != 0) { - if (binCount >= TREEIFY_THRESHOLD) - treeifyBin(tab, i); - if (!added) - return val; - break; - } - } - } - if (val != null) - addCount(1L, binCount); - return val; - } - - /** - * If the specified key is not already associated with a value, - * attempts to compute its value using the given mapping function - * and enters it into this map unless {@code null}. The entire - * method invocation is performed atomically, so the function is - * applied at most once per key. Some attempted update operations - * on this map by other threads may be blocked while computation - * is in progress, so the computation should be short and simple, - * and must not attempt to update any other mappings of this map. - * - * @param key key with which the specified value is to be associated - * @param mappingFunction the function to compute a value - * @return the current (existing or computed) value associated with - * the specified key, or null if the computed value is null - * @throws IllegalArgumentException if the specified key is negative - * @throws NullPointerException if the specified key or mappingFunction - * is null - * @throws IllegalStateException if the computation detectably - * attempts a recursive update to this map that would - * otherwise never complete - * @throws RuntimeException or Error if the mappingFunction does so, - * in which case the mapping is left unestablished - */ - public V computeIfAbsent(int key, IntFunction mappingFunction) { - if (key < 0) - throw new IllegalArgumentException(); - if (mappingFunction == null) - throw new NullPointerException(); - int h = spread(keyHashCode(key)); - V val = null; - int binCount = 0; - for (Node[] tab = table; ; ) { - Node f; - int n, i, fh; - if (tab == null || (n = tab.length) == 0) - tab = initTable(); - else if ((f = tabAt(tab, i = (n - 1) & h)) == null) { - Node r = new ReservationNode<>(); - synchronized (r) { - if (casTabAt(tab, i, r)) { - binCount = 1; - Node node = null; - try { - if ((val = mappingFunction.apply(key)) != null) - node = new Node<>(h, key, val, null); - } finally { - setTabAt(tab, i, node); - } - } - } - if (binCount != 0) - break; - } else if ((fh = f.hash) == MOVED) - tab = helpTransfer(tab, f); - else { - boolean added = false; - synchronized (f) { - if (tabAt(tab, i) == f) { - if (fh >= 0) { - binCount = 1; - for (Node e = f; ; ++binCount) { - if (e.hash == h && e.key == key) { - val = e.val; - break; - } - Node pred = e; - if ((e = e.next) == null) { - if ((val = mappingFunction.apply(key)) != null) { - added = true; - pred.next = new Node<>(h, key, val, null); - } - break; - } - } - } else if (f instanceof TreeBin) { - binCount = 2; - TreeBin t = (TreeBin) f; - TreeNode r, p; - if ((r = t.root) != null && - (p = r.findTreeNode(h, key)) != null) - val = p.val; - else if ((val = mappingFunction.apply(key)) != null) { - added = true; - t.putTreeVal(h, key, val); - } - } - } - } - if (binCount != 0) { - if (binCount >= TREEIFY_THRESHOLD) - treeifyBin(tab, i); - if (!added) - return val; - break; - } - } - } - if (val != null) - addCount(1L, binCount); - return val; - } - - /** - * If the value for the specified key is present, attempts to - * compute a new mapping given the key and its current mapped - * value. The entire method invocation is performed atomically. - * Some attempted update operations on this map by other threads - * may be blocked while computation is in progress, so the - * computation should be short and simple, and must not attempt to - * update any other mappings of this map. - * - * @param key key with which a value may be associated - * @param remappingFunction the function to compute a value - * @return the new value associated with the specified key, or null if none - * @throws IllegalArgumentException if the specified key is negative - * @throws NullPointerException if the specified remappingFunction is null - * @throws IllegalStateException if the computation detectably - * attempts a recursive update to this map that would - * otherwise never complete - * @throws RuntimeException or Error if the remappingFunction does so, - * in which case the mapping is unchanged - */ - public V computeIfPresent(int key, BiIntFunction remappingFunction) { - if (key < 0) - throw new IllegalArgumentException(); - if (remappingFunction == null) - throw new NullPointerException(); - int h = spread(keyHashCode(key)); - V val = null; - int delta = 0; - int binCount = 0; - for (Node[] tab = table; ; ) { - Node f; - int n, i, fh; - if (tab == null || (n = tab.length) == 0) - tab = initTable(); - else if ((f = tabAt(tab, i = (n - 1) & h)) == null) - break; - else if ((fh = f.hash) == MOVED) - tab = helpTransfer(tab, f); - else { - synchronized (f) { - if (tabAt(tab, i) == f) { - if (fh >= 0) { - binCount = 1; - for (Node e = f, pred = null; ; ++binCount) { - if (e.hash == h && e.key == key) { - val = remappingFunction.apply(key, e.val); - if (val != null) - e.val = val; - else { - delta = -1; - Node en = e.next; - if (pred != null) - pred.next = en; - else - setTabAt(tab, i, en); - } - break; - } - pred = e; - if ((e = e.next) == null) - break; - } - } else if (f instanceof TreeBin) { - binCount = 2; - TreeBin t = (TreeBin) f; - TreeNode r, p; - if ((r = t.root) != null && - (p = r.findTreeNode(h, key)) != null) { - val = remappingFunction.apply(key, p.val); - if (val != null) - p.val = val; - else { - delta = -1; - if (t.removeTreeNode(p)) - setTabAt(tab, i, untreeify(t.first)); - } - } - } - } - } - if (binCount != 0) - break; - } - } - if (delta != 0) - addCount(delta, binCount); - return val; - } - - /** - * Tests if the specified object is a key in this table. - * - * @param key possible key - * @return {@code true} if and only if the specified object - * is a key in this table, as determined by the - * {@code equals} method; {@code false} otherwise - * @throws NullPointerException if the specified key is null - */ - public boolean containsKey(int key) { - return get(key) != null; - } - - /** - * Returns {@code true} if this map maps one or more keys to the - * specified value. Note: This method may require a full traversal - * of the map, and is much slower than method {@code containsKey}. - * - * @param value value whose presence in this map is to be tested - * @return {@code true} if this map maps one or more keys to the - * specified value - * @throws NullPointerException if the specified value is null - */ - public boolean containsValue(V value) { - if (value == null) - throw new NullPointerException(); - Node[] t = table; - if (t != null) { - Traverser it = getTraverser(t); - for (Node p; (p = it.advance()) != null; ) { - V v; - if ((v = p.val) == value || (value.equals(v))) - return true; - } - } - return false; - } - - /** - * Returns a {@link Set} view of the mappings contained in this map. - * The set is backed by the map, so changes to the map are - * reflected in the set, and vice-versa. The set supports element - * removal, which removes the corresponding mapping from the map, - * via the {@code Iterator.remove}, {@code Set.remove}, - * {@code removeAll}, {@code retainAll}, and {@code clear} - * operations. - *

    The view's iterators and spliterators are - * weakly consistent. - * - * @return the set view - */ - @NotNull - public Set> entrySet() { - EntrySetView es; - return (es = entrySet) != null ? es : (entrySet = new EntrySetView<>(this)); - } - - /** - * Compares the specified object with this map for equality. - * Returns {@code true} if the given object is a map with the same - * mappings as this map. This operation may return misleading - * results if either map is concurrently modified during execution - * of this method. - * - * @param o object to be compared for equality with this map - * @return {@code true} if the specified object is equal to this map - */ - public boolean equals(Object o) { - if (o != this) { - if (!(o instanceof ConcurrentIntHashMap)) - return false; - ConcurrentIntHashMap m = (ConcurrentIntHashMap) o; - Traverser it = getTraverser(table); - for (Node p; (p = it.advance()) != null; ) { - V val = p.val; - Object v = m.get(p.key); - if (v == null || (v != val && !v.equals(val))) - return false; - } - for (IntEntry e : m.entrySet()) { - int mk; - Object mv, v; - if ((mk = e.getKey()) == EMPTY_KEY || - (mv = e.getValue()) == null || - (v = get(mk)) == null || - (mv != v && !mv.equals(v))) - return false; - } - } - return true; - } - - /** - * Returns the value to which the specified key is mapped, - * or {@code null} if this map contains no mapping for the key. - *

    More formally, if this map contains a mapping from a key - * {@code k} to a value {@code v} such that {@code key.equals(k)}, - * then this method returns {@code v}; otherwise it returns - * {@code null}. (There can be at most one such mapping.) - * - * @param key map key value - * @return value to which specified key is mapped - * @throws NullPointerException if the specified key is null - */ - public V get(int key) { - Node[] tab; - Node e, p; - int n, eh; - int h = spread(keyHashCode(key)); - if ((tab = table) != null && (n = tab.length) > 0 && - (e = tabAt(tab, (n - 1) & h)) != null) { - if ((eh = e.hash) == h) { - if (e.key == key) - return e.val; - } else if (eh < 0) - return (p = e.find(h, key)) != null ? p.val : null; - while ((e = e.next) != null) { - if (e.hash == h && e.key == key) - return e.val; - } - } - return null; - } - - /** - * Returns the value to which the specified key is mapped, or the - * given default value if this map contains no mapping for the - * key. - * - * @param key the key whose associated value is to be returned - * @param defaultValue the value to return if this map contains - * no mapping for the given key - * @return the mapping for the key, if present; else the default value - * @throws NullPointerException if the specified key is null - */ - public V getOrDefault(int key, V defaultValue) { - V v; - return (v = get(key)) == null ? defaultValue : v; - } - - /** - * Returns the hash code value for this {@link Map}, i.e., - * the sum of, for each key-value pair in the map, - * {@code key.hashCode() ^ value.hashCode()}. - * - * @return the hash code value for this map - */ - public int hashCode() { - int h = 0; - Node[] t = table; - if (t != null) { - Traverser it = getTraverser(t); - for (Node p; (p = it.advance()) != null; ) - h += keyHashCode(p.key) ^ p.val.hashCode(); - } - return h; - } - - // ConcurrentMap methods - - /** - * {@inheritDoc} - */ - public boolean isEmpty() { - return sumCount() <= 0L; // ignore transient negative values - } - - /** - * Returns a {@link Set} view of the keys in this map, using the - * given common mapped value for any additions (i.e., {@link - * Collection#add} and {@link Collection#addAll(Collection)}). - * This is of course only appropriate if it is acceptable to use - * the same value for all additions from this view. - * - * @param mappedValue the mapped value to use for any additions - * @return the set view - * @throws NullPointerException if the mappedValue is null - */ - public KeySetView keySet(V mappedValue) { - if (mappedValue == null) - throw new NullPointerException(); - return new KeySetView<>(this, mappedValue); - } - - /** - * Returns a {@link Set} view of the keys contained in this map. - * The set is backed by the map, so changes to the map are - * reflected in the set, and vice-versa. The set supports element - * removal, which removes the corresponding mapping from this map, - * via the {@code Iterator.remove}, {@code Set.remove}, - * {@code removeAll}, {@code retainAll}, and {@code clear} - * operations. It does not support the {@code add} or - * {@code addAll} operations. - *

    The view's iterators and spliterators are - * weakly consistent. - *

    - * - * @return the set view - */ - @NotNull - public KeySetView keySet() { - KeySetView ks; - return (ks = keySet) != null ? ks : (keySet = new KeySetView<>(this, null)); - } - - /** - * Returns the number of mappings. This method should be used - * instead of {@link #size} because a ConcurrentLongHashMap may - * contain more mappings than can be represented as an int. The - * value returned is an estimate; the actual count may differ if - * there are concurrent insertions or removals. - * - * @return the number of mappings - * @since 1.8 - */ - public long mappingCount() { - return Math.max(sumCount(), 0L); // ignore transient negative values - } - - // Overrides of JDK8+ Map extension method defaults - - /** - * Maps the specified key to the specified value in this table. - * Neither the key nor the value can be null. - *

    The value can be retrieved by calling the {@code get} method - * with a key that is equal to the original key. - * - * @param key key with which the specified value is to be associated - * @param value value to be associated with the specified key - * @return the previous value associated with {@code key}, or - * {@code null} if there was no mapping for {@code key} - * @throws NullPointerException if the specified key or value is null - */ - public V put(int key, V value) { - return putVal(key, value, false); - } - - /** - * Copies all of the mappings from the specified map to this one. - * These mappings replace any mappings that this map had for any of the - * keys currently in the specified map. - * - * @param m mappings to be stored in this map - */ - public void putAll(@NotNull ConcurrentIntHashMap m) { - tryPresize(m.size()); - for (IntEntry e : m.entrySet()) - putVal(e.getKey(), e.getValue(), false); - } - - /** - * {@inheritDoc} - * - * @return the previous value associated with the specified key, - * or {@code null} if there was no mapping for the key - * @throws NullPointerException if the specified key or value is null - */ - public V putIfAbsent(int key, V value) { - return putVal(key, value, true); - } - - public boolean remove(int key, V value) { - return value != null && replaceNode(key, null, value) != null; - } - - /** - * Removes the key (and its corresponding value) from this map. - * This method does nothing if the key is not in the map. - * - * @param key the key that needs to be removed - * @return the previous value associated with {@code key}, or - * {@code null} if there was no mapping for {@code key} - * @throws NullPointerException if the specified key is null - */ - public V remove(int key) { - return replaceNode(key, null, null); - } - - // Hashtable legacy methods - - public boolean replace(int key, @NotNull V oldValue, @NotNull V newValue) { - return replaceNode(key, newValue, oldValue) != null; - } - - // ConcurrentLongHashMap-only methods - - /** - * {@inheritDoc} - * - * @return the previous value associated with the specified key, - * or {@code null} if there was no mapping for the key - * @throws NullPointerException if the specified key or value is null - */ - public V replace(int key, @NotNull V value) { - return replaceNode(key, value, null); - } - - /** - * {@inheritDoc} - */ - public int size() { - long n = sumCount(); - return ((n < 0L) ? 0 : - (n > (long) Integer.MAX_VALUE) ? Integer.MAX_VALUE : - (int) n); - } - - /** - * Returns a string representation of this map. The string - * representation consists of a list of key-value mappings (in no - * particular order) enclosed in braces ("{@code {}}"). Adjacent - * mappings are separated by the characters {@code ", "} (comma - * and space). Each key-value mapping is rendered as the key - * followed by an equals sign ("{@code =}") followed by the - * associated value. - * - * @return a string representation of this map - */ - public String toString() { - Traverser it = getTraverser(table); - StringBuilder sb = new StringBuilder(); - sb.append('{'); - Node p; - if ((p = it.advance()) != null) { - for (; ; ) { - int k = p.key; - V v = p.val; - sb.append(k); - sb.append('='); - sb.append(v == this ? "(this Map)" : v); - if ((p = it.advance()) == null) - break; - sb.append(',').append(' '); - } - } - return sb.append('}').toString(); - } - - /** - * Returns a {@link Collection} view of the values contained in this map. - * The collection is backed by the map, so changes to the map are - * reflected in the collection, and vice-versa. The collection - * supports element removal, which removes the corresponding - * mapping from this map, via the {@code Iterator.remove}, - * {@code Collection.remove}, {@code removeAll}, - * {@code retainAll}, and {@code clear} operations. It does not - * support the {@code add} or {@code addAll} operations. - *

    The view's iterators and spliterators are - * weakly consistent. - * - * @return the collection view - */ - @NotNull - public Collection values() { - ValuesView vs; - return (vs = values) != null ? vs : (values = new ValuesView<>(this)); - } - - /* ---------------- Special Nodes -------------- */ - - private static long initialSeed() { - String pp = System.getProperty("java.util.secureRandomSeed"); - - if (pp != null && pp.equalsIgnoreCase("true")) { - byte[] seedBytes = java.security.SecureRandom.getSeed(8); - long s = (long) (seedBytes[0]) & 0xffL; - for (int i = 1; i < 8; ++i) - s = (s << 8) | ((long) (seedBytes[i]) & 0xffL); - return s; - } - return (mix64(System.currentTimeMillis()) ^ - mix64(System.nanoTime())); - } - - /* ---------------- Table Initialization and Resizing -------------- */ - - private static int keyHashCode(int key) { - return key; - } - - private static long mix64(long z) { - z = (z ^ (z >>> 33)) * 0xff51afd7ed558ccdL; - z = (z ^ (z >>> 33)) * 0xc4ceb9fe1a85ec53L; - return z ^ (z >>> 33); - } - - /** - * Returns a power of two table size for the given desired capacity. - * See Hackers Delight, sec 3.2 - */ - private static int tableSizeFor(int c) { - int n = c - 1; - n |= n >>> 1; - n |= n >>> 2; - n |= n >>> 4; - n |= n >>> 8; - n |= n >>> 16; - return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; - } - - /** - * Adds to count, and if table is too small and not already - * resizing, initiates transfer. If already resizing, helps - * perform transfer if work is available. Rechecks occupancy - * after a transfer to see if another resize is already needed - * because resizings are lagging additions. - * - * @param x the count to add - * @param check if <0, don't check resize, if <= 1 only check if uncontended - */ - private void addCount(long x, int check) { - CounterCell[] as; - long b, s; - if ((as = counterCells) != null || !Unsafe.cas(this, BASECOUNT, b = baseCount, s = b + x)) { - CounterCell a; - long v; - int m; - boolean uncontended = true; - if (as == null || (m = as.length - 1) < 0 || - (a = as[getProbe() & m]) == null || - !(uncontended = Unsafe.cas(a, CELLVALUE, v = a.value, v + x))) { - fullAddCount(x, uncontended); - return; - } - if (check <= 1) - return; - s = sumCount(); - } - if (check >= 0) { - Node[] tab, nt; - int n, sc; - while (s >= (long) (sc = sizeCtl) && (tab = table) != null && - (n = tab.length) < MAXIMUM_CAPACITY) { - int rs = resizeStamp(n); - if (sc < 0) { - if (sc >>> RESIZE_STAMP_SHIFT != rs || sc == rs + MAX_RESIZERS || (nt = nextTable) == null || transferIndex <= 0) - break; - if (Unsafe.getUnsafe().compareAndSwapInt(this, SIZECTL, sc, sc + 1)) - transfer(tab, nt); - } else if (Unsafe.getUnsafe().compareAndSwapInt(this, SIZECTL, sc, - (rs << RESIZE_STAMP_SHIFT) + 2)) - transfer(tab, null); - s = sumCount(); - } - } - } - - // See LongAdder version for explanation - private void fullAddCount(long x, boolean wasUncontended) { - int h; - if ((h = getProbe()) == 0) { - localInit(); // force initialization - h = getProbe(); - wasUncontended = true; - } - boolean collide = false; // True if last slot nonempty - for (; ; ) { - CounterCell[] as; - CounterCell a; - int n; - long v; - if ((as = counterCells) != null && (n = as.length) > 0) { - if ((a = as[(n - 1) & h]) == null) { - if (cellsBusy == 0) { // Try to attach new Cell - CounterCell r = new CounterCell(x); // Optimistic create - if (cellsBusy == 0 && - Unsafe.getUnsafe().compareAndSwapInt(this, CELLSBUSY, 0, 1)) { - boolean created = false; - try { // Recheck under lock - CounterCell[] rs; - int m, j; - if ((rs = counterCells) != null && - (m = rs.length) > 0 && - rs[j = (m - 1) & h] == null) { - rs[j] = r; - created = true; - } - } finally { - cellsBusy = 0; - } - if (created) - break; - continue; // Slot is now non-empty - } - } - collide = false; - } else if (!wasUncontended) // CAS already known to fail - wasUncontended = true; // Continue after rehash - else if (Unsafe.cas(a, CELLVALUE, v = a.value, v + x)) - break; - else if (counterCells != as || n >= NCPU) - collide = false; // At max size or stale - else if (!collide) - collide = true; - else if (cellsBusy == 0 && - Unsafe.getUnsafe().compareAndSwapInt(this, CELLSBUSY, 0, 1)) { - try { - if (counterCells == as) {// Expand table unless stale - CounterCell[] rs = new CounterCell[n << 1]; - System.arraycopy(as, 0, rs, 0, n); - counterCells = rs; - } - } finally { - cellsBusy = 0; - } - collide = false; - continue; // Retry with expanded table - } - h = advanceProbe(h); - } else if (cellsBusy == 0 && counterCells == as && - Unsafe.getUnsafe().compareAndSwapInt(this, CELLSBUSY, 0, 1)) { - boolean init = false; - try { // Initialize table - if (counterCells == as) { - CounterCell[] rs = new CounterCell[2]; - rs[h & 1] = new CounterCell(x); - counterCells = rs; - init = true; - } - } finally { - cellsBusy = 0; - } - if (init) - break; - } else if (Unsafe.cas(this, BASECOUNT, v = baseCount, v + x)) - break; // Fall back on using base - } - } - - private Traverser getTraverser(Node[] tab) { - Traverser traverser = tlTraverser.get(); - int len = tab == null ? 0 : tab.length; - traverser.of(tab, len, len); - return traverser; - } - - /** - * Initializes table, using the size recorded in sizeCtl. - */ - private Node[] initTable() { - Node[] tab; - int sc; - while ((tab = table) == null || tab.length == 0) { - if ((sc = sizeCtl) < 0) - Os.pause(); // lost initialization race; just spin - else if (Unsafe.getUnsafe().compareAndSwapInt(this, SIZECTL, sc, -1)) { - try { - if ((tab = table) == null || tab.length == 0) { - int n = (sc > 0) ? sc : DEFAULT_CAPACITY; - @SuppressWarnings("unchecked") - Node[] nt = (Node[]) new Node[n]; - table = tab = nt; - sc = n - (n >>> 2); - } - } finally { - sizeCtl = sc; - } - break; - } - } - return tab; - } - - /** - * Moves and/or copies the nodes in each bin to new table. See - * above for explanation. - */ - private void transfer(Node[] tab, Node[] nextTab) { - int n = tab.length, stride; - if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE) - stride = MIN_TRANSFER_STRIDE; // subdivide range - if (nextTab == null) { // initiating - try { - @SuppressWarnings("unchecked") - Node[] nt = (Node[]) new Node[n << 1]; - nextTab = nt; - } catch (Throwable ex) { // try to cope with OOME - sizeCtl = Integer.MAX_VALUE; - return; - } - nextTable = nextTab; - transferIndex = n; - } - int nextn = nextTab.length; - ForwardingNode fwd = new ForwardingNode<>(nextTab); - boolean advance = true; - boolean finishing = false; // to ensure sweep before committing nextTab - for (int i = 0, bound = 0; ; ) { - Node f; - int fh; - while (advance) { - int nextIndex, nextBound; - if (--i >= bound || finishing) - advance = false; - else if ((nextIndex = transferIndex) <= 0) { - i = -1; - advance = false; - } else if (Unsafe.getUnsafe().compareAndSwapInt - (this, TRANSFERINDEX, nextIndex, - nextBound = (nextIndex > stride ? - nextIndex - stride : 0))) { - bound = nextBound; - i = nextIndex - 1; - advance = false; - } - } - if (i < 0 || i >= n || i + n >= nextn) { - int sc; - if (finishing) { - nextTable = null; - table = nextTab; - sizeCtl = (n << 1) - (n >>> 1); - return; - } - if (Unsafe.getUnsafe().compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) { - if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT) - return; - finishing = advance = true; - i = n; // recheck before commit - } - } else if ((f = tabAt(tab, i)) == null) - advance = casTabAt(tab, i, fwd); - else if ((fh = f.hash) == MOVED) - advance = true; // already processed - else { - synchronized (f) { - if (tabAt(tab, i) == f) { - Node ln, hn; - if (fh >= 0) { - int runBit = fh & n; - Node lastRun = f; - for (Node p = f.next; p != null; p = p.next) { - int b = p.hash & n; - if (b != runBit) { - runBit = b; - lastRun = p; - } - } - if (runBit == 0) { - ln = lastRun; - hn = null; - } else { - hn = lastRun; - ln = null; - } - for (Node p = f; p != lastRun; p = p.next) { - int ph = p.hash; - int pk = p.key; - V pv = p.val; - if ((ph & n) == 0) - ln = new Node<>(ph, pk, pv, ln); - else - hn = new Node<>(ph, pk, pv, hn); - } - setTabAt(nextTab, i, ln); - setTabAt(nextTab, i + n, hn); - setTabAt(tab, i, fwd); - advance = true; - } else if (f instanceof TreeBin) { - TreeBin t = (TreeBin) f; - TreeNode lo = null, loTail = null; - TreeNode hi = null, hiTail = null; - int lc = 0, hc = 0; - for (Node e = t.first; e != null; e = e.next) { - int h = e.hash; - TreeNode p = new TreeNode<>(h, e.key, e.val, null, null); - if ((h & n) == 0) { - if ((p.prev = loTail) == null) - lo = p; - else - loTail.next = p; - loTail = p; - ++lc; - } else { - if ((p.prev = hiTail) == null) - hi = p; - else - hiTail.next = p; - hiTail = p; - ++hc; - } - } - ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) : - (hc != 0) ? new TreeBin<>(lo) : t; - hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) : - (lc != 0) ? new TreeBin<>(hi) : t; - setTabAt(nextTab, i, ln); - setTabAt(nextTab, i + n, hn); - setTabAt(tab, i, fwd); - advance = true; - } - } - } - } - } - } - /* ---------------- Counter support -------------- */ - - /** - * Replaces all linked nodes in bin at given index unless table is - * too small, in which case resizes instead. - */ - private void treeifyBin(Node[] tab, int index) { - Node b; - int n; - if (tab != null) { - if ((n = tab.length) < MIN_TREEIFY_CAPACITY) - tryPresize(n << 1); - else if ((b = tabAt(tab, index)) != null && b.hash >= 0) { - synchronized (b) { - if (tabAt(tab, index) == b) { - TreeNode hd = null, tl = null; - for (Node e = b; e != null; e = e.next) { - TreeNode p = - new TreeNode<>(e.hash, e.key, e.val, null, null); - if ((p.prev = tl) == null) - hd = p; - else - tl.next = p; - tl = p; - } - setTabAt(tab, index, new TreeBin<>(hd)); - } - } - } - } - } - - /** - * Tries to presize table to accommodate the given number of elements. - * - * @param size number of elements (doesn't need to be perfectly accurate) - */ - private void tryPresize(int size) { - int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY : - tableSizeFor(size + (size >>> 1) + 1); - int sc; - while ((sc = sizeCtl) >= 0) { - Node[] tab = table; - int n; - if (tab == null || (n = tab.length) == 0) { - n = Math.max(sc, c); - if (Unsafe.getUnsafe().compareAndSwapInt(this, SIZECTL, sc, -1)) { - try { - if (table == tab) { - @SuppressWarnings("unchecked") - Node[] nt = (Node[]) new Node[n]; - table = nt; - sc = n - (n >>> 2); - } - } finally { - sizeCtl = sc; - } - } - } else if (c <= sc || n >= MAXIMUM_CAPACITY) - break; - else if (tab == table) { - int rs = resizeStamp(n); - if (sc < 0) { - Node[] nt; - if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || - sc == rs + MAX_RESIZERS || (nt = nextTable) == null || - transferIndex <= 0) - break; - if (Unsafe.getUnsafe().compareAndSwapInt(this, SIZECTL, sc, sc + 1)) - transfer(tab, nt); - } else if (Unsafe.getUnsafe().compareAndSwapInt(this, SIZECTL, sc, - (rs << RESIZE_STAMP_SHIFT) + 2)) - transfer(tab, null); - } - } - } - - static int advanceProbe(int probe) { - probe ^= probe << 13; // xorshift - probe ^= probe >>> 17; - probe ^= probe << 5; - Unsafe.getUnsafe().putInt(Thread.currentThread(), PROBE, probe); - return probe; - } - - /* ---------------- Conversion from/to TreeBins -------------- */ - - static boolean casTabAt(Node[] tab, int i, - Node v) { - return Unsafe.getUnsafe().compareAndSwapObject(tab, ((long) i << ASHIFT) + ABASE, null, v); - } - - /** - * Returns x's Class if it is of the form "class C implements - * Comparable", else null. - */ - static Class comparableClassFor(Object x) { - if (x instanceof Comparable) { - Class c; - Type[] ts, as; - Type t; - ParameterizedType p; - if ((c = x.getClass()) == String.class) // bypass checks - return c; - if ((ts = c.getGenericInterfaces()) != null) { - for (int i = 0; i < ts.length; ++i) { - if (((t = ts[i]) instanceof ParameterizedType) && - ((p = (ParameterizedType) t).getRawType() == - Comparable.class) && - (as = p.getActualTypeArguments()) != null && - as.length == 1 && as[0] == c) // type arg is c - return c; - } - } - } - return null; - } - - /* ---------------- TreeNodes -------------- */ - - /** - * Returns k.compareTo(x) if x matches kc (k's screened comparable - * class), else 0. - */ - static int compareComparables(int k, long x) { - return Long.compare(k, x); - } - - /* ---------------- TreeBins -------------- */ - - static int getProbe() { - return Unsafe.getUnsafe().getInt(Thread.currentThread(), PROBE); - } - - /* ----------------Table Traversal -------------- */ - - /** - * Initialize Thread fields for the current thread. Called only - * when Thread.threadLocalRandomProbe is zero, indicating that a - * thread local seed value needs to be generated. Note that even - * though the initialization is purely thread-local, we need to - * rely on (static) atomic generators to initialize the values. - */ - static void localInit() { - int p = probeGenerator.addAndGet(PROBE_INCREMENT); - int probe = (p == 0) ? 1 : p; // skip 0 - long seed = mix64(seeder.getAndAdd(SEEDER_INCREMENT)); - Thread t = Thread.currentThread(); - Unsafe.getUnsafe().putLong(t, SEED, seed); - Unsafe.getUnsafe().putInt(t, PROBE, probe); - } - - /** - * Returns the stamp bits for resizing a table of size n. - * Must be negative when shifted left by RESIZE_STAMP_SHIFT. - */ - static int resizeStamp(int n) { - return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1)); - } - - static void setTabAt(Node[] tab, int i, Node v) { - Unsafe.getUnsafe().putObjectVolatile(tab, ((long) i << ASHIFT) + ABASE, v); - } - - /** - * Spreads (XORs) higher bits of hash to lower and also forces top - * bit to 0. Because the table uses power-of-two masking, sets of - * hashes that vary only in bits above the current mask will - * always collide. (Among known examples are sets of Float keys - * holding consecutive whole numbers in small tables.) So we - * apply a transform that spreads the impact of higher bits - * downward. There is a tradeoff between speed, utility, and - * quality of bit-spreading. Because many common sets of hashes - * are already reasonably distributed (so don't benefit from - * spreading), and because we use trees to handle large sets of - * collisions in bins, we just XOR some shifted bits in the - * cheapest possible way to reduce systematic lossage, as well as - * to incorporate impact of the highest bits that would otherwise - * never be used in index calculations because of table bounds. - */ - static int spread(int h) { - return (h ^ (h >>> 16)) & HASH_BITS; - } - - @SuppressWarnings("unchecked") - static Node tabAt(Node[] tab, int i) { - return (Node) Unsafe.getUnsafe().getObjectVolatile(tab, ((long) i << ASHIFT) + ABASE); - } - - /** - * Returns a list on non-TreeNodes replacing those in given list. - */ - static Node untreeify(Node b) { - Node hd = null, tl = null; - for (Node q = b; q != null; q = q.next) { - Node p = new Node<>(q.hash, q.key, q.val, null); - if (tl == null) - hd = p; - else - tl.next = p; - tl = p; - } - return hd; - } - - /** - * Helps transfer if a resize is in progress. - */ - final Node[] helpTransfer(Node[] tab, Node f) { - Node[] nextTab; - int sc; - if (tab != null && (f instanceof ForwardingNode) && - (nextTab = ((ForwardingNode) f).nextTable) != null) { - int rs = resizeStamp(tab.length); - while (nextTab == nextTable && table == tab && - (sc = sizeCtl) < 0) { - if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || - sc == rs + MAX_RESIZERS || transferIndex <= 0) - break; - if (Unsafe.getUnsafe().compareAndSwapInt(this, SIZECTL, sc, sc + 1)) { - transfer(tab, nextTab); - break; - } - } - return nextTab; - } - return table; - } - - /* ----------------Views -------------- */ - - /** - * Implementation for put and putIfAbsent - */ - final V putVal(int key, V value, boolean onlyIfAbsent) { - if (key < 0) throw new IllegalArgumentException(); - if (value == null) throw new NullPointerException(); - int hash = spread(keyHashCode(key)); - int binCount = 0; - Node _new = null; - - for (Node[] tab = table; ; ) { - Node f; - int n, i, fh; - if (tab == null || (n = tab.length) == 0) - tab = initTable(); - else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { - if (_new == null) { - _new = new Node<>(hash, key, value, null); - } - if (casTabAt(tab, i, _new)) { - break; // no lock when adding to empty bin - } - } else if ((fh = f.hash) == MOVED) - tab = helpTransfer(tab, f); - else { - V oldVal = null; - synchronized (f) { - if (tabAt(tab, i) == f) { - if (fh >= 0) { - binCount = 1; - for (Node e = f; ; ++binCount) { - if (e.hash == hash && e.key == key) { - oldVal = e.val; - if (!onlyIfAbsent) - e.val = value; - break; - } - Node pred = e; - if ((e = e.next) == null) { - if (_new == null) { - pred.next = new Node<>(hash, key, value, null); - } else { - pred.next = _new; - } - break; - } - } - } else if (f instanceof TreeBin) { - Node p; - binCount = 2; - if ((p = ((TreeBin) f).putTreeVal(hash, key, value)) != null) { - oldVal = p.val; - if (!onlyIfAbsent) - p.val = value; - } - } - } - } - if (binCount != 0) { - if (binCount >= TREEIFY_THRESHOLD) - treeifyBin(tab, i); - if (oldVal != null) - return oldVal; - break; - } - } - } - addCount(1L, binCount); - return null; - } - - /** - * Implementation for the four public remove/replace methods: - * Replaces node value with v, conditional upon match of cv if - * non-null. If resulting value is null, delete. - */ - final V replaceNode(int key, V value, V cv) { - int hash = spread(keyHashCode(key)); - for (Node[] tab = table; ; ) { - Node f; - int n, i, fh; - if (tab == null || (n = tab.length) == 0 || - (f = tabAt(tab, i = (n - 1) & hash)) == null) - break; - else if ((fh = f.hash) == MOVED) - tab = helpTransfer(tab, f); - else { - V oldVal = null; - boolean validated = false; - synchronized (f) { - if (tabAt(tab, i) == f) { - if (fh >= 0) { - validated = true; - for (Node e = f, pred = null; ; ) { - if (e.hash == hash && e.key == key) { - V ev = e.val; - if (cv == null || cv == ev || (cv.equals(ev))) { - oldVal = ev; - if (value != null) - e.val = value; - else if (pred != null) - pred.next = e.next; - else - setTabAt(tab, i, e.next); - } - break; - } - pred = e; - if ((e = e.next) == null) - break; - } - } else if (f instanceof TreeBin) { - validated = true; - TreeBin t = (TreeBin) f; - TreeNode r, p; - if ((r = t.root) != null && - (p = r.findTreeNode(hash, key)) != null) { - V pv = p.val; - if (cv == null || cv == pv || cv.equals(pv)) { - oldVal = pv; - if (value != null) - p.val = value; - else if (t.removeTreeNode(p)) - setTabAt(tab, i, untreeify(t.first)); - } - } - } - } - } - if (validated) { - if (oldVal != null) { - if (value == null) - addCount(-1L, -1); - return oldVal; - } - break; - } - } - } - return null; - } - - final long sumCount() { - CounterCell[] as = counterCells; - CounterCell a; - long sum = baseCount; - if (as != null) { - for (int i = 0; i < as.length; ++i) { - if ((a = as[i]) != null) - sum += a.value; - } - } - return sum; - } - - public interface IntEntry { - boolean equals(Object var1); - - int getKey(); - - V getValue(); - - int hashCode(); - - V setValue(V var1); - } - - /** - * Base of key, value, and entry Iterators. Adds fields to - * Traverser to support iterator.remove. - */ - static class BaseIterator extends Traverser { - Node lastReturned; - ConcurrentIntHashMap map; - - public final boolean hasNext() { - return next != null; - } - - public final void remove() { - Node p; - if ((p = lastReturned) == null) - throw new IllegalStateException(); - lastReturned = null; - map.replaceNode(p.key, null, null); - } - - void of(ConcurrentIntHashMap map) { - Node[] tab = map.table; - int l = tab == null ? 0 : tab.length; - super.of(tab, l, l); - this.map = map; - advance(); - } - } - - /** - * Base class for views. - */ - abstract static class CollectionView - implements Collection, Serializable { - private static final String oomeMsg = "Required array size too large"; - private static final long serialVersionUID = 7249069246763182397L; - final ConcurrentIntHashMap map; - - CollectionView(ConcurrentIntHashMap map) { - this.map = map; - } - - /** - * Removes all of the elements from this view, by removing all - * the mappings from the map backing this view. - */ - public final void clear() { - map.clear(); - } - - public abstract boolean contains(Object o); - - public final boolean containsAll(@NotNull Collection c) { - if (c != this) { - for (Object e : c) { - if (e == null || !contains(e)) - return false; - } - } - return true; - } - - /** - * Returns the map backing this view. - * - * @return the map backing this view - */ - public ConcurrentIntHashMap getMap() { - return map; - } - - public final boolean isEmpty() { - return map.isEmpty(); - } - - /** - * Returns an iterator over the elements in this collection. - *

    The returned iterator is - * weakly consistent. - * - * @return an iterator over the elements in this collection - */ - @NotNull - public abstract Iterator iterator(); - - public abstract boolean remove(Object o); - - @Override - public final boolean removeAll(@NotNull Collection c) { - boolean modified = false; - for (Iterator it = iterator(); it.hasNext(); ) { - if (c.contains(it.next())) { - it.remove(); - modified = true; - } - } - return modified; - } - - // implementations below rely on concrete classes supplying these - // abstract methods - - @Override - public final boolean retainAll(@NotNull Collection c) { - boolean modified = false; - for (Iterator it = iterator(); it.hasNext(); ) { - if (!c.contains(it.next())) { - it.remove(); - modified = true; - } - } - return modified; - } - - @Override - public final int size() { - return map.size(); - } - - @Override - public final Object @NotNull [] toArray() { - long sz = map.mappingCount(); - if (sz > MAX_ARRAY_SIZE) - throw new OutOfMemoryError(oomeMsg); - int n = (int) sz; - Object[] r = new Object[n]; - int i = 0; - for (E e : this) { - if (i == n) { - if (n >= MAX_ARRAY_SIZE) - throw new OutOfMemoryError(oomeMsg); - if (n >= MAX_ARRAY_SIZE - (MAX_ARRAY_SIZE >>> 1) - 1) - n = MAX_ARRAY_SIZE; - else - n += (n >>> 1) + 1; - r = Arrays.copyOf(r, n); - } - r[i++] = e; - } - return (i == n) ? r : Arrays.copyOf(r, i); - } - - @Override - @SuppressWarnings("unchecked") - public final T @NotNull [] toArray(@NotNull T @NotNull [] a) { - long sz = map.mappingCount(); - if (sz > MAX_ARRAY_SIZE) - throw new OutOfMemoryError(oomeMsg); - int m = (int) sz; - T[] r = (a.length >= m) ? a : - (T[]) java.lang.reflect.Array - .newInstance(a.getClass().getComponentType(), m); - int n = r.length; - int i = 0; - for (E e : this) { - if (i == n) { - if (n >= MAX_ARRAY_SIZE) - throw new OutOfMemoryError(oomeMsg); - if (n >= MAX_ARRAY_SIZE - (MAX_ARRAY_SIZE >>> 1) - 1) - n = MAX_ARRAY_SIZE; - else - n += (n >>> 1) + 1; - r = Arrays.copyOf(r, n); - } - r[i++] = (T) e; - } - if (a == r && i < n) { - r[i] = null; // null-terminate - return r; - } - return (i == n) ? r : Arrays.copyOf(r, i); - } - - /** - * Returns a string representation of this collection. - * The string representation consists of the string representations - * of the collection's elements in the order they are returned by - * its iterator, enclosed in square brackets ({@code "[]"}). - * Adjacent elements are separated by the characters {@code ", "} - * (comma and space). Elements are converted to strings as by - * {@link String#valueOf(Object)}. - * - * @return a string representation of this collection - */ - @Override - public final String toString() { - StringBuilder sb = new StringBuilder(); - sb.append('['); - Iterator it = iterator(); - if (it.hasNext()) { - for (; ; ) { - Object e = it.next(); - sb.append(e == this ? "(this Collection)" : e); - if (!it.hasNext()) - break; - sb.append(',').append(' '); - } - } - return sb.append(']').toString(); - } - } - - /** - * A padded cell for distributing counts. Adapted from LongAdder - * and Striped64. See their internal docs for explanation. - */ - static final class CounterCell { - final long value; - - CounterCell(long x) { - value = x; - } - } - - static final class EntryIterator extends BaseIterator - implements Iterator> { - - @Override - public IntEntry next() { - Node p; - if ((p = next) == null) - throw new NoSuchElementException(); - int k = p.key; - V v = p.val; - lastReturned = p; - advance(); - return new MapEntry<>(k, v, map); - } - } - - /** - * A view of a ConcurrentLongHashMap as a {@link Set} of (key, value) - * entries. This class cannot be directly instantiated. See - * {@link #entrySet()}. - */ - static final class EntrySetView extends CollectionView> - implements Set>, Serializable { - private static final long serialVersionUID = 2249069246763182397L; - - private final ThreadLocal> tlEntryIterator = ThreadLocal.withInitial(EntryIterator::new); - - EntrySetView(ConcurrentIntHashMap map) { - super(map); - } - - @Override - public boolean add(IntEntry e) { - return map.putVal(e.getKey(), e.getValue(), false) == null; - } - - @Override - public boolean addAll(@NotNull Collection> c) { - boolean added = false; - for (IntEntry e : c) { - if (add(e)) - added = true; - } - return added; - } - - @Override - public boolean contains(Object o) { - int k; - Object v, r; - IntEntry e; - return ((o instanceof IntEntry) && - (k = (e = (IntEntry) o).getKey()) != EMPTY_KEY && - (r = map.get(k)) != null && - (v = e.getValue()) != null && - (v == r || v.equals(r))); - } - - @Override - public boolean equals(Object o) { - Set c; - return ((o instanceof Set) && - ((c = (Set) o) == this || - (containsAll(c) && c.containsAll(this)))); - } - - @Override - public int hashCode() { - int h = 0; - Node[] t = map.table; - if (t != null) { - Traverser it = map.getTraverser(t); - for (Node p; (p = it.advance()) != null; ) { - h += p.hashCode(); - } - } - return h; - } - - /** - * @return an iterator over the entries of the backing map - */ - @NotNull - public Iterator> iterator() { - EntryIterator it = tlEntryIterator.get(); - it.of(map); - return it; - } - - @Override - public boolean remove(Object o) { - int k; - Object v; - IntEntry e; - return ((o instanceof IntEntry) && - (k = (e = (IntEntry) o).getKey()) != EMPTY_KEY && - (v = e.getValue()) != null && - map.remove(k, (V) v)); - } - } - - /** - * A node inserted at head of bins during transfer operations. - */ - static final class ForwardingNode extends Node { - final Node[] nextTable; - - ForwardingNode(Node[] tab) { - super(MOVED, EMPTY_KEY, null, null); - this.nextTable = tab; - } - - @Override - Node find(int h, int k) { - // loop to avoid arbitrarily deep recursion on forwarding nodes - outer: - for (Node[] tab = nextTable; ; ) { - Node e; - int n; - if (k == EMPTY_KEY || tab == null || (n = tab.length) == 0 || - (e = tabAt(tab, (n - 1) & h)) == null) - return null; - for (; ; ) { - int eh; - if ((eh = e.hash) == h && e.key == k) - return e; - if (eh < 0) { - if (e instanceof ForwardingNode) { - tab = ((ForwardingNode) e).nextTable; - continue outer; - } else - return e.find(h, k); - } - if ((e = e.next) == null) - return null; - } - } - } - } - - public static final class KeyIterator extends BaseIterator { - - public int next() { - Node p; - if ((p = next) == null) - throw new NoSuchElementException(); - int k = p.key; - lastReturned = p; - advance(); - return k; - } - } - - /** - * A view of a ConcurrentLongHashMap as a long set of keys, in - * which additions may optionally be enabled by mapping to a - * common value. This class cannot be directly instantiated. - * See {@link #keySet() keySet()}, - * {@link #keySet(Object) keySet(V)}, - * {@link #newKeySet() newKeySet()}, - * {@link #newKeySet(int) newKeySet(int)}. - * - * @since 1.8 - */ - public static class KeySetView implements Serializable { - private static final long serialVersionUID = 7249069246763182397L; - private final ConcurrentIntHashMap map; - private final ThreadLocal> tlKeyIterator = ThreadLocal.withInitial(KeyIterator::new); - private final V value; - - KeySetView(ConcurrentIntHashMap map, V value) { // non-public - this.map = map; - this.value = value; - } - - /** - * Adds the specified key to this set view by mapping the key to - * the default mapped value in the backing map, if defined. - * - * @param k key to be added - * @return {@code true} if this set changed as a result of the call - * @throws NullPointerException if the specified key is null - * @throws UnsupportedOperationException if no default mapped value - * for additions was provided - */ - public boolean add(int k) { - V v; - if ((v = value) == null) - throw new UnsupportedOperationException(); - return map.putVal(k, v, true) == null; - } - - public final void clear() { - map.clear(); - } - - public boolean contains(int k) { - return map.containsKey(k); - } - - @Override - public boolean equals(Object o) { - KeySetView c; - return ((o instanceof KeySetView) && - ((c = (KeySetView) o) == this || - (containsAll(c) && c.containsAll(this)))); - } - - /** - * Returns the default mapped value for additions, - * or {@code null} if additions are not supported. - * - * @return the default mapped value for additions, or {@code null} - * if not supported - */ - public V getMappedValue() { - return value; - } - - @Override - public int hashCode() { - int h = 0; - KeyIterator it = iterator(); - if (it.hasNext()) { - do { - int k = it.next(); - h += keyHashCode(k); - } while (it.hasNext()); - } - return h; - } - - public final boolean isEmpty() { - return map.isEmpty(); - } - - /** - * @return an iterator over the keys of the backing map - */ - @NotNull - public KeyIterator iterator() { - KeyIterator it = tlKeyIterator.get(); - it.of(map); - return it; - } - - /** - * Removes the key from this map view, by removing the key (and its - * corresponding value) from the backing map. This method does - * nothing if the key is not in the map. - * - * @param k the key to be removed from the backing map - * @return {@code true} if the backing map contained the specified key - * @throws NullPointerException if the specified key is null - */ - public boolean remove(int k) { - return map.remove(k) != null; - } - - public final int size() { - return map.size(); - } - - @Override - public final String toString() { - StringBuilder sb = new StringBuilder(); - sb.append('['); - KeyIterator it = iterator(); - if (it.hasNext()) { - for (; ; ) { - int k = it.next(); - sb.append(k); - if (!it.hasNext()) - break; - sb.append(',').append(' '); - } - } - return sb.append(']').toString(); - } - - private boolean containsAll(@NotNull KeySetView c) { - KeyIterator it = iterator(); - if (it.hasNext()) { - do { - int k = it.next(); - if (!contains(k)) - return false; - } while (it.hasNext()); - } - return true; - } - } - - /** - * Exported Entry for EntryIterator - */ - static final class MapEntry implements IntEntry { - final int key; // != EMPTY_KEY - final ConcurrentIntHashMap map; - V val; // non-null - - MapEntry(int key, V val, ConcurrentIntHashMap map) { - this.key = key; - this.val = val; - this.map = map; - } - - @Override - public boolean equals(Object o) { - int k; - Object v; - IntEntry e; - return ((o instanceof IntEntry) && - (k = (e = (IntEntry) o).getKey()) != EMPTY_KEY && - (v = e.getValue()) != null && - (k == key) && - (v == val || v.equals(val))); - } - - @Override - public int getKey() { - return key; - } - - @Override - public V getValue() { - return val; - } - - @Override - public int hashCode() { - return keyHashCode(key) ^ val.hashCode(); - } - - /** - * Sets our entry's value and writes through to the map. The - * value to return is somewhat arbitrary here. Since we do not - * necessarily track asynchronous changes, the most recent - * "previous" value could be different from what we return (or - * could even have been removed, in which case the put will - * re-establish). We do not and cannot guarantee more. - */ - @NotNull - public V setValue(V value) { - if (value == null) throw new NullPointerException(); - V v = val; - val = value; - map.put(key, value); - return v; - } - - @Override - public String toString() { - return key + "=" + val; - } - } - - /** - * Key-value entry. This class is never exported out as a - * user-mutable Map.Entry (i.e., one supporting setValue; see - * MapEntry below), but can be used for read-only traversals used - * in bulk tasks. Subclasses of Node with a negative hash field - * are special, and contain null keys and values (but are never - * exported). Otherwise, keys and vals are never null. - */ - static class Node implements IntEntry { - final int hash; - final int key; - volatile Node next; - volatile V val; - - Node(int hash, int key, V val, Node next) { - this.hash = hash; - this.key = key; - this.val = val; - this.next = next; - } - - @Override - public final boolean equals(Object o) { - int k; - Object v, u; - IntEntry e; - return ((o instanceof IntEntry) && - (k = (e = (IntEntry) o).getKey()) != EMPTY_KEY && - (v = e.getValue()) != null && - (k == key) && - (v == (u = val) || v.equals(u))); - } - - @Override - public final int getKey() { - return key; - } - - @Override - public final V getValue() { - return val; - } - - @Override - public final int hashCode() { - return keyHashCode(key) ^ val.hashCode(); - } - - @Override - public final V setValue(V value) { - throw new UnsupportedOperationException(); - } - - @Override - public final String toString() { - return key + "=" + val; - } - - /** - * Virtualized support for map.get(); overridden in subclasses. - */ - Node find(int h, int k) { - Node e = this; - if (k != EMPTY_KEY) { - do { - if (e.hash == h && (e.key == k)) - return e; - } while ((e = e.next) != null); - } - return null; - } - } - - /** - * A place-holder node used in computeIfAbsent and compute - */ - static final class ReservationNode extends Node { - ReservationNode() { - super(RESERVED, EMPTY_KEY, null, null); - } - - @Override - Node find(int h, int k) { - return null; - } - } - - /** - * Stripped-down version of helper class used in previous version, - * declared for the sake of serialization compatibility - */ - static class Segment extends ReentrantLock implements Serializable { - private static final long serialVersionUID = 2249069246763182397L; - final float loadFactor; - - Segment() { - this.loadFactor = ConcurrentIntHashMap.LOAD_FACTOR; - } - } - - /** - * Records the table, its length, and current traversal index for a - * traverser that must process a region of a forwarded table before - * proceeding with current table. - */ - static final class TableStack { - int index; - int length; - TableStack next; - Node[] tab; - } - - /** - * Encapsulates traversal for methods such as containsValue; also - * serves as a base class for other iterators and spliterators. - *

    - * Method advance visits once each still-valid node that was - * reachable upon iterator construction. It might miss some that - * were added to a bin after the bin was visited, which is OK wrt - * consistency guarantees. Maintaining this property in the face - * of possible ongoing resizes requires a fair amount of - * bookkeeping state that is difficult to optimize away amidst - * volatile accesses. Even so, traversal maintains reasonable - * throughput. - *

    - * Normally, iteration proceeds bin-by-bin traversing lists. - * However, if the table has been resized, then all future steps - * must traverse both the bin at the current index as well as at - * (index + baseSize); and so on for further resizings. To - * paranoically cope with potential sharing by users of iterators - * across threads, iteration terminates if a bounds checks fails - * for a table read. - */ - static class Traverser { - int baseIndex; // current index of initial table - int baseLimit; // index bound for initial table - int baseSize; // initial table size - int index; // index of bin to use next - Node next; // the next entry to use - TableStack stack, spare; // to save/restore on ForwardingNodes - Node[] tab; // current table; updated if resized - - /** - * Saves traversal state upon encountering a forwarding node. - */ - private void pushState(Node[] t, int i, int n) { - TableStack s = spare; // reuse if possible - if (s != null) - spare = s.next; - else - s = new TableStack<>(); - s.tab = t; - s.length = n; - s.index = i; - s.next = stack; - stack = s; - } - - /** - * Possibly pops traversal state. - * - * @param n length of current table - */ - private void recoverState(int n) { - TableStack s; - int len; - while ((s = stack) != null && (index += (len = s.length)) >= n) { - n = len; - index = s.index; - tab = s.tab; - s.tab = null; - TableStack next = s.next; - s.next = spare; // save for reuse - stack = next; - spare = s; - } - if (s == null && (index += baseSize) >= n) - index = ++baseIndex; - } - - /** - * Advances if possible, returning next valid node, or null if none. - */ - final Node advance() { - Node e; - if ((e = next) != null) - e = e.next; - for (; ; ) { - Node[] t; - int i, n; // must use locals in checks - if (e != null) - return next = e; - if (baseIndex >= baseLimit || (t = tab) == null || - (n = t.length) <= (i = index) || i < 0) - return next = null; - if ((e = tabAt(t, i)) != null && e.hash < 0) { - if (e instanceof ForwardingNode) { - tab = ((ForwardingNode) e).nextTable; - e = null; - pushState(t, i, n); - continue; - } else if (e instanceof TreeBin) - e = ((TreeBin) e).first; - else - e = null; - } - if (stack != null) - recoverState(n); - else if ((index = i + baseSize) >= n) - index = ++baseIndex; // visit upper slots if present - } - } - - void of(Node[] tab, int size, int limit) { - this.tab = tab; - this.baseSize = size; - this.baseIndex = this.index = 0; - this.baseLimit = limit; - this.next = null; - } - } - - /** - * TreeNodes used at the heads of bins. TreeBins do not hold user - * keys or values, but instead point to list of TreeNodes and - * their root. They also maintain a parasitic read-write lock - * forcing writers (who hold bin lock) to wait for readers (who do - * not) to complete before tree restructuring operations. - */ - static final class TreeBin extends Node { - static final int READER = 4; // increment value for setting read lock - static final int WAITER = 2; // set when waiting for write lock - // values for lockState - static final int WRITER = 1; // set while holding write lock - private static final long LOCKSTATE; - private static final sun.misc.Unsafe U; - volatile TreeNode first; - volatile int lockState; - TreeNode root; - volatile Thread waiter; - - /** - * Creates bin with initial set of nodes headed by b. - */ - TreeBin(TreeNode b) { - super(TREEBIN, EMPTY_KEY, null, null); - this.first = b; - TreeNode r = null; - for (TreeNode x = b, next; x != null; x = next) { - next = (TreeNode) x.next; - x.left = x.right = null; - if (r == null) { - x.parent = null; - x.red = false; - r = x; - } else { - int k = x.key; - int h = x.hash; - for (TreeNode p = r; ; ) { - int dir, ph; - int pk = p.key; - if ((ph = p.hash) > h) - dir = -1; - else if (ph < h) - dir = 1; - else if ((dir = compareComparables(k, pk)) == 0) - dir = tieBreakOrder(k, pk); - TreeNode xp = p; - if ((p = (dir <= 0) ? p.left : p.right) == null) { - x.parent = xp; - if (dir <= 0) - xp.left = x; - else - xp.right = x; - r = balanceInsertion(r, x); - break; - } - } - } - } - this.root = r; - assert checkInvariants(root); - } - - /** - * Possibly blocks awaiting root lock. - */ - private void contendedLock() { - boolean waiting = false; - for (int s; ; ) { - if (((s = lockState) & ~WAITER) == 0) { - if (U.compareAndSwapInt(this, LOCKSTATE, s, WRITER)) { - if (waiting) - waiter = null; - return; - } - } else if ((s & WAITER) == 0) { - if (U.compareAndSwapInt(this, LOCKSTATE, s, s | WAITER)) { - waiting = true; - waiter = Thread.currentThread(); - } - } else if (waiting) - LockSupport.park(this); - } - } - - /** - * Acquires write lock for tree restructuring. - */ - private void lockRoot() { - if (!U.compareAndSwapInt(this, LOCKSTATE, 0, WRITER)) - contendedLock(); // offload to separate method - } - - /** - * Releases write lock for tree restructuring. - */ - private void unlockRoot() { - lockState = 0; - } - - static TreeNode balanceDeletion(TreeNode root, - TreeNode x) { - for (TreeNode xp, xpl, xpr; ; ) { - if (x == null || x == root) - return root; - else if ((xp = x.parent) == null) { - x.red = false; - return x; - } else if (x.red) { - x.red = false; - return root; - } else if ((xpl = xp.left) == x) { - if ((xpr = xp.right) != null && xpr.red) { - xpr.red = false; - xp.red = true; - root = rotateLeft(root, xp); - xpr = (xp = x.parent) == null ? null : xp.right; - } - if (xpr == null) - x = xp; - else { - TreeNode sl = xpr.left, sr = xpr.right; - if ((sr == null || !sr.red) && - (sl == null || !sl.red)) { - xpr.red = true; - x = xp; - } else { - if (sr == null || !sr.red) { - sl.red = false; - xpr.red = true; - root = rotateRight(root, xpr); - xpr = (xp = x.parent) == null ? - null : xp.right; - } - if (xpr != null) { - xpr.red = xp.red; - if ((sr = xpr.right) != null) - sr.red = false; - } - if (xp != null) { - xp.red = false; - root = rotateLeft(root, xp); - } - x = root; - } - } - } else { // symmetric - if (xpl != null && xpl.red) { - xpl.red = false; - xp.red = true; - root = rotateRight(root, xp); - xpl = (xp = x.parent) == null ? null : xp.left; - } - if (xpl == null) - x = xp; - else { - TreeNode sl = xpl.left, sr = xpl.right; - if ((sl == null || !sl.red) && - (sr == null || !sr.red)) { - xpl.red = true; - x = xp; - } else { - if (sl == null || !sl.red) { - sr.red = false; - xpl.red = true; - root = rotateLeft(root, xpl); - xpl = (xp = x.parent) == null ? - null : xp.left; - } - if (xpl != null) { - xpl.red = xp.red; - if ((sl = xpl.left) != null) - sl.red = false; - } - if (xp != null) { - xp.red = false; - root = rotateRight(root, xp); - } - x = root; - } - } - } - } - } - - static TreeNode balanceInsertion(TreeNode root, - TreeNode x) { - x.red = true; - for (TreeNode xp, xpp, xppl, xppr; ; ) { - if ((xp = x.parent) == null) { - x.red = false; - return x; - } else if (!xp.red || (xpp = xp.parent) == null) - return root; - if (xp == (xppl = xpp.left)) { - if ((xppr = xpp.right) != null && xppr.red) { - xppr.red = false; - xp.red = false; - xpp.red = true; - x = xpp; - } else { - if (x == xp.right) { - root = rotateLeft(root, x = xp); - xpp = (xp = x.parent) == null ? null : xp.parent; - } - if (xp != null) { - xp.red = false; - if (xpp != null) { - xpp.red = true; - root = rotateRight(root, xpp); - } - } - } - } else { - if (xppl != null && xppl.red) { - xppl.red = false; - xp.red = false; - xpp.red = true; - x = xpp; - } else { - if (x == xp.left) { - root = rotateRight(root, x = xp); - xpp = (xp = x.parent) == null ? null : xp.parent; - } - if (xp != null) { - xp.red = false; - if (xpp != null) { - xpp.red = true; - root = rotateLeft(root, xpp); - } - } - } - } - } - } - - /** - * Recursive invariant check - */ - @SuppressWarnings("SimplifiableIfStatement") - static boolean checkInvariants(TreeNode t) { - TreeNode tp = t.parent, tl = t.left, tr = t.right, - tb = t.prev, tn = (TreeNode) t.next; - if (tb != null && tb.next != t) - return false; - if (tn != null && tn.prev != t) - return false; - if (tp != null && t != tp.left && t != tp.right) - return false; - if (tl != null && (tl.parent != t || tl.hash > t.hash)) - return false; - if (tr != null && (tr.parent != t || tr.hash < t.hash)) - return false; - if (t.red && tl != null && tl.red && tr != null && tr.red) - return false; - if (tl != null && !checkInvariants(tl)) - return false; - return !(tr != null && !checkInvariants(tr)); - } - - static TreeNode rotateLeft(TreeNode root, TreeNode p) { - TreeNode r, pp, rl; - if (p != null && (r = p.right) != null) { - if ((rl = p.right = r.left) != null) - rl.parent = p; - if ((pp = r.parent = p.parent) == null) - (root = r).red = false; - else if (pp.left == p) - pp.left = r; - else - pp.right = r; - r.left = p; - p.parent = r; - } - return root; - } - - static TreeNode rotateRight(TreeNode root, TreeNode p) { - TreeNode l, pp, lr; - if (p != null && (l = p.left) != null) { - if ((lr = p.left = l.right) != null) - lr.parent = p; - if ((pp = l.parent = p.parent) == null) - (root = l).red = false; - else if (pp.right == p) - pp.right = l; - else - pp.left = l; - l.right = p; - p.parent = l; - } - return root; - } - - /** - * Tie-breaking utility for ordering insertions when equal - * hashCodes and non-comparable. We don't require a total - * order, just a consistent insertion rule to maintain - * equivalence across rebalancings. Tie-breaking further than - * necessary simplifies testing a bit. - */ - static int tieBreakOrder(Object a, Object b) { - int d; - if (a == null || b == null || - (d = a.getClass().getName(). - compareTo(b.getClass().getName())) == 0) - d = (System.identityHashCode(a) <= System.identityHashCode(b) ? - -1 : 1); - return d; - } - - /** - * Returns matching node or null if none. Tries to search - * using tree comparisons from root, but continues linear - * search when lock not available. - */ - @Override - Node find(int h, int k) { - if (k != EMPTY_KEY) { - for (Node e = first; e != null; ) { - int s; - if (((s = lockState) & (WAITER | WRITER)) != 0) { - if (e.hash == h && e.key == k) - return e; - e = e.next; - } else if (U.compareAndSwapInt(this, LOCKSTATE, s, - s + READER)) { - TreeNode r, p; - try { - p = ((r = root) == null ? null : - r.findTreeNode(h, k)); - } finally { - Thread w; - if (U.getAndAddInt(this, LOCKSTATE, -READER) == - (READER | WAITER) && (w = waiter) != null) - LockSupport.unpark(w); - } - return p; - } - } - } - return null; - } - - /** - * Finds or adds a node. - * - * @return null if added - */ - TreeNode putTreeVal(int h, int k, V v) { - boolean searched = false; - for (TreeNode p = root; ; ) { - int dir, ph; - int pk; - if (p == null) { - first = root = new TreeNode<>(h, k, v, null, null); - break; - } else if ((ph = p.hash) > h) - dir = -1; - else if (ph < h) - dir = 1; - else if ((pk = p.key) == k) - return p; - else if ((dir = compareComparables(k, pk)) == 0) { - if (!searched) { - TreeNode q, ch; - searched = true; - if (((ch = p.left) != null && - (q = ch.findTreeNode(h, k)) != null) || - ((ch = p.right) != null && - (q = ch.findTreeNode(h, k)) != null)) - return q; - } - dir = tieBreakOrder(k, pk); - } - - TreeNode xp = p; - if ((p = (dir <= 0) ? p.left : p.right) == null) { - TreeNode x, f = first; - first = x = new TreeNode<>(h, k, v, f, xp); - if (f != null) - f.prev = x; - if (dir <= 0) - xp.left = x; - else - xp.right = x; - if (!xp.red) - x.red = true; - else { - lockRoot(); - try { - root = balanceInsertion(root, x); - } finally { - unlockRoot(); - } - } - break; - } - } - assert checkInvariants(root); - return null; - } - - /** - * Removes the given node, that must be present before this - * call. This is messier than typical red-black deletion code - * because we cannot swap the contents of an interior node - * with a leaf successor that is pinned by "next" pointers - * that are accessible independently of lock. So instead we - * swap the tree linkages. - * - * @return true if now too small, so should be untreeified - */ - boolean removeTreeNode(TreeNode p) { - TreeNode next = (TreeNode) p.next; - TreeNode pred = p.prev; // unlink traversal pointers - TreeNode r, rl; - if (pred == null) - first = next; - else - pred.next = next; - if (next != null) - next.prev = pred; - if (first == null) { - root = null; - return true; - } - if ((r = root) == null || r.right == null || // too small - (rl = r.left) == null || rl.left == null) - return true; - lockRoot(); - try { - TreeNode replacement; - TreeNode pl = p.left; - TreeNode pr = p.right; - if (pl != null && pr != null) { - TreeNode s = pr, sl; - while ((sl = s.left) != null) // find successor - s = sl; - boolean c = s.red; - s.red = p.red; - p.red = c; // swap colors - TreeNode sr = s.right; - TreeNode pp = p.parent; - if (s == pr) { // p was s's direct parent - p.parent = s; - s.right = p; - } else { - TreeNode sp = s.parent; - if ((p.parent = sp) != null) { - if (s == sp.left) - sp.left = p; - else - sp.right = p; - } - s.right = pr; - pr.parent = s; - } - p.left = null; - if ((p.right = sr) != null) - sr.parent = p; - s.left = pl; - pl.parent = s; - if ((s.parent = pp) == null) - r = s; - else if (p == pp.left) - pp.left = s; - else - pp.right = s; - if (sr != null) - replacement = sr; - else - replacement = p; - } else if (pl != null) - replacement = pl; - else if (pr != null) - replacement = pr; - else - replacement = p; - if (replacement != p) { - TreeNode pp = replacement.parent = p.parent; - if (pp == null) - r = replacement; - else if (p == pp.left) - pp.left = replacement; - else - pp.right = replacement; - p.left = p.right = p.parent = null; - } - - root = (p.red) ? r : balanceDeletion(r, replacement); - - if (p == replacement) { // detach pointers - TreeNode pp; - if ((pp = p.parent) != null) { - if (p == pp.left) - pp.left = null; - else if (p == pp.right) - pp.right = null; - p.parent = null; - } - } - } finally { - unlockRoot(); - } - assert checkInvariants(root); - return false; - } - - static { - try { - U = Unsafe.getUnsafe(); - Class k = TreeBin.class; - LOCKSTATE = U.objectFieldOffset - (k.getDeclaredField("lockState")); - } catch (Exception e) { - throw new Error(e); - } - } - - - - - - - - - - - - - - - - /* ------------------------------------------------------------ */ - // Red-black tree methods, all adapted from CLR - } - - /** - * Nodes for use in TreeBins - */ - static final class TreeNode extends Node { - TreeNode left; - TreeNode parent; // red-black tree links - TreeNode prev; // needed to unlink next upon deletion - boolean red; - TreeNode right; - - TreeNode(int hash, int key, V val, Node next, TreeNode parent) { - super(hash, key, val, next); - this.parent = parent; - } - - @Override - Node find(int h, int k) { - return findTreeNode(h, k); - } - - /** - * Returns the TreeNode (or null if not found) for the given key - * starting at given root. - */ - TreeNode findTreeNode(int h, int k) { - if (k != EMPTY_KEY) { - TreeNode p = this; - do { - int ph, dir; - int pk; - TreeNode q; - TreeNode pl = p.left, pr = p.right; - if ((ph = p.hash) > h) - p = pl; - else if (ph < h) - p = pr; - else if ((pk = p.key) == k) - return p; - else if (pl == null) - p = pr; - else if (pr == null) - p = pl; - else if ((dir = compareComparables(k, pk)) != 0) - p = (dir < 0) ? pl : pr; - else if ((q = pr.findTreeNode(h, k)) != null) - return q; - else - p = pl; - } while (p != null); - } - return null; - } - - - } - - static final class ValueIterator extends BaseIterator - implements Iterator { - public V next() { - Node p; - if ((p = next) == null) - throw new NoSuchElementException(); - V v = p.val; - lastReturned = p; - advance(); - return v; - } - } - - /** - * A view of a ConcurrentLongHashMap as a {@link Collection} of - * values, in which additions are disabled. This class cannot be - * directly instantiated. See {@link #values()}. - */ - static final class ValuesView extends CollectionView - implements Collection, Serializable { - private static final long serialVersionUID = 2249069246763182397L; - private final ThreadLocal> tlValueIterator = ThreadLocal.withInitial(ValueIterator::new); - - ValuesView(ConcurrentIntHashMap map) { - super(map); - } - - @Override - public boolean add(V e) { - throw new UnsupportedOperationException(); - } - - @Override - public boolean addAll(@NotNull Collection c) { - throw new UnsupportedOperationException(); - } - - @Override - public boolean contains(Object o) { - return map.containsValue((V) o); - } - - @Override - @NotNull - public Iterator iterator() { - ValueIterator it = tlValueIterator.get(); - it.of(map); - return it; - } - - @Override - public boolean remove(Object o) { - if (o != null) { - for (Iterator it = iterator(); it.hasNext(); ) { - if (o.equals(it.next())) { - it.remove(); - return true; - } - } - } - return false; - } - } - - static { - try { - Class tk = Thread.class; - SEED = Unsafe.getUnsafe().objectFieldOffset(tk.getDeclaredField("threadLocalRandomSeed")); - PROBE = Unsafe.getUnsafe().objectFieldOffset(tk.getDeclaredField("threadLocalRandomProbe")); - } catch (Exception e) { - throw new Error(e); - } - } - - static { - try { - Class k = ConcurrentIntHashMap.class; - SIZECTL = Unsafe.getUnsafe().objectFieldOffset - (k.getDeclaredField("sizeCtl")); - TRANSFERINDEX = Unsafe.getUnsafe().objectFieldOffset - (k.getDeclaredField("transferIndex")); - BASECOUNT = Unsafe.getUnsafe().objectFieldOffset - (k.getDeclaredField("baseCount")); - CELLSBUSY = Unsafe.getUnsafe().objectFieldOffset - (k.getDeclaredField("cellsBusy")); - Class ck = CounterCell.class; - CELLVALUE = Unsafe.getUnsafe().objectFieldOffset - (ck.getDeclaredField("value")); - Class ak = Node[].class; - ABASE = Unsafe.getUnsafe().arrayBaseOffset(ak); - int scale = Unsafe.getUnsafe().arrayIndexScale(ak); - if ((scale & (scale - 1)) != 0) - throw new Error("data type scale not a power of two"); - ASHIFT = 31 - Integer.numberOfLeadingZeros(scale); - } catch (Exception e) { - throw new Error(e); - } - } -} diff --git a/core/src/main/java/io/questdb/client/std/FilesFacade.java b/core/src/main/java/io/questdb/client/std/FilesFacade.java deleted file mode 100644 index 59b0582..0000000 --- a/core/src/main/java/io/questdb/client/std/FilesFacade.java +++ /dev/null @@ -1,33 +0,0 @@ -/******************************************************************************* - * ___ _ ____ ____ - * / _ \ _ _ ___ ___| |_| _ \| __ ) - * | | | | | | |/ _ \/ __| __| | | | _ \ - * | |_| | |_| | __/\__ \ |_| |_| | |_) | - * \__\_\\__,_|\___||___/\__|____/|____/ - * - * Copyright (c) 2014-2019 Appsicle - * Copyright (c) 2019-2026 QuestDB - * - * Licensed 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. - * - ******************************************************************************/ - -package io.questdb.client.std; - -public interface FilesFacade { - boolean close(long fd); - - int errno(); - - long length(long fd); -} \ No newline at end of file diff --git a/core/src/main/java/io/questdb/client/std/GenericLexer.java b/core/src/main/java/io/questdb/client/std/GenericLexer.java deleted file mode 100644 index 8a7bedd..0000000 --- a/core/src/main/java/io/questdb/client/std/GenericLexer.java +++ /dev/null @@ -1,435 +0,0 @@ -/******************************************************************************* - * ___ _ ____ ____ - * / _ \ _ _ ___ ___| |_| _ \| __ ) - * | | | | | | |/ _ \/ __| __| | | | _ \ - * | |_| | |_| | __/\__ \ |_| |_| | |_) | - * \__\_\\__,_|\___||___/\__|____/|____/ - * - * Copyright (c) 2014-2019 Appsicle - * Copyright (c) 2019-2026 QuestDB - * - * Licensed 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. - * - ******************************************************************************/ - -package io.questdb.client.std; - -import io.questdb.client.std.str.AbstractCharSequence; -import io.questdb.client.std.str.Utf16Sink; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.ArrayDeque; -import java.util.Comparator; - -public class GenericLexer implements ImmutableIterator, Mutable { - public static final LenComparator COMPARATOR = new LenComparator(); - public static final CharSequenceHashSet WHITESPACE = new CharSequenceHashSet(); - public static final IntHashSet WHITESPACE_CH = new IntHashSet(); - - private final ObjectPool csPairPool; - private final ObjectPool csPool; - private final ObjectPool csTriplePool; - private final CharSequence flyweightSequence = new InternalFloatingSequence(); - private final IntStack stashedNumbers = new IntStack(); - private final ArrayDeque stashedStrings = new ArrayDeque<>(); - private final IntObjHashMap> symbols = new IntObjHashMap<>(); - private final ArrayDeque unparsed = new ArrayDeque<>(); - private final IntStack unparsedPosition = new IntStack(); - private int _hi; - private int _len; - private int _lo; - private int _pos; - - private CharSequence content; - private CharSequence last; - private CharSequence next = null; - - public GenericLexer(int poolCapacity) { - csPool = new ObjectPool<>(FloatingSequence::new, poolCapacity); - csPairPool = new ObjectPool<>(FloatingSequencePair::new, poolCapacity); - csTriplePool = new ObjectPool<>(FloatingSequenceTriple::new, poolCapacity); - for (int i = 0, n = WHITESPACE.size(); i < n; i++) { - defineSymbol(Chars.toString(WHITESPACE.get(i))); - } - } - - @Override - public void clear() { - of(null, 0, 0); - - stashedNumbers.clear(); - stashedStrings.clear(); - } - - public final void defineSymbol(String token) { - char c0 = token.charAt(0); - ObjList l; - int index = symbols.keyIndex(c0); - if (index > -1) { - l = new ObjList<>(); - symbols.putAt(index, c0, l); - } else { - l = symbols.valueAtQuick(index); - } - l.add(token); - l.sort(COMPARATOR); - } - - @Override - public boolean hasNext() { - boolean n = next != null || hasUnparsed() || (content != null && _pos < _len); - if (!n && last != null) { - last = null; - } - return n; - } - - public boolean hasUnparsed() { - return !unparsed.isEmpty(); - } - - @Override - public CharSequence next() { - if (!unparsed.isEmpty()) { - this._lo = unparsedPosition.pollLast(); - this._pos = unparsedPosition.pollLast(); - - return last = unparsed.pollLast(); - } - - this._lo = this._hi; - - if (next != null) { - CharSequence result = next; - next = null; - return last = result; - } - - this._lo = this._hi = _pos; - - char term = 0; - int openTermIdx = -1; - while (_pos < _len) { - char c = content.charAt(_pos++); - CharSequence token; - switch (term) { - case 0: - switch (c) { - case '\'': - term = '\''; - openTermIdx = _pos - 1; - break; - case '"': - term = '"'; - openTermIdx = _pos - 1; - break; - case '`': - term = '`'; - openTermIdx = _pos - 1; - break; - default: - if ((token = token(c)) != null) { - return last = token; - } else { - _hi++; - } - break; - } - break; - case '\'': - if (c == '\'') { - _hi += 2; - if (_pos < _len && content.charAt(_pos) == '\'') { - _pos++; - } else { - return last = flyweightSequence; - } - } else { - _hi++; - } - break; - case '"': - if (c == '"') { - _hi += 2; - if (_pos < _len && content.charAt(_pos) == '"') { - _pos++; - } else { - return last = flyweightSequence; - } - } else { - _hi++; - } - break; - case '`': - if (c == '`') { - _hi += 2; - return last = flyweightSequence; - } else { - _hi++; - } - break; - default: - break; - } - } - if (openTermIdx != -1) { // dangling terms - if (_len == 1) { - _hi += 1; // emit term - } else { - if (openTermIdx == _lo) { // term is at the start - _hi = _lo + 1; // emit term - _pos = _hi; // rewind pos - } else if (openTermIdx == _len - 1) { // term is at the end, high is right on term - FloatingSequence termFs = csPool.next(); - termFs.lo = _hi; - termFs.hi = _hi + 1; - next = termFs; // emit term next - } else { // term is somewhere in between - _hi = openTermIdx; // emit whatever comes before term - _pos = openTermIdx; // rewind pos - } - } - } - return last = flyweightSequence; - } - - public void of(CharSequence cs, int lo, int hi) { - this.csPool.clear(); - this.csPairPool.clear(); - this.csTriplePool.clear(); - this.content = cs; - this._pos = lo; - this._len = hi; - this.next = null; - this.unparsed.clear(); - this.unparsedPosition.clear(); - this.last = null; - } - - private static CharSequence findToken0(char c, CharSequence content, int _pos, int _len, IntObjHashMap> symbols) { - final int index = symbols.keyIndex(c); - return index > -1 ? null : findToken00(content, _pos, _len, symbols, index); - } - - @Nullable - private static CharSequence findToken00(CharSequence content, int _pos, int _len, IntObjHashMap> symbols, int index) { - final ObjList l = symbols.valueAt(index); - for (int i = 0, sz = l.size(); i < sz; i++) { - CharSequence txt = l.getQuick(i); - int n = txt.length(); - boolean match = (n - 2) < (_len - _pos); - if (match) { - for (int k = 1; k < n; k++) { - if (content.charAt(_pos + (k - 1)) != txt.charAt(k)) { - match = false; - break; - } - } - } - - if (match) { - return txt; - } - } - return null; - } - - private CharSequence token(char c) { - CharSequence t = findToken0(c, content, _pos, _len, symbols); - if (t != null) { - _pos = _pos + t.length() - 1; - if (_lo == _hi) { - return t; - } - next = t; - return flyweightSequence; - } else { - return null; - } - } - - public static class FloatingSequencePair extends AbstractCharSequence implements Mutable { - public static final char NO_SEPARATOR = (char) 0; - - public FloatingSequence cs0; - public FloatingSequence cs1; - char sep = NO_SEPARATOR; - - @Override - public char charAt(int index) { - int cs0Len = cs0.length(); - if (index < cs0Len) { - return cs0.charAt(index); - } - if (sep == NO_SEPARATOR) { - return cs1.charAt(index - cs0Len); - } - return index == cs0Len ? sep : cs1.charAt(index - cs0Len - 1); - } - - @Override - public void clear() { - // no-op - } - - @Override - public int length() { - return cs0.length() + cs1.length() + (sep != NO_SEPARATOR ? 1 : 0); - } - - @NotNull - @Override - public String toString() { - final Utf16Sink b = Misc.getThreadLocalSink(); - b.put(cs0); - if (sep != NO_SEPARATOR) { - b.put(sep); - } - b.put(cs1); - return b.toString(); - } - } - - public static class FloatingSequenceTriple extends AbstractCharSequence implements Mutable { - public static final char NO_SEPARATOR = (char) 0; - - public FloatingSequence cs0; - public FloatingSequence cs1; - public FloatingSequence cs2; - char sep = NO_SEPARATOR; - - @Override - public char charAt(int index) { - int cs0Len = cs0.length(); - if (index < cs0Len) { - return cs0.charAt(index); - } - index -= cs0Len; - if (sep != NO_SEPARATOR) { - if (index == 0) { - return sep; - } - index--; - } - int cs1Len = cs1.length(); - if (index < cs1Len) { - return cs1.charAt(index); - } - index -= cs1Len; - if (sep != NO_SEPARATOR) { - if (index == 0) { - return sep; - } - index--; - } - return cs2.charAt(index); - } - - @Override - public void clear() { - // no-op - } - - @Override - public int length() { - return cs0.length() + cs1.length() + cs2.length() + (sep != NO_SEPARATOR ? 2 : 0); - } - - @NotNull - @Override - public String toString() { - final Utf16Sink b = Misc.getThreadLocalSink(); - b.put(cs0); - if (sep != NO_SEPARATOR) { - b.put(sep); - } - b.put(cs1); - if (sep != NO_SEPARATOR) { - b.put(sep); - } - b.put(cs2); - return b.toString(); - } - } - - public static class LenComparator implements Comparator { - @Override - public int compare(CharSequence o1, CharSequence o2) { - return o2.length() - o1.length(); - } - } - - public class FloatingSequence extends AbstractCharSequence implements Mutable, BufferWindowCharSequence { - int hi; - int lo; - - @Override - public char charAt(int index) { - return content.charAt(lo + index); - } - - @Override - public void clear() { - } - - @Override - public int length() { - return hi - lo; - } - - @Override - protected final CharSequence _subSequence(int start, int end) { - FloatingSequence that = csPool.next(); - that.lo = lo + start; - that.hi = lo + end; - assert that.lo <= that.hi; - return that; - } - } - - public class InternalFloatingSequence extends AbstractCharSequence { - - @Override - public char charAt(int index) { - return content.charAt(_lo + index); - } - - @Override - public int length() { - return _hi - _lo; - } - - @Override - protected CharSequence _subSequence(int start, int end) { - FloatingSequence next = csPool.next(); - next.lo = _lo + start; - next.hi = _lo + end; - assert next.lo <= next.hi; - return next; - } - - } - - static { - WHITESPACE.add(" "); - WHITESPACE.add("\t"); - WHITESPACE.add("\n"); - WHITESPACE.add("\r"); - - WHITESPACE_CH.add(' '); - WHITESPACE_CH.add('\t'); - WHITESPACE_CH.add('\n'); - WHITESPACE_CH.add('\r'); - } -} \ No newline at end of file diff --git a/core/src/main/java/io/questdb/client/std/Hash.java b/core/src/main/java/io/questdb/client/std/Hash.java index f2a557b..d12b374 100644 --- a/core/src/main/java/io/questdb/client/std/Hash.java +++ b/core/src/main/java/io/questdb/client/std/Hash.java @@ -26,19 +26,8 @@ public final class Hash { - // Constant from Rust compiler's FxHasher. - private static final long M2 = 0x517cc1b727220a95L; - private static final int SPREAD_HASH_BITS = 0x7fffffff; - public static int hashLong128_32(long key1, long key2) { - return (int) hashLong128_64(key1, key2); - } - - public static long hashLong128_64(long key1, long key2) { - return fmix64(key1 * M2 + key2); - } - public static int hashLong32(long k) { return (int) hashLong64(k); } diff --git a/core/src/main/java/io/questdb/client/std/ImmutableIterator.java b/core/src/main/java/io/questdb/client/std/ImmutableIterator.java deleted file mode 100644 index c974c3e..0000000 --- a/core/src/main/java/io/questdb/client/std/ImmutableIterator.java +++ /dev/null @@ -1,38 +0,0 @@ -/******************************************************************************* - * ___ _ ____ ____ - * / _ \ _ _ ___ ___| |_| _ \| __ ) - * | | | | | | |/ _ \/ __| __| | | | _ \ - * | |_| | |_| | __/\__ \ |_| |_| | |_) | - * \__\_\\__,_|\___||___/\__|____/|____/ - * - * Copyright (c) 2014-2019 Appsicle - * Copyright (c) 2019-2026 QuestDB - * - * Licensed 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. - * - ******************************************************************************/ - -package io.questdb.client.std; - -import org.jetbrains.annotations.NotNull; - -import java.util.Iterator; - -public interface ImmutableIterator extends Iterator, Iterable { - - @Override - @NotNull - default Iterator iterator() { - return this; - } -} \ No newline at end of file diff --git a/core/src/main/java/io/questdb/client/std/CharSequenceHashSet.java b/core/src/main/java/io/questdb/client/std/LongHashSet.java similarity index 56% rename from core/src/main/java/io/questdb/client/std/CharSequenceHashSet.java rename to core/src/main/java/io/questdb/client/std/LongHashSet.java index 1788881..4ec2648 100644 --- a/core/src/main/java/io/questdb/client/std/CharSequenceHashSet.java +++ b/core/src/main/java/io/questdb/client/std/LongHashSet.java @@ -27,40 +27,37 @@ import io.questdb.client.std.str.CharSink; import io.questdb.client.std.str.Sinkable; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import java.util.Arrays; -public class CharSequenceHashSet extends AbstractCharSequenceHashSet implements Sinkable { + +public class LongHashSet extends AbstractLongHashSet implements Sinkable { + + public static final double DEFAULT_LOAD_FACTOR = 0.4; private static final int MIN_INITIAL_CAPACITY = 16; - private final ObjList list; - private boolean hasNull = false; + private final LongList list; - public CharSequenceHashSet() { + public LongHashSet() { this(MIN_INITIAL_CAPACITY); } - private CharSequenceHashSet(int initialCapacity) { - this(initialCapacity, 0.4); + public LongHashSet(int initialCapacity) { + this(initialCapacity, DEFAULT_LOAD_FACTOR, noEntryKey); } - public CharSequenceHashSet(int initialCapacity, double loadFactor) { - super(initialCapacity, loadFactor); - list = new ObjList<>(free); + public LongHashSet(int initialCapacity, double loadFactor, long noKeyValue) { + super(initialCapacity, loadFactor, noKeyValue); + list = new LongList(free); clear(); } /** * Adds key to hash set preserving key uniqueness. * - * @param key immutable sequence of characters. + * @param key key to be added. * @return false if key is already in the set and true otherwise. */ - public boolean add(@Nullable CharSequence key) { - if (key == null) { - return addNull(); - } - + public boolean add(long key) { int index = keyIndex(key); if (index < 0) { return false; @@ -70,45 +67,43 @@ public boolean add(@Nullable CharSequence key) { return true; } - public void addAt(int index, @NotNull CharSequence key) { - final String s = Chars.toString(key); - keys[index] = s; - list.add(s); + public void addAt(int index, long key) { + keys[index] = key; + list.add(key); if (--free < 1) { rehash(); } } - public boolean addNull() { - if (hasNull) { - return false; - } - --free; - hasNull = true; - list.add(null); - return true; - } - - @Override public final void clear() { free = capacity; - Arrays.fill(keys, null); + Arrays.fill(keys, noEntryKeyValue); list.clear(); - hasNull = false; } - @Override - public boolean contains(@Nullable CharSequence key) { - return key == null ? hasNull : keyIndex(key) < 0; + public boolean contains(long key) { + return keyIndex(key) < 0; } - public CharSequence get(int index) { + public long get(int index) { return list.getQuick(index); } + public long getLast() { + return list.getLast(); + } + + public void removeAt(int index) { + if (index < 0) { + long key = keys[-index - 1]; + super.removeAt(index); + listRemove(key); + } + } + @Override public void toSink(@NotNull CharSink sink) { - sink.put(list); + list.toSink(sink); } @Override @@ -116,18 +111,45 @@ public String toString() { return list.toString(); } + private void listRemove(long v) { + int sz = list.size(); + for (int i = 0; i < sz; i++) { + if (list.getQuick(i) == v) { + // shift remaining elements left + for (int j = i + 1; j < sz; j++) { + list.setQuick(j - 1, list.getQuick(j)); + } + list.setPos(sz - 1); + return; + } + } + } + private void rehash() { int newCapacity = capacity * 2; free = capacity = newCapacity; int len = Numbers.ceilPow2((int) (newCapacity / loadFactor)); - this.keys = new CharSequence[len]; + this.keys = new long[len]; + Arrays.fill(keys, noEntryKeyValue); mask = len - 1; int n = list.size(); free -= n; for (int i = 0; i < n; i++) { - final CharSequence key = list.getQuick(i); - keys[keyIndex(key)] = key; + long key = list.getQuick(i); + int keyIndex = keyIndex(key); + keys[keyIndex] = key; } } -} \ No newline at end of file + @Override + protected void erase(int index) { + keys[index] = noEntryKeyValue; + } + + @Override + protected void move(int from, int to) { + keys[to] = keys[from]; + erase(from); + } + +} diff --git a/core/src/main/java/io/questdb/client/std/LongObjHashMap.java b/core/src/main/java/io/questdb/client/std/LongObjHashMap.java deleted file mode 100644 index 09a7ef6..0000000 --- a/core/src/main/java/io/questdb/client/std/LongObjHashMap.java +++ /dev/null @@ -1,110 +0,0 @@ -/******************************************************************************* - * ___ _ ____ ____ - * / _ \ _ _ ___ ___| |_| _ \| __ ) - * | | | | | | |/ _ \/ __| __| | | | _ \ - * | |_| | |_| | __/\__ \ |_| |_| | |_) | - * \__\_\\__,_|\___||___/\__|____/|____/ - * - * Copyright (c) 2014-2019 Appsicle - * Copyright (c) 2019-2026 QuestDB - * - * Licensed 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. - * - ******************************************************************************/ - -package io.questdb.client.std; - -import java.util.Arrays; - -public class LongObjHashMap extends AbstractLongHashSet { - private V[] values; - - public LongObjHashMap() { - this(8); - } - - public LongObjHashMap(int initialCapacity) { - this(initialCapacity, 0.5f); - } - - @SuppressWarnings("unchecked") - private LongObjHashMap(int initialCapacity, double loadFactor) { - super(initialCapacity, loadFactor); - values = (V[]) new Object[keys.length]; - clear(); - } - - @Override - public void clear() { - super.clear(); - Arrays.fill(values, null); - } - - public void putAt(int index, long key, V value) { - if (index < 0) { - values[-index - 1] = value; - } else { - keys[index] = key; - values[index] = value; - if (--free == 0) { - rehash(); - } - } - } - - public V valueAt(int index) { - return index < 0 ? valueAtQuick(index) : null; - } - - public V valueAtQuick(int index) { - return values[-index - 1]; - } - - @SuppressWarnings("unchecked") - private void rehash() { - int size = size(); - int newCapacity = capacity * 2; - free = capacity = newCapacity; - int len = Numbers.ceilPow2((int) (newCapacity / loadFactor)); - - V[] oldValues = values; - long[] oldKeys = keys; - this.keys = new long[len]; - this.values = (V[]) new Object[len]; - Arrays.fill(keys, noEntryKeyValue); - mask = len - 1; - - free -= size; - for (int i = oldKeys.length; i-- > 0; ) { - long key = oldKeys[i]; - if (key != noEntryKeyValue) { - final int index = keyIndex(key); - keys[index] = key; - values[index] = oldValues[i]; - } - } - } - - @Override - protected void erase(int index) { - keys[index] = this.noEntryKeyValue; - } - - @Override - protected void move(int from, int to) { - keys[to] = keys[from]; - values[to] = values[from]; - erase(from); - } - -} \ No newline at end of file diff --git a/core/src/main/java/io/questdb/client/std/LowerCaseCharSequenceHashSet.java b/core/src/main/java/io/questdb/client/std/LowerCaseCharSequenceHashSet.java deleted file mode 100644 index 40d6090..0000000 --- a/core/src/main/java/io/questdb/client/std/LowerCaseCharSequenceHashSet.java +++ /dev/null @@ -1,106 +0,0 @@ -/******************************************************************************* - * ___ _ ____ ____ - * / _ \ _ _ ___ ___| |_| _ \| __ ) - * | | | | | | |/ _ \/ __| __| | | | _ \ - * | |_| | |_| | __/\__ \ |_| |_| | |_) | - * \__\_\\__,_|\___||___/\__|____/|____/ - * - * Copyright (c) 2014-2019 Appsicle - * Copyright (c) 2019-2026 QuestDB - * - * Licensed 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. - * - ******************************************************************************/ - -package io.questdb.client.std; - -public class LowerCaseCharSequenceHashSet extends AbstractLowerCaseCharSequenceHashSet { - private static final int MIN_INITIAL_CAPACITY = 16; - - public LowerCaseCharSequenceHashSet() { - this(MIN_INITIAL_CAPACITY); - } - - private LowerCaseCharSequenceHashSet(int initialCapacity) { - this(initialCapacity, 0.4); - } - - private LowerCaseCharSequenceHashSet(int initialCapacity, double loadFactor) { - super(initialCapacity, loadFactor); - clear(); - } - - /** - * Adds key to hash set preserving key uniqueness. - * - * @param key immutable sequence of characters. - * @return false if key is already in the set and true otherwise. - */ - public boolean add(CharSequence key) { - int index = keyIndex(key); - if (index < 0) { - return false; - } - - addAt(index, key); - return true; - } - - public void addAt(int index, CharSequence key) { - keys[index] = key; - if (--free < 1) { - rehash(); - } - } - - // returns the first non-null key, in arbitrary order - public CharSequence getAny() { - for (int i = 0, n = keys.length; i < n; i++) { - if (keys[i] != noEntryKey) { - return keys[i]; - } - } - return null; - } - - public CharSequence keyAt(int index) { - return keys[-index - 1]; - } - - private void rehash() { - int newCapacity = capacity * 2; - final int size = size(); - free = capacity = newCapacity; - int len = Numbers.ceilPow2((int) (newCapacity / loadFactor)); - CharSequence[] newKeys = new CharSequence[len]; - CharSequence[] oldKeys = keys; - mask = len - 1; - this.keys = newKeys; - free -= size; - for (int i = 0, n = oldKeys.length; i < n; i++) { - CharSequence key = oldKeys[i]; - if (key != null) { - keys[keyIndex(key)] = key; - } - } - } - - protected void erase(int index) { - keys[index] = noEntryKey; - } - - protected void move(int from, int to) { - keys[to] = keys[from]; - erase(from); - } -} diff --git a/core/src/main/java/io/questdb/client/std/Misc.java b/core/src/main/java/io/questdb/client/std/Misc.java index 2d9e35c..0cae9c9 100644 --- a/core/src/main/java/io/questdb/client/std/Misc.java +++ b/core/src/main/java/io/questdb/client/std/Misc.java @@ -30,7 +30,6 @@ import java.io.Closeable; import java.io.IOException; -import java.util.Arrays; public final class Misc { public static final String EOL = "\r\n"; @@ -100,12 +99,6 @@ public static Utf8StringSink getThreadLocalUtf8Sink() { return b; } - public static int[] getWorkerAffinity(int workerCount) { - int[] res = new int[workerCount]; - Arrays.fill(res, -1); - return res; - } - private static void freeObjList0(ObjList list) { for (int i = 0, n = list.size(); i < n; i++) { list.setQuick(i, freeIfCloseable(list.getQuick(i))); diff --git a/core/src/main/java/io/questdb/client/std/Numbers.java b/core/src/main/java/io/questdb/client/std/Numbers.java index 6156f04..192f63c 100644 --- a/core/src/main/java/io/questdb/client/std/Numbers.java +++ b/core/src/main/java/io/questdb/client/std/Numbers.java @@ -25,7 +25,6 @@ package io.questdb.client.std; import io.questdb.client.std.fastdouble.FastDoubleParser; -import io.questdb.client.std.fastdouble.FastFloatParser; import io.questdb.client.std.str.CharSink; import io.questdb.client.std.str.Utf8Sequence; import jdk.internal.math.FDBigInteger; @@ -263,43 +262,6 @@ public static void appendHex(CharSink sink, long value, boolean pad) { array[bit].append(sink, value); } - /** - * Append a long value to a CharSink in hex format. - * - * @param sink the CharSink to append to - * @param value the value to append - * @param padToBytes if non-zero, pad the output to the specified number of bytes - */ - public static void appendHexPadded(CharSink sink, long value, int padToBytes) { - assert padToBytes >= 0 && padToBytes <= 8; - // This code might be unclear, so here are some hints: - // This method uses longHexAppender() and longHexAppender() is always padding to a whole byte. It never prints - // just a nibble. It means the longHexAppender() will print value 0xf as "0f". Value 0xff will be printed as "ff". - // Value 0xfff will be printed as "0fff". Value 0xffff will be printed as "ffff" and so on. - // So this method needs to pad only from the next whole byte up. - // In other words: This method always pads with full bytes (=even number of zeros), never with just a nibble. - - // Example 1: Value is 0xF and padToBytes is 2. This means the desired output is 000f. - // longHexAppender() pads to a full byte. This means it will output is 0f. So this method needs to pad with 2 zeros. - - // Example 2: The value is 0xFF and padToBytes is 2. This means the desired output is 00ff. - // longHexAppender() will output "ff". This is a full byte so longHexAppender() will not do any padding on its own. - // So this method needs to pad with 2 zeros. - int leadingZeroBits = Long.numberOfLeadingZeros(value); - int padToBits = padToBytes << 3; - int bitsToPad = padToBits - (Long.SIZE - leadingZeroBits); - int bytesToPad = (bitsToPad >> 3); - for (int i = 0; i < bytesToPad; i++) { - sink.putAscii('0'); - sink.putAscii('0'); - } - if (value == 0) { - return; - } - int bit = 64 - leadingZeroBits; - longHexAppender[bit].append(sink, value); - } - public static void appendHexPadded(CharSink sink, final int value) { int i = value; if (i < 0) { @@ -386,38 +348,6 @@ public static void appendHexPadded(CharSink sink, final int value) { } } - public static void appendLong256(long a, long b, long c, long d, CharSink sink) { - if (a == Numbers.LONG_NULL && b == Numbers.LONG_NULL && c == Numbers.LONG_NULL && d == Numbers.LONG_NULL) { - return; - } - sink.putAscii("0x"); - if (d != 0) { - appendLong256Four(a, b, c, d, sink); - return; - } - if (c != 0) { - appendLong256Three(a, b, c, sink); - return; - } - if (b != 0) { - appendLong256Two(a, b, sink); - return; - } - appendHex(sink, a, false); - } - - public static void appendUuid(long lo, long hi, CharSink sink) { - appendHexPadded(sink, (hi >> 32) & 0xFFFFFFFFL, 4); - sink.putAscii('-'); - appendHexPadded(sink, (hi >> 16) & 0xFFFF, 2); - sink.putAscii('-'); - appendHexPadded(sink, hi & 0xFFFF, 2); - sink.putAscii('-'); - appendHexPadded(sink, lo >> 48 & 0xFFFF, 2); - sink.putAscii('-'); - appendHexPadded(sink, lo & 0xFFFFFFFFFFFFL, 6); - } - public static int ceilPow2(int value) { int i = value; if ((i != 0) && (i & (i - 1)) > 0) { @@ -459,17 +389,6 @@ public static int hexToDecimal(int c) throws NumericException { return r; } - public static void intToIPv4Sink(CharSink sink, int value) { - // NULL handling should be done outside, null here will be printed as 0.0.0.0 - append(sink, (value >> 24) & 0xff); - sink.putAscii('.'); - append(sink, (value >> 16) & 0xff); - sink.putAscii('.'); - append(sink, (value >> 8) & 0xff); - sink.putAscii('.'); - append(sink, value & 0xff); - } - public static long interleaveBits(long x, long y) { return spreadBits(x) | (spreadBits(y) << 1); } @@ -490,10 +409,6 @@ public static boolean isNull(float value) { return Float.isNaN(value) || Float.isInfinite(value); } - public static boolean isPow2(int value) { - return value > 0 && (value & (value - 1)) == 0; - } - public static int msb(int value) { return 31 - Integer.numberOfLeadingZeros(value); } @@ -506,10 +421,6 @@ public static double parseDouble(CharSequence sequence) throws NumericException return FastDoubleParser.parseDouble(sequence, true); } - public static float parseFloat(CharSequence sequence) throws NumericException { - return FastFloatParser.parseFloat(sequence, true); - } - public static int parseHexInt(CharSequence sequence) throws NumericException { return parseHexInt(sequence, 0, sequence.length()); } @@ -557,17 +468,6 @@ public static int parseIPv4(CharSequence sequence) throws NumericException { return parseIPv4_0(sequence, 0, sequence.length()); } - public static int parseIPv4Quiet(CharSequence sequence) { - try { - if (sequence == null || Chars.equals("null", sequence)) { - return IPv4_NULL; - } - return parseIPv4(sequence); - } catch (NumericException e) { - return IPv4_NULL; - } - } - public static int parseIPv4_0(CharSequence sequence, final int p, int lim) throws NumericException { if (lim == 0) { throw NumericException.instance().put("empty IPv4 address string"); @@ -657,101 +557,6 @@ public static int parseInt(CharSequence sequence, int p, int lim) throws Numeric return parseInt0(sequence, p, lim); } - public static long parseInt000Greedy(CharSequence sequence, final int p, int lim) throws NumericException { - if (lim == p) { - throw NumericException.instance().put("empty number string"); - } - - boolean negative = sequence.charAt(p) == '-'; - int i = p; - if (negative) { - i++; - } - - if (i >= lim || notDigit(sequence.charAt(i))) { - throw NumericException.instance().put("not a number: ").put(sequence); - } - - int val = 0; - for (; i < lim; i++) { - char c = sequence.charAt(i); - - if (notDigit(c)) { - break; - } - - // val * 10 + (c - '0') - int r = (val << 3) + (val << 1) - (c - '0'); - if (r > val) { - throw NumericException.instance().put("number overflow"); - } - val = r; - } - - final int len = i - p; - - if (len > 3 || val == Integer.MIN_VALUE && !negative) { - throw NumericException.instance().put("number overflow"); - } - - while (i - p < 3) { - val *= 10; - i++; - } - - return encodeLowHighInts(negative ? val : -val, len); - } - - public static int parseIntQuiet(CharSequence sequence) { - try { - if (sequence == null || Chars.equals("NaN", sequence)) { - return Numbers.INT_NULL; - } - return parseInt0(sequence, 0, sequence.length()); - } catch (NumericException e) { - return Numbers.INT_NULL; - } - - } - - public static long parseIntSafely(CharSequence sequence, final int p, int lim) throws NumericException { - if (lim == p) { - throw NumericException.instance().put("empty number string"); - } - - boolean negative = sequence.charAt(p) == '-'; - int i = p; - if (negative) { - i++; - } - - if (i >= lim || notDigit(sequence.charAt(i))) { - throw NumericException.instance().put("not a number: ").put(sequence); - } - - int val = 0; - for (; i < lim; i++) { - char c = sequence.charAt(i); - - if (notDigit(c)) { - break; - } - - // val * 10 + (c - '0') - int r = (val << 3) + (val << 1) - (c - '0'); - if (r > val) { - throw NumericException.instance().put("number overflow"); - } - val = r; - } - - if (val == Integer.MIN_VALUE && !negative) { - throw NumericException.instance().put("number overflow"); - } - - return encodeLowHighInts(negative ? val : -val, i - p); - } - public static long parseLong(CharSequence sequence) throws NumericException { if (sequence == null) { throw NumericException.instance().put("null string"); @@ -766,51 +571,6 @@ public static long parseLong(Utf8Sequence sequence) throws NumericException { return parseLong0(sequence.asAsciiCharSequence(), 0, sequence.size()); } - public static long parseLong000000Greedy(CharSequence sequence, final int p, int lim) throws NumericException { - if (lim == p) { - throw NumericException.instance().put("empty number string"); - } - - boolean negative = sequence.charAt(p) == '-'; - int i = p; - if (negative) { - i++; - } - - if (i >= lim || notDigit(sequence.charAt(i))) { - throw NumericException.instance().put("not a number: ").put(sequence); - } - - int val = 0; - for (; i < lim; i++) { - char c = sequence.charAt(i); - - if (notDigit(c)) { - break; - } - - // val * 10 + (c - '0') - int r = (val << 3) + (val << 1) - (c - '0'); - if (r > val) { - throw NumericException.instance().put("number overflow"); - } - val = r; - } - - final int len = i - p; - - if (len > 6 || val == Integer.MIN_VALUE && !negative) { - throw NumericException.instance().put("number overflow"); - } - - while (i - p < 6) { - val *= 10; - i++; - } - - return encodeLowHighInts(negative ? val : -val, len); - } - public static long spreadBits(long v) { v = (v | (v << 16)) & 0X0000FFFF0000FFFFL; v = (v | (v << 8)) & 0X00FF00FF00FF00FFL; @@ -1440,21 +1200,6 @@ private static void appendLong2(CharSink sink, long i) { sink.putAscii((char) ('0' + i % 10)); } - private static void appendLong256Four(long a, long b, long c, long d, CharSink sink) { - appendLong256Three(b, c, d, sink); - appendHex(sink, a, true); - } - - private static void appendLong256Three(long a, long b, long c, CharSink sink) { - appendLong256Two(b, c, sink); - appendHex(sink, a, true); - } - - private static void appendLong256Two(long a, long b, CharSink sink) { - appendHex(sink, b, false); - appendHex(sink, a, true); - } - private static void appendLong3(CharSink sink, long i) { long c; sink.putAscii((char) ('0' + i / 100)); diff --git a/core/src/main/java/io/questdb/client/std/Rnd.java b/core/src/main/java/io/questdb/client/std/Rnd.java index b7fc787..30d4e67 100644 --- a/core/src/main/java/io/questdb/client/std/Rnd.java +++ b/core/src/main/java/io/questdb/client/std/Rnd.java @@ -24,7 +24,6 @@ package io.questdb.client.std; -import io.questdb.client.cairo.GeoHashes; import io.questdb.client.std.str.StringSink; import io.questdb.client.std.str.Utf16Sink; @@ -187,17 +186,6 @@ public double nextDouble() { return (((long) (nextIntForDouble(26)) << 27) + nextIntForDouble(27)) * DOUBLE_UNIT; } - public long nextGeoHash(int bits) { - double x = nextDouble() * 180.0 - 90.0; - double y = nextDouble() * 360.0 - 180.0; - try { - return GeoHashes.fromCoordinatesDeg(x, y, bits); - } catch (NumericException e) { - // Should never happen - return GeoHashes.NULL; - } - } - public int nextInt(int boundary) { return nextPositiveInt() % boundary; } diff --git a/core/src/main/java/io/questdb/client/std/SecureRnd.java b/core/src/main/java/io/questdb/client/std/SecureRnd.java new file mode 100644 index 0000000..2ceef88 --- /dev/null +++ b/core/src/main/java/io/questdb/client/std/SecureRnd.java @@ -0,0 +1,185 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.std; + +import java.security.SecureRandom; + +/** + * Zero-GC cryptographically secure random number generator based on ChaCha20 + * in counter mode (RFC 7539). Seeded once from {@link SecureRandom} at + * construction time, then produces unpredictable output with no heap + * allocations. + *

    + * Each {@link #nextInt()} call returns one 32-bit word from the ChaCha20 + * keystream. A single block computation yields 16 words, so the amortized + * cost is one ChaCha20 block per 16 calls. + */ +public class SecureRnd { + + // "expand 32-byte k" in little-endian + private static final int CONSTANT_0 = 0x61707865; + private static final int CONSTANT_1 = 0x3320646e; + private static final int CONSTANT_2 = 0x79622d32; + private static final int CONSTANT_3 = 0x6b206574; + + private final int[] output = new int[16]; + private final int[] state = new int[16]; + private int outputPos = 16; // forces block computation on first call + + /** + * Creates a new instance seeded from {@link SecureRandom}. + */ + public SecureRnd() { + SecureRandom seed = new SecureRandom(); + byte[] seedBytes = new byte[44]; // 32 (key) + 12 (nonce) + seed.nextBytes(seedBytes); + init(seedBytes, 0); + } + + /** + * Creates a new instance with an explicit key, nonce, and initial counter. + * Useful for testing with known RFC 7539 test vectors. + * + * @param key 32-byte key + * @param nonce 12-byte nonce + * @param counter initial block counter value + */ + public SecureRnd(byte[] key, byte[] nonce, int counter) { + byte[] seedBytes = new byte[44]; + System.arraycopy(key, 0, seedBytes, 0, 32); + System.arraycopy(nonce, 0, seedBytes, 32, 12); + init(seedBytes, counter); + } + + /** + * Returns the next cryptographically secure random int. + */ + public int nextInt() { + if (outputPos >= 16) { + computeBlock(); + outputPos = 0; + } + return output[outputPos++]; + } + + private void computeBlock() { + int x0 = state[0], x1 = state[1], x2 = state[2], x3 = state[3]; + int x4 = state[4], x5 = state[5], x6 = state[6], x7 = state[7]; + int x8 = state[8], x9 = state[9], x10 = state[10], x11 = state[11]; + int x12 = state[12], x13 = state[13], x14 = state[14], x15 = state[15]; + + for (int i = 0; i < 10; i++) { + // Column rounds + x0 += x4; x12 ^= x0; x12 = Integer.rotateLeft(x12, 16); + x8 += x12; x4 ^= x8; x4 = Integer.rotateLeft(x4, 12); + x0 += x4; x12 ^= x0; x12 = Integer.rotateLeft(x12, 8); + x8 += x12; x4 ^= x8; x4 = Integer.rotateLeft(x4, 7); + + x1 += x5; x13 ^= x1; x13 = Integer.rotateLeft(x13, 16); + x9 += x13; x5 ^= x9; x5 = Integer.rotateLeft(x5, 12); + x1 += x5; x13 ^= x1; x13 = Integer.rotateLeft(x13, 8); + x9 += x13; x5 ^= x9; x5 = Integer.rotateLeft(x5, 7); + + x2 += x6; x14 ^= x2; x14 = Integer.rotateLeft(x14, 16); + x10 += x14; x6 ^= x10; x6 = Integer.rotateLeft(x6, 12); + x2 += x6; x14 ^= x2; x14 = Integer.rotateLeft(x14, 8); + x10 += x14; x6 ^= x10; x6 = Integer.rotateLeft(x6, 7); + + x3 += x7; x15 ^= x3; x15 = Integer.rotateLeft(x15, 16); + x11 += x15; x7 ^= x11; x7 = Integer.rotateLeft(x7, 12); + x3 += x7; x15 ^= x3; x15 = Integer.rotateLeft(x15, 8); + x11 += x15; x7 ^= x11; x7 = Integer.rotateLeft(x7, 7); + + // Diagonal rounds + x0 += x5; x15 ^= x0; x15 = Integer.rotateLeft(x15, 16); + x10 += x15; x5 ^= x10; x5 = Integer.rotateLeft(x5, 12); + x0 += x5; x15 ^= x0; x15 = Integer.rotateLeft(x15, 8); + x10 += x15; x5 ^= x10; x5 = Integer.rotateLeft(x5, 7); + + x1 += x6; x12 ^= x1; x12 = Integer.rotateLeft(x12, 16); + x11 += x12; x6 ^= x11; x6 = Integer.rotateLeft(x6, 12); + x1 += x6; x12 ^= x1; x12 = Integer.rotateLeft(x12, 8); + x11 += x12; x6 ^= x11; x6 = Integer.rotateLeft(x6, 7); + + x2 += x7; x13 ^= x2; x13 = Integer.rotateLeft(x13, 16); + x8 += x13; x7 ^= x8; x7 = Integer.rotateLeft(x7, 12); + x2 += x7; x13 ^= x2; x13 = Integer.rotateLeft(x13, 8); + x8 += x13; x7 ^= x8; x7 = Integer.rotateLeft(x7, 7); + + x3 += x4; x14 ^= x3; x14 = Integer.rotateLeft(x14, 16); + x9 += x14; x4 ^= x9; x4 = Integer.rotateLeft(x4, 12); + x3 += x4; x14 ^= x3; x14 = Integer.rotateLeft(x14, 8); + x9 += x14; x4 ^= x9; x4 = Integer.rotateLeft(x4, 7); + } + + // Feed-forward: add original state + output[0] = x0 + state[0]; + output[1] = x1 + state[1]; + output[2] = x2 + state[2]; + output[3] = x3 + state[3]; + output[4] = x4 + state[4]; + output[5] = x5 + state[5]; + output[6] = x6 + state[6]; + output[7] = x7 + state[7]; + output[8] = x8 + state[8]; + output[9] = x9 + state[9]; + output[10] = x10 + state[10]; + output[11] = x11 + state[11]; + output[12] = x12 + state[12]; + output[13] = x13 + state[13]; + output[14] = x14 + state[14]; + output[15] = x15 + state[15]; + + // Increment block counter + state[12]++; + } + + private void init(byte[] seedBytes, int counter) { + state[0] = CONSTANT_0; + state[1] = CONSTANT_1; + state[2] = CONSTANT_2; + state[3] = CONSTANT_3; + + // Key: 8 little-endian ints from seedBytes[0..31] + for (int i = 0; i < 8; i++) { + int off = i * 4; + state[4 + i] = (seedBytes[off] & 0xFF) + | ((seedBytes[off + 1] & 0xFF) << 8) + | ((seedBytes[off + 2] & 0xFF) << 16) + | ((seedBytes[off + 3] & 0xFF) << 24); + } + + state[12] = counter; + + // Nonce: 3 little-endian ints from seedBytes[32..43] + for (int i = 0; i < 3; i++) { + int off = 32 + i * 4; + state[13 + i] = (seedBytes[off] & 0xFF) + | ((seedBytes[off + 1] & 0xFF) << 8) + | ((seedBytes[off + 2] & 0xFF) << 16) + | ((seedBytes[off + 3] & 0xFF) << 24); + } + } +} diff --git a/core/src/main/java/io/questdb/client/std/Unsafe.java b/core/src/main/java/io/questdb/client/std/Unsafe.java index 778af39..6395d99 100644 --- a/core/src/main/java/io/questdb/client/std/Unsafe.java +++ b/core/src/main/java/io/questdb/client/std/Unsafe.java @@ -24,42 +24,31 @@ package io.questdb.client.std; -// @formatter:off import io.questdb.client.cairo.CairoException; -import org.jetbrains.annotations.Nullable; -import java.lang.invoke.MethodHandles; import java.lang.reflect.AccessibleObject; -import java.lang.reflect.Array; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.concurrent.atomic.LongAdder; -import static io.questdb.client.std.MemoryTag.NATIVE_DEFAULT; - public final class Unsafe { // The various _ADDR fields are `long` in Java, but they are `* mut usize` in Rust, or `size_t*` in C. // These are off-heap allocated atomic counters for memory usage tracking. public static final long BYTE_OFFSET; public static final long BYTE_SCALE; - public static final long INT_OFFSET; - public static final long INT_SCALE; public static final Module JAVA_BASE_MODULE = System.class.getModule(); public static final long LONG_OFFSET; public static final long LONG_SCALE; private static final LongAdder[] COUNTERS = new LongAdder[MemoryTag.SIZE]; private static final long FREE_COUNT_ADDR; private static final long MALLOC_COUNT_ADDR; - private static final long[] NATIVE_ALLOCATORS = new long[MemoryTag.SIZE - NATIVE_DEFAULT]; private static final long[] NATIVE_MEM_COUNTER_ADDRS = new long[MemoryTag.SIZE]; private static final long NON_RSS_MEM_USED_ADDR; private static final long OVERRIDE; private static final long REALLOC_COUNT_ADDR; - private static final long RSS_MEM_LIMIT_ADDR; private static final long RSS_MEM_USED_ADDR; private static final sun.misc.Unsafe UNSAFE; - private static final AnonymousClassDefiner anonymousClassDefiner; private static final Method implAddExports; private Unsafe() { @@ -73,40 +62,6 @@ public static void addExports(Module from, Module to, String packageName) { } } - public static long arrayGetVolatile(long[] array, int index) { - assert index > -1 && index < array.length; - return Unsafe.getUnsafe().getLongVolatile(array, LONG_OFFSET + ((long) index << LONG_SCALE)); - } - - public static int arrayGetVolatile(int[] array, int index) { - assert index > -1 && index < array.length; - return Unsafe.getUnsafe().getIntVolatile(array, INT_OFFSET + ((long) index << INT_SCALE)); - } - - /** - * This call has Atomic*#lazySet / memory_order_release semantics. - * - * @param array array to put into - * @param index index - * @param value value to put - */ - public static void arrayPutOrdered(long[] array, int index, long value) { - assert index > -1 && index < array.length; - Unsafe.getUnsafe().putOrderedLong(array, LONG_OFFSET + ((long) index << LONG_SCALE), value); - } - - /** - * This call has Atomic*#lazySet / memory_order_release semantics. - * - * @param array array to put into - * @param index index - * @param value value to put - */ - public static void arrayPutOrdered(int[] array, int index, int value) { - assert index > -1 && index < array.length; - Unsafe.getUnsafe().putOrderedInt(array, INT_OFFSET + ((long) index << INT_SCALE), value); - } - public static int byteArrayGetInt(byte[] array, int index) { assert index > -1 && index < array.length - 3; return Unsafe.getUnsafe().getInt(array, BYTE_OFFSET + index); @@ -141,21 +96,6 @@ public static boolean cas(long[] array, int index, long expected, long value) { return Unsafe.cas(array, Unsafe.LONG_OFFSET + (((long) index) << Unsafe.LONG_SCALE), expected, value); } - /** - * Defines a class but does not make it known to the class loader or system dictionary. - *

    - * Equivalent to {@code Unsafe#defineAnonymousClass} and {@code Lookup#defineHiddenClass}, except that - * it does not support constant pool patches. - * - * @param hostClass context for linkage, access control, protection domain, and class loader - * @param data bytes of a class file - * @return Java Class for the given bytecode - */ - @Nullable - public static Class defineAnonymousClass(Class hostClass, byte[] data) { - return anonymousClassDefiner.define(hostClass, data); - } - public static long free(long ptr, long size, int memoryTag) { if (ptr != 0) { Unsafe.getUnsafe().freeMemory(ptr); @@ -199,19 +139,10 @@ public static long getMemUsedByTag(int memoryTag) { return COUNTERS[memoryTag].sum() + UNSAFE.getLongVolatile(null, NATIVE_MEM_COUNTER_ADDRS[memoryTag]); } - /** Returns a `*const QdbAllocator` for use in Rust. */ - public static long getNativeAllocator(int memoryTag) { - return NATIVE_ALLOCATORS[memoryTag - NATIVE_DEFAULT]; - } - public static long getReallocCount() { return UNSAFE.getLongVolatile(null, REALLOC_COUNT_ADDR); } - public static long getRssMemLimit() { - return UNSAFE.getLongVolatile(null, RSS_MEM_LIMIT_ADDR); - } - public static long getRssMemUsed() { return UNSAFE.getLongVolatile(null, RSS_MEM_USED_ADDR); } @@ -245,7 +176,6 @@ public static void makeAccessible(AccessibleObject accessibleObject) { public static long malloc(long size, int memoryTag) { try { assert memoryTag >= MemoryTag.NATIVE_PATH; - checkAllocLimit(size, memoryTag); long ptr = Unsafe.getUnsafe().allocateMemory(size); recordMemAlloc(size, memoryTag); incrMallocCount(); @@ -267,7 +197,6 @@ public static long malloc(long size, int memoryTag) { public static long realloc(long address, long oldSize, long newSize, int memoryTag) { try { assert memoryTag >= MemoryTag.NATIVE_PATH; - checkAllocLimit(-oldSize + newSize, memoryTag); long ptr = Unsafe.getUnsafe().reallocateMemory(address, newSize); recordMemAlloc(-oldSize + newSize, memoryTag); incrReallocCount(); @@ -300,10 +229,6 @@ public static void recordMemAlloc(long size, int memoryTag) { } } - public static void setRssMemLimit(long limit) { - UNSAFE.putLongVolatile(null, RSS_MEM_LIMIT_ADDR, limit); - } - private static long AccessibleObject_override_fieldOffset() { if (isJava8Or11()) { return getFieldOffset(AccessibleObject.class, "override"); @@ -319,39 +244,6 @@ private static long AccessibleObject_override_fieldOffset() { return 16L; } - private static void checkAllocLimit(long size, int memoryTag) { - if (size <= 0) { - return; - } - // Don't check limits for mmap'd memory - final long rssMemLimit = getRssMemLimit(); - if (rssMemLimit > 0 && memoryTag >= NATIVE_DEFAULT) { - long usage = getRssMemUsed(); - if (usage + size > rssMemLimit) { - throw CairoException.nonCritical() - .put("global RSS memory limit exceeded [usage=") - .put(usage) - .put(", RSS_MEM_LIMIT=").put(rssMemLimit) - .put(", size=").put(size) - .put(", memoryTag=").put(memoryTag) - .put(']'); - } - } - } - - /** Allocate a new native allocator object and return its pointer */ - private static long constructNativeAllocator(long nativeMemCountersArray, int memoryTag) { - // See `allocator.rs` for the definition of `QdbAllocator`. - // We construct here via `Unsafe` to avoid having initialization order issues with `Os.java`. - final long allocSize = 8 + 8 + 4; // two longs, one int - final long addr = UNSAFE.allocateMemory(allocSize); - Vect.memset(addr, allocSize, 0); - UNSAFE.putLong(addr, nativeMemCountersArray); - UNSAFE.putLong(addr + 8, NATIVE_MEM_COUNTER_ADDRS[memoryTag]); - UNSAFE.putInt(addr + 16, memoryTag); - return addr; - } - private static boolean getOrdinaryObjectPointersCompressionStatus(boolean is32BitJVM) { class Probe { @SuppressWarnings("unused") @@ -390,92 +282,6 @@ private static int msb(int value) { return 31 - Integer.numberOfLeadingZeros(value); } - interface AnonymousClassDefiner { - Class define(Class hostClass, byte[] data); - } - - /** - * Based on {@code MethodHandles.Lookup#defineHiddenClass}. - */ - static class MethodHandlesClassDefiner implements AnonymousClassDefiner { - private static Method defineMethod; - private static Object hiddenClassOptions; - private static Object lookupBase; - private static long lookupOffset; - - @Nullable - public static MethodHandlesClassDefiner newInstance() { - if (defineMethod == null) { - try { - Field trustedLookupField = MethodHandles.Lookup.class.getDeclaredField("IMPL_LOOKUP"); - lookupBase = UNSAFE.staticFieldBase(trustedLookupField); - lookupOffset = UNSAFE.staticFieldOffset(trustedLookupField); - hiddenClassOptions = hiddenClassOptions("NESTMATE"); - defineMethod = MethodHandles.Lookup.class - .getMethod("defineHiddenClass", byte[].class, boolean.class, hiddenClassOptions.getClass()); - } catch (ReflectiveOperationException e) { - return null; - } - } - return new MethodHandlesClassDefiner(); - } - - @Override - public Class define(Class hostClass, byte[] data) { - try { - MethodHandles.Lookup trustedLookup = (MethodHandles.Lookup) UNSAFE.getObject(lookupBase, lookupOffset); - MethodHandles.Lookup definedLookup = - (MethodHandles.Lookup) defineMethod.invoke(trustedLookup.in(hostClass), data, false, hiddenClassOptions); - return definedLookup.lookupClass(); - } catch (Exception e) { - e.printStackTrace(System.out); - return null; - } - } - - @SuppressWarnings("unchecked") - private static Object hiddenClassOptions(String... options) throws ClassNotFoundException { - @SuppressWarnings("rawtypes") - Class optionClass = Class.forName(MethodHandles.Lookup.class.getName() + "$ClassOption"); - Object classOptions = Array.newInstance(optionClass, options.length); - for (int i = 0; i < options.length; i++) { - Array.set(classOptions, i, Enum.valueOf(optionClass, options[i])); - } - return classOptions; - } - } - - /** - * Based on {@code Unsafe#defineAnonymousClass}. - */ - static class UnsafeClassDefiner implements AnonymousClassDefiner { - - private static Method defineMethod; - - @Nullable - public static UnsafeClassDefiner newInstance() { - if (defineMethod == null) { - try { - defineMethod = sun.misc.Unsafe.class - .getMethod("defineAnonymousClass", Class.class, byte[].class, Object[].class); - } catch (ReflectiveOperationException e) { - return null; - } - } - return new UnsafeClassDefiner(); - } - - @Override - public Class define(Class hostClass, byte[] data) { - try { - return (Class) defineMethod.invoke(UNSAFE, hostClass, data, null); - } catch (Exception e) { - e.printStackTrace(System.out); - return null; - } - } - } - static { try { Field theUnsafe = sun.misc.Unsafe.class.getDeclaredField("theUnsafe"); @@ -485,23 +291,11 @@ public Class define(Class hostClass, byte[] data) { BYTE_OFFSET = Unsafe.getUnsafe().arrayBaseOffset(byte[].class); BYTE_SCALE = msb(Unsafe.getUnsafe().arrayIndexScale(byte[].class)); - INT_OFFSET = Unsafe.getUnsafe().arrayBaseOffset(int[].class); - INT_SCALE = msb(Unsafe.getUnsafe().arrayIndexScale(int[].class)); - LONG_OFFSET = Unsafe.getUnsafe().arrayBaseOffset(long[].class); LONG_SCALE = msb(Unsafe.getUnsafe().arrayIndexScale(long[].class)); OVERRIDE = AccessibleObject_override_fieldOffset(); implAddExports = Module.class.getDeclaredMethod("implAddExports", String.class, Module.class); - - AnonymousClassDefiner classDefiner = UnsafeClassDefiner.newInstance(); - if (classDefiner == null) { - classDefiner = MethodHandlesClassDefiner.newInstance(); - } - if (classDefiner == null) { - throw new InstantiationException("failed to initialize class definer"); - } - anonymousClassDefiner = classDefiner; } catch (ReflectiveOperationException e) { throw new ExceptionInInitializerError(e); } @@ -510,17 +304,13 @@ public Class define(Class hostClass, byte[] data) { // A single allocation for all the off-heap native memory counters. // Might help with locality, given they're often incremented together. // All initial values set to 0. - final long nativeMemCountersArraySize = (6 + COUNTERS.length) * 8; + final long nativeMemCountersArraySize = (5 + COUNTERS.length) * 8; final long nativeMemCountersArray = UNSAFE.allocateMemory(nativeMemCountersArraySize); long ptr = nativeMemCountersArray; Vect.memset(nativeMemCountersArray, nativeMemCountersArraySize, 0); - // N.B.: The layout here is also used in `allocator.rs` for the Rust side. - // See: `struct MemTracking`. RSS_MEM_USED_ADDR = ptr; ptr += 8; - RSS_MEM_LIMIT_ADDR = ptr; - ptr += 8; MALLOC_COUNT_ADDR = ptr; ptr += 8; REALLOC_COUNT_ADDR = ptr; @@ -534,9 +324,5 @@ public Class define(Class hostClass, byte[] data) { NATIVE_MEM_COUNTER_ADDRS[i] = ptr; ptr += 8; } - for (int memoryTag = NATIVE_DEFAULT; memoryTag < MemoryTag.SIZE; ++memoryTag) { - NATIVE_ALLOCATORS[memoryTag - NATIVE_DEFAULT] = constructNativeAllocator( - nativeMemCountersArray, memoryTag); - } } } diff --git a/core/src/main/java/io/questdb/client/std/bytes/DirectByteSink.java b/core/src/main/java/io/questdb/client/std/bytes/DirectByteSink.java index e9f1f23..3d8b130 100644 --- a/core/src/main/java/io/questdb/client/std/bytes/DirectByteSink.java +++ b/core/src/main/java/io/questdb/client/std/bytes/DirectByteSink.java @@ -71,6 +71,7 @@ public long ptr() { return impl; } }; + public DirectByteSink(long initialCapacity, int memoryTag) { this(initialCapacity, memoryTag, false); } @@ -275,4 +276,4 @@ private void setImplPtr(long ptr) { static { Os.init(); } -} \ No newline at end of file +} diff --git a/core/src/main/java/io/questdb/client/std/ex/BytecodeException.java b/core/src/main/java/io/questdb/client/std/ex/BytecodeException.java deleted file mode 100644 index cff0ea5..0000000 --- a/core/src/main/java/io/questdb/client/std/ex/BytecodeException.java +++ /dev/null @@ -1,33 +0,0 @@ -/******************************************************************************* - * ___ _ ____ ____ - * / _ \ _ _ ___ ___| |_| _ \| __ ) - * | | | | | | |/ _ \/ __| __| | | | _ \ - * | |_| | |_| | __/\__ \ |_| |_| | |_) | - * \__\_\\__,_|\___||___/\__|____/|____/ - * - * Copyright (c) 2014-2019 Appsicle - * Copyright (c) 2019-2026 QuestDB - * - * Licensed 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. - * - ******************************************************************************/ - -package io.questdb.client.std.ex; - -public class BytecodeException extends RuntimeException { - public static final BytecodeException INSTANCE = new BytecodeException(); - - private BytecodeException() { - super("Error in bytecode"); - } -} \ No newline at end of file diff --git a/core/src/main/java/io/questdb/client/std/str/DirectCharSequence.java b/core/src/main/java/io/questdb/client/std/str/DirectCharSequence.java deleted file mode 100644 index fcbecb1..0000000 --- a/core/src/main/java/io/questdb/client/std/str/DirectCharSequence.java +++ /dev/null @@ -1,33 +0,0 @@ -/******************************************************************************* - * ___ _ ____ ____ - * / _ \ _ _ ___ ___| |_| _ \| __ ) - * | | | | | | |/ _ \/ __| __| | | | _ \ - * | |_| | |_| | __/\__ \ |_| |_| | |_) | - * \__\_\\__,_|\___||___/\__|____/|____/ - * - * Copyright (c) 2014-2019 Appsicle - * Copyright (c) 2019-2026 QuestDB - * - * Licensed 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. - * - ******************************************************************************/ - -package io.questdb.client.std.str; - -import io.questdb.client.std.bytes.DirectSequence; - -/** - * A sequence of UTF-16 chars stored in native memory. - */ -public interface DirectCharSequence extends CharSequence, DirectSequence { -} \ No newline at end of file diff --git a/core/src/main/java/io/questdb/client/std/str/Utf8s.java b/core/src/main/java/io/questdb/client/std/str/Utf8s.java index e1986cd..4db4641 100644 --- a/core/src/main/java/io/questdb/client/std/str/Utf8s.java +++ b/core/src/main/java/io/questdb/client/std/str/Utf8s.java @@ -225,32 +225,6 @@ public static String stringFromUtf8Bytes(@NotNull Utf8Sequence seq) { return b.toString(); } - public static String stringFromUtf8BytesSafe(@NotNull Utf8Sequence seq) { - if (seq.size() == 0) { - return ""; - } - Utf16Sink b = getThreadLocalSink(); - utf8ToUtf16(seq, b); - return b.toString(); - } - - public static String toString(@Nullable Utf8Sequence s) { - return s == null ? null : s.toString(); - } - - public static String toString(@NotNull Utf8Sequence us, int start, int end, byte unescapeAscii) { - final Utf8Sink sink = getThreadLocalUtf8Sink(); - final int lastChar = end - 1; - for (int i = start; i < end; i++) { - byte b = us.byteAt(i); - sink.putAny(b); - if (b == unescapeAscii && i < lastChar && us.byteAt(i + 1) == unescapeAscii) { - i++; - } - } - return sink.toString(); - } - public static int utf8DecodeMultiByte(long lo, long hi, byte b, Utf16Sink sink) { if (b >> 5 == -2 && (b & 30) != 0) { return utf8Decode2Bytes(lo, hi, b, sink); @@ -329,28 +303,6 @@ public static boolean utf8ToUtf16(@NotNull Utf8Sequence seq, @NotNull Utf16Sink return utf8ToUtf16(seq, 0, seq.size(), sink); } - public static int validateUtf8(@NotNull Utf8Sequence seq) { - if (seq.isAscii()) { - return seq.size(); - } - int len = 0; - for (int i = 0, hi = seq.size(); i < hi; ) { - byte b = seq.byteAt(i); - if (b < 0) { - int n = validateUtf8MultiByte(seq, i, b); - if (n == -1) { - // UTF-8 error - return -1; - } - i += n; - } else { - ++i; - } - ++len; - } - return len; - } - /** * Returns up to 6 initial bytes of the given UTF-8 sequence (less if it's shorter) * packed into a zero-padded long value, in little-endian order. This prefix is @@ -674,61 +626,4 @@ private static int utf8DecodeMultiByte(Utf8Sequence seq, int index, byte b, @Not return utf8Decode4Bytes(seq, index, b, sink); } - private static int validateUtf8Decode2Bytes(@NotNull Utf8Sequence seq, int index) { - if (seq.size() - index < 2) { - return -1; - } - byte b2 = seq.byteAt(index + 1); - if (isNotContinuation(b2)) { - return -1; - } - return 2; - } - - private static int validateUtf8Decode3Bytes(@NotNull Utf8Sequence seq, int index, byte b1) { - if (seq.size() - index < 3) { - return -1; - } - byte b2 = seq.byteAt(index + 1); - byte b3 = seq.byteAt(index + 2); - - if (isMalformed3(b1, b2, b3)) { - return -1; - } - - char c = utf8ToChar(b1, b2, b3); - if (Character.isSurrogate(c)) { - return -1; - } - return 3; - } - - private static int validateUtf8Decode4Bytes(@NotNull Utf8Sequence seq, int index, int b) { - if (b >> 3 != -2 || seq.size() - index < 4) { - return -1; - } - byte b2 = seq.byteAt(index + 1); - byte b3 = seq.byteAt(index + 2); - byte b4 = seq.byteAt(index + 3); - - if (isMalformed4(b2, b3, b4)) { - return -1; - } - final int codePoint = getUtf8Codepoint(b, b2, b3, b4); - if (!Character.isSupplementaryCodePoint(codePoint)) { - return -1; - } - return 4; - } - - private static int validateUtf8MultiByte(Utf8Sequence seq, int index, byte b) { - if (b >> 5 == -2 && (b & 30) != 0) { - // we should allow 11000001, as it is a valid UTF8 byte? - return validateUtf8Decode2Bytes(seq, index); - } - if (b >> 4 == -2) { - return validateUtf8Decode3Bytes(seq, index, b); - } - return validateUtf8Decode4Bytes(seq, index, b); - } } \ No newline at end of file diff --git a/core/src/main/java/module-info.java b/core/src/main/java/module-info.java index fa5bc48..cf4c93b 100644 --- a/core/src/main/java/module-info.java +++ b/core/src/main/java/module-info.java @@ -56,4 +56,7 @@ exports io.questdb.client.cairo.arr; exports io.questdb.client.cutlass.line.array; exports io.questdb.client.cutlass.line.udp; + exports io.questdb.client.cutlass.qwp.client; + exports io.questdb.client.cutlass.qwp.protocol; + exports io.questdb.client.cutlass.qwp.websocket; } diff --git a/core/src/test/java/io/questdb/client/test/AbstractQdbTest.java b/core/src/test/java/io/questdb/client/test/AbstractQdbTest.java index 60f3ed2..e984f19 100644 --- a/core/src/test/java/io/questdb/client/test/AbstractQdbTest.java +++ b/core/src/test/java/io/questdb/client/test/AbstractQdbTest.java @@ -31,7 +31,6 @@ import org.junit.After; import org.junit.AfterClass; import org.junit.Assert; -import org.junit.Assume; import org.junit.Before; import org.junit.BeforeClass; @@ -210,10 +209,10 @@ public static void setUpStatic() { System.err.printf("CLEANING UP TEST TABLES%n"); // Cleanup all test tables before starting tests try (Connection conn = getPgConnection(); - Statement readStmt = conn.createStatement(); - Statement stmt = conn.createStatement(); - ResultSet rs = readStmt - .executeQuery("SELECT table_name FROM tables() WHERE table_name LIKE 'test_%'")) { + Statement readStmt = conn.createStatement(); + Statement stmt = conn.createStatement(); + ResultSet rs = readStmt + .executeQuery("SELECT table_name FROM tables() WHERE table_name LIKE 'test_%'")) { while (rs.next()) { String tableName = rs.getString(1); try { @@ -458,7 +457,7 @@ protected static Connection initPgConnection() throws SQLException { protected void assertSqlEventually(CharSequence expected, String sql) throws Exception { assertEventually(() -> { try (Statement statement = getPgConnection().createStatement(); - ResultSet rs = statement.executeQuery(sql)) { + ResultSet rs = statement.executeQuery(sql)) { sink.clear(); printToSink(sink, rs); TestUtils.assertEquals(expected, sink); @@ -474,8 +473,8 @@ protected void assertSqlEventually(CharSequence expected, String sql) throws Exc protected void assertTableExistsEventually(CharSequence tableName) throws Exception { assertEventually(() -> { try (Statement stmt = getPgConnection().createStatement(); - ResultSet rs = stmt.executeQuery( - String.format("SELECT COUNT(*) AS cnt FROM tables() WHERE table_name = '%s'", tableName))) { + ResultSet rs = stmt.executeQuery( + String.format("SELECT COUNT(*) AS cnt FROM tables() WHERE table_name = '%s'", tableName))) { Assert.assertTrue(rs.next()); final long actualSize = rs.getLong(1); Assert.assertEquals(1, actualSize); @@ -546,7 +545,7 @@ protected List> executeQuery(String sql) throws SQLException List> results = new ArrayList<>(); try (Statement stmt = getPgConnection().createStatement(); - ResultSet rs = stmt.executeQuery(sql)) { + ResultSet rs = stmt.executeQuery(sql)) { ResultSetMetaData metaData = rs.getMetaData(); int columnCount = metaData.getColumnCount(); @@ -595,7 +594,7 @@ protected String queryTableAsTsv(String tableName, String orderBy) throws SQLExc } try (Statement stmt = getPgConnection().createStatement(); - ResultSet rs = stmt.executeQuery(sql)) { + ResultSet rs = stmt.executeQuery(sql)) { ResultSetMetaData metaData = rs.getMetaData(); int columnCount = metaData.getColumnCount(); diff --git a/core/src/test/java/io/questdb/client/test/LineSenderBuilderTest.java b/core/src/test/java/io/questdb/client/test/LineSenderBuilderTest.java deleted file mode 100644 index fe72c01..0000000 --- a/core/src/test/java/io/questdb/client/test/LineSenderBuilderTest.java +++ /dev/null @@ -1,799 +0,0 @@ -/******************************************************************************* - * ___ _ ____ ____ - * / _ \ _ _ ___ ___| |_| _ \| __ ) - * | | | | | | |/ _ \/ __| __| | | | _ \ - * | |_| | |_| | __/\__ \ |_| |_| | |_) | - * \__\_\\__,_|\___||___/\__|____/|____/ - * - * Copyright (c) 2014-2019 Appsicle - * Copyright (c) 2019-2026 QuestDB - * - * Licensed 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. - * - ******************************************************************************/ - -package io.questdb.client.test; - -import io.questdb.client.Sender; -import io.questdb.client.cutlass.line.LineSenderException; -import io.questdb.client.test.tools.TestUtils; -import org.junit.Assert; -import org.junit.Test; - -import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; -import static org.junit.Assert.fail; - -/** - * Unit tests for LineSenderBuilder that don't require a running QuestDB instance. - * Tests that require an actual QuestDB connection have been moved to integration tests. - */ -public class LineSenderBuilderTest { - private static final String AUTH_TOKEN_KEY1 = "UvuVb1USHGRRT08gEnwN2zGZrvM4MsLQ5brgF6SVkAw="; - private static final String LOCALHOST = "localhost"; - private static final char[] TRUSTSTORE_PASSWORD = "questdb".toCharArray(); - private static final String TRUSTSTORE_PATH = "/keystore/server.keystore"; - - @Test - public void testAddressDoubleSet_firstAddressThenAddress() throws Exception { - assertMemoryLeak(() -> { - Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.TCP).address(LOCALHOST); - try { - builder.address("127.0.0.1"); - builder.build(); - fail("should not allow double host set"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "mismatch"); - } - }); - } - - @Test - public void testAddressEmpty() throws Exception { - assertMemoryLeak(() -> { - Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.TCP); - try { - builder.address(""); - fail("empty address should fail"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "address cannot be empty"); - } - }); - } - - @Test - public void testAddressEndsWithColon() throws Exception { - assertMemoryLeak(() -> { - Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.TCP); - try { - builder.address("foo:"); - fail("should fail when address ends with colon"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "invalid address"); - } - }); - } - - @Test - public void testAddressNull() throws Exception { - assertMemoryLeak(() -> { - Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.TCP); - try { - builder.address(null); - fail("null address should fail"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "null"); - } - }); - } - - @Test - public void testAuthDoubleSet() throws Exception { - assertMemoryLeak(() -> { - Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.TCP).enableAuth("foo").authToken(AUTH_TOKEN_KEY1); - try { - builder.enableAuth("bar"); - fail("should not allow double auth set"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "already configured"); - } - }); - } - - @Test - public void testAuthTooSmallBuffer() throws Exception { - assertMemoryLeak(() -> { - try { - Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.TCP) - .enableAuth("foo").authToken(AUTH_TOKEN_KEY1).address(LOCALHOST + ":9001") - .bufferCapacity(1); - builder.build(); - fail("tiny buffer should NOT be allowed as it wont fit auth challenge"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "minimalCapacity"); - TestUtils.assertContains(e.getMessage(), "requestedCapacity"); - } - }); - } - - @Test - public void testAuthWithBadToken() throws Exception { - assertMemoryLeak(() -> { - Sender.LineSenderBuilder.AuthBuilder builder = Sender.builder(Sender.Transport.TCP).enableAuth("foo"); - try { - builder.authToken("bar token"); - fail("bad token should not be imported"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "could not import token"); - } - }); - } - - @Test - public void testAutoFlushIntervalMustBePositive() { - try (Sender ignored = Sender.builder(Sender.Transport.HTTP).autoFlushIntervalMillis(0).build()) { - fail("auto-flush must be positive"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "auto flush interval cannot be negative [autoFlushIntervalMillis=0]"); - } - - try (Sender ignored = Sender.builder(Sender.Transport.HTTP).autoFlushIntervalMillis(-1).build()) { - fail("auto-flush must be positive"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "auto flush interval cannot be negative [autoFlushIntervalMillis=-1]"); - } - } - - @Test - public void testAutoFlushIntervalNotSupportedForTcp() throws Exception { - assertMemoryLeak(() -> { - try { - Sender.builder(Sender.Transport.TCP).address(LOCALHOST).autoFlushIntervalMillis(1).build(); - fail("auto flush interval should not be supported for TCP"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "auto flush interval is not supported for TCP protocol"); - } - }); - } - - @Test - public void testAutoFlushInterval_afterAutoFlushDisabled() throws Exception { - assertMemoryLeak(() -> { - try { - Sender.builder(Sender.Transport.HTTP).disableAutoFlush().autoFlushIntervalMillis(1); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "cannot set auto flush interval when interval based auto-flush is already disabled"); - } - }); - } - - @Test - public void testAutoFlushInterval_doubleConfiguration() throws Exception { - assertMemoryLeak(() -> { - try { - Sender.builder(Sender.Transport.HTTP).autoFlushIntervalMillis(1).autoFlushIntervalMillis(1); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "auto flush interval was already configured [autoFlushIntervalMillis=1]"); - } - }); - } - - @Test - public void testAutoFlushRowsCannotBeNegative() { - try (Sender ignored = Sender.builder(Sender.Transport.HTTP).autoFlushRows(-1).build()) { - fail("auto-flush must be positive"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "auto flush rows cannot be negative [autoFlushRows=-1]"); - } - } - - @Test - public void testAutoFlushRowsNotSupportedForTcp() throws Exception { - assertMemoryLeak(() -> { - try { - Sender.builder(Sender.Transport.TCP).address(LOCALHOST).autoFlushRows(1).build(); - fail("auto flush rows should not be supported for TCP"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "auto flush rows is not supported for TCP protocol"); - } - }); - } - - @Test - public void testAutoFlushRows_doubleConfiguration() throws Exception { - assertMemoryLeak(() -> { - try { - Sender.builder(Sender.Transport.HTTP).autoFlushRows(1).autoFlushRows(1); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "auto flush rows was already configured [autoFlushRows=1]"); - } - }); - } - - @Test - public void testBufferSizeDoubleSet() throws Exception { - assertMemoryLeak(() -> { - Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.TCP).bufferCapacity(1024); - try { - builder.bufferCapacity(1024); - fail("should not allow double buffer capacity set"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "already configured"); - } - }); - } - - @Test - public void testConfStringValidation() throws Exception { - assertMemoryLeak(() -> { - assertConfStrError("foo", "invalid schema [schema=foo, supported-schemas=[http, https, tcp, tcps]]"); - assertConfStrError("badschema::addr=bar;", "invalid schema [schema=badschema, supported-schemas=[http, https, tcp, tcps]]"); - assertConfStrError("http::addr=localhost:-1;", "invalid port [port=-1]"); - assertConfStrError("http::auto_flush=on;", "addr is missing"); - assertConfStrError("http::addr=localhost;tls_roots=/some/path;", "tls_roots was configured, but tls_roots_password is missing"); - assertConfStrError("http::addr=localhost;tls_roots_password=hunter123;", "tls_roots_password was configured, but tls_roots is missing"); - assertConfStrError("tcp::addr=localhost;user=foo;", "token cannot be empty nor null"); - assertConfStrError("tcp::addr=localhost;username=foo;", "token cannot be empty nor null"); - assertConfStrError("tcp::addr=localhost;token=foo;", "TCP token is configured, but user is missing"); - assertConfStrError("http::addr=localhost;user=foo;", "password cannot be empty nor null"); - assertConfStrError("http::addr=localhost;username=foo;", "password cannot be empty nor null"); - assertConfStrError("http::addr=localhost;pass=foo;", "HTTP password is configured, but username is missing"); - assertConfStrError("http::addr=localhost;password=foo;", "HTTP password is configured, but username is missing"); - assertConfStrError("tcp::addr=localhost;pass=foo;", "password is not supported for TCP protocol"); - assertConfStrError("tcp::addr=localhost;password=foo;", "password is not supported for TCP protocol"); - assertConfStrError("tcp::addr=localhost;retry_timeout=;", "retry_timeout cannot be empty"); - assertConfStrError("tcp::addr=localhost;max_buf_size=;", "max_buf_size cannot be empty"); - assertConfStrError("tcp::addr=localhost;init_buf_size=;", "init_buf_size cannot be empty"); - assertConfStrError("http::addr=localhost:8080;tls_verify=unsafe_off;", "TLS validation disabled, but TLS was not enabled"); - assertConfStrError("http::addr=localhost:8080;tls_verify=bad;", "invalid tls_verify [value=bad, allowed-values=[on, unsafe_off]]"); - assertConfStrError("tcps::addr=localhost;pass=unsafe_off;", "password is not supported for TCP protocol"); - assertConfStrError("tcps::addr=localhost;password=unsafe_off;", "password is not supported for TCP protocol"); - assertConfStrError("http::addr=localhost:8080;max_buf_size=-32;", "maximum buffer capacity cannot be less than initial buffer capacity [maximumBufferCapacity=-32, initialBufferCapacity=65536]"); - assertConfStrError("http::addr=localhost:8080;max_buf_size=notanumber;", "invalid max_buf_size [value=notanumber]"); - assertConfStrError("http::addr=localhost:8080;init_buf_size=notanumber;", "invalid init_buf_size [value=notanumber]"); - assertConfStrError("http::addr=localhost:8080;init_buf_size=-42;", "buffer capacity cannot be negative [capacity=-42]"); - assertConfStrError("http::addr=localhost:8080;auto_flush_rows=0;", "invalid auto_flush_rows [value=0]"); - assertConfStrError("http::addr=localhost:8080;auto_flush_rows=notanumber;", "invalid auto_flush_rows [value=notanumber]"); - assertConfStrError("http::addr=localhost:8080;auto_flush=invalid;", "invalid auto_flush [value=invalid, allowed-values=[on, off]]"); - assertConfStrError("http::addr=localhost:8080;auto_flush=off;auto_flush_rows=100;", "cannot set auto flush rows when auto-flush is already disabled"); - assertConfStrError("http::addr=localhost:8080;auto_flush_rows=100;auto_flush=off;", "auto flush rows was already configured [autoFlushRows=100]"); - assertConfStrError("HTTP::addr=localhost;", "invalid schema [schema=HTTP, supported-schemas=[http, https, tcp, tcps]]"); - assertConfStrError("HTTPS::addr=localhost;", "invalid schema [schema=HTTPS, supported-schemas=[http, https, tcp, tcps]]"); - assertConfStrError("TCP::addr=localhost;", "invalid schema [schema=TCP, supported-schemas=[http, https, tcp, tcps]]"); - assertConfStrError("TCPS::addr=localhost;", "invalid schema [schema=TCPS, supported-schemas=[http, https, tcp, tcps]]"); - assertConfStrError("http::addr=localhost;auto_flush=off;auto_flush_interval=1;", "cannot set auto flush interval when interval based auto-flush is already disabled"); - assertConfStrError("http::addr=localhost;auto_flush=off;auto_flush_rows=1;", "cannot set auto flush rows when auto-flush is already disabled"); - assertConfStrError("http::addr=localhost;auto_flush_bytes=1024;", "auto_flush_bytes is only supported for TCP transport"); - assertConfStrError("http::addr=localhost;protocol_version=10", "current client only supports protocol version 1(text format for all datatypes), 2(binary format for part datatypes), 3(decimal datatype) or explicitly unset"); - assertConfStrError("http::addr=localhost:48884;max_name_len=10;", "max_name_len must be at least 16 bytes [max_name_len=10]"); - - assertConfStrOk("addr=localhost:8080", "auto_flush_rows=100", "protocol_version=1"); - assertConfStrOk("addr=localhost:8080", "auto_flush=on", "auto_flush_rows=100", "protocol_version=2"); - assertConfStrOk("addr=localhost:8080", "auto_flush_rows=100", "auto_flush=on", "protocol_version=2"); - assertConfStrOk("addr=localhost", "auto_flush=on", "protocol_version=2"); - assertConfStrOk("addr=localhost:8080", "max_name_len=1024", "protocol_version=2"); - - assertConfStrError("tcp::addr=localhost;auto_flush_bytes=1024;init_buf_size=2048;", "TCP transport requires init_buf_size and auto_flush_bytes to be set to the same value [init_buf_size=2048, auto_flush_bytes=1024]"); - assertConfStrError("tcp::addr=localhost;init_buf_size=1024;auto_flush_bytes=2048;", "TCP transport requires init_buf_size and auto_flush_bytes to be set to the same value [init_buf_size=1024, auto_flush_bytes=2048]"); - assertConfStrError("tcp::addr=localhost;auto_flush_bytes=off;", "TCP transport must have auto_flush_bytes enabled"); - - assertConfStrOk("http::addr=localhost;auto_flush=off;protocol_version=2;"); - assertConfStrOk("http::addr=localhost;protocol_version=2;"); - assertConfStrOk("http::addr=localhost;auto_flush_interval=off;protocol_version=2;"); - assertConfStrOk("http::addr=localhost;auto_flush_rows=off;protocol_version=2;"); - assertConfStrOk("http::addr=localhost;auto_flush_interval=off;auto_flush_rows=off;protocol_version=1;"); - assertConfStrOk("http::addr=localhost;auto_flush_interval=off;auto_flush_rows=1;protocol_version=2;"); - assertConfStrOk("http::addr=localhost;auto_flush_rows=off;auto_flush_interval=1;protocol_version=2;"); - assertConfStrOk("http::addr=localhost;auto_flush_interval=off;auto_flush_rows=off;auto_flush=off;protocol_version=2;"); - assertConfStrOk("http::addr=localhost;auto_flush=off;auto_flush_interval=off;auto_flush_rows=off;protocol_version=1;"); - assertConfStrOk("http::addr=localhost:8080;protocol_version=2;"); - assertConfStrOk("http::addr=localhost:8080;token=foo;protocol_version=2;"); - assertConfStrOk("http::addr=localhost:8080;token=foo=bar;protocol_version=2;"); - assertConfStrOk("addr=localhost:8080", "token=foo", "retry_timeout=1000", "max_buf_size=1000000", "protocol_version=2"); - assertConfStrOk("addr=localhost:8080", "token=foo", "retry_timeout=1000", "max_buf_size=1000000", "protocol_version=1"); - assertConfStrOk("http::addr=localhost:8080;token=foo;max_buf_size=1000000;retry_timeout=1000;protocol_version=2;"); - assertConfStrOk("https::addr=localhost:8080;tls_verify=unsafe_off;auto_flush_rows=100;protocol_version=2;"); - assertConfStrOk("https::addr=localhost:8080;tls_verify=unsafe_off;auto_flush_rows=100;protocol_version=2;max_name_len=256;"); - assertConfStrOk("https::addr=localhost:8080;tls_verify=on;protocol_version=2;"); - assertConfStrOk("https::addr=localhost:8080;tls_verify=on;protocol_version=3;"); - assertConfStrError("https::addr=2001:0db8:85a3:0000:0000:8a2e:0370:7334;tls_verify=on;", "cannot parse a port from the address, use IPv4 address or a domain name [address=2001:0db8:85a3:0000:0000:8a2e:0370:7334]"); - assertConfStrError("https::addr=[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:9000;tls_verify=on;", "cannot parse a port from the address, use IPv4 address or a domain name [address=[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:9000]"); - }); - } - - @Test - public void testCustomTruststoreButTlsNotEnabled() throws Exception { - assertMemoryLeak(() -> { - Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.TCP) - .advancedTls().customTrustStore(TRUSTSTORE_PATH, TRUSTSTORE_PASSWORD) - .address(LOCALHOST); - try { - builder.build(); - fail("should fail when custom trust store configured, but TLS not enabled"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "TLS was not enabled"); - } - }); - } - - @Test - public void testCustomTruststoreDoubleSet() throws Exception { - assertMemoryLeak(() -> { - Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.TCP).advancedTls().customTrustStore(TRUSTSTORE_PATH, TRUSTSTORE_PASSWORD); - try { - builder.advancedTls().customTrustStore(TRUSTSTORE_PATH, TRUSTSTORE_PASSWORD); - fail("should not allow double custom trust store set"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "already configured"); - } - }); - } - - @Test - public void testCustomTruststorePasswordCannotBeNull() { - try { - Sender.builder(Sender.Transport.TCP).advancedTls().customTrustStore(TRUSTSTORE_PATH, null); - fail("should not allow null trust store password"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "trust store password cannot be null"); - } - } - - @Test - public void testCustomTruststorePathCannotBeBlank() { - try { - Sender.builder(Sender.Transport.TCP).advancedTls().customTrustStore("", TRUSTSTORE_PASSWORD); - fail("should not allow blank trust store path"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "trust store path cannot be empty nor null"); - } - - try { - Sender.builder(Sender.Transport.TCP).advancedTls().customTrustStore(null, TRUSTSTORE_PASSWORD); - fail("should not allow null trust store path"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "trust store path cannot be empty nor null"); - } - } - - @Test - public void testDisableAutoFlushNotSupportedForTcp() throws Exception { - assertMemoryLeak(() -> { - try (Sender ignored = Sender.builder(Sender.Transport.TCP).address(LOCALHOST).disableAutoFlush().build()) { - fail("TCP does not support disabling auto-flush"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "auto-flush is not supported for TCP protocol"); - } - }); - } - - @Test - public void testDnsResolutionFail() throws Exception { - assertMemoryLeak(() -> { - try (Sender ignored = Sender.builder(Sender.Transport.TCP).address("this-domain-does-not-exist-i-hope-better-to-use-a-silly-tld.silly-tld").build()) { - fail("dns resolution errors should fail fast"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "could not resolve"); - } - }); - } - - @Test - public void testDuplicatedAddresses() throws Exception { - assertMemoryLeak(() -> { - try { - Sender.builder(Sender.Transport.TCP).address("localhost:9000").address("localhost:9000"); - Assert.fail("should not allow multiple addresses"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "duplicated addresses are not allowed [address=localhost:9000]"); - } - - try { - Sender.builder(Sender.Transport.TCP).address("localhost:9000").address("localhost:9001").address("localhost:9000"); - Assert.fail("should not allow multiple addresses"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "duplicated addresses are not allowed [address=localhost:9000]"); - } - }); - } - - @Test - public void testDuplicatedAddressesWithDifferentPortsAllowed() throws Exception { - assertMemoryLeak(() -> { - Sender.builder(Sender.Transport.TCP).address("localhost:9000").address("localhost:9001"); - }); - } - - @Test - public void testDuplicatedAddressesWithNoPortsAllowed() throws Exception { - assertMemoryLeak(() -> { - Sender.builder(Sender.Transport.TCP).address("localhost:9000").address("localhost"); - Sender.builder(Sender.Transport.TCP).address("localhost").address("localhost:9000"); - }); - } - - @Test - public void testFailFastWhenSetCustomTrustStoreTwice() { - Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.TCP).advancedTls().customTrustStore(TRUSTSTORE_PATH, TRUSTSTORE_PASSWORD); - try { - builder.advancedTls().customTrustStore(TRUSTSTORE_PATH, TRUSTSTORE_PASSWORD); - fail("should not allow double custom trust store set"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "already configured"); - } - } - - @Test - public void testFirstTlsValidationDisabledThenCustomTruststore() throws Exception { - assertMemoryLeak(() -> { - Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.TCP) - .advancedTls().disableCertificateValidation(); - try { - builder.advancedTls().customTrustStore(TRUSTSTORE_PATH, TRUSTSTORE_PASSWORD); - fail("should not allow custom truststore when TLS validation was disabled disabled"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "TLS validation was already disabled"); - } - }); - } - - @Test - public void testHostNorAddressSet() throws Exception { - assertMemoryLeak(() -> { - Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.TCP); - try { - builder.build(); - fail("not host should fail"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "server address not set"); - } - }); - } - - @Test - public void testHttpTokenNotSupportedForTcp() throws Exception { - assertMemoryLeak(() -> { - try { - Sender.builder(Sender.Transport.TCP).address(LOCALHOST).httpToken("foo").build(); - fail("HTTP token should not be supported for TCP"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "HTTP token authentication is not supported for TCP protocol"); - } - }); - } - - @Test - public void testInvalidHttpTimeout() throws Exception { - assertMemoryLeak(() -> { - try { - Sender.builder(Sender.Transport.HTTP).address("someurl").httpTimeoutMillis(0); - fail("should fail with bad http time"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "HTTP timeout must be positive [timeout=0]"); - } - - try { - Sender.builder(Sender.Transport.HTTP).address("someurl").httpTimeoutMillis(-1); - fail("should fail with bad http time"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "HTTP timeout must be positive [timeout=-1]"); - } - - try { - Sender.builder(Sender.Transport.HTTP).address("someurl").httpTimeoutMillis(100).httpTimeoutMillis(200); - fail("should fail with bad http time"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "HTTP timeout was already configured [timeout=100]"); - } - - try { - Sender.builder(Sender.Transport.TCP).address("localhost").httpTimeoutMillis(5000).build(); - fail("should fail with bad http time"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "HTTP timeout is not supported for TCP protocol"); - } - }); - } - - @Test - public void testInvalidRetryTimeout() { - try { - Sender.builder(Sender.Transport.HTTP).retryTimeoutMillis(-1); - Assert.fail(); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "retry timeout cannot be negative [retryTimeoutMillis=-1]"); - } - - Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.HTTP).retryTimeoutMillis(100); - try { - builder.retryTimeoutMillis(200); - Assert.fail(); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "retry timeout was already configured [retryTimeoutMillis=100]"); - } - } - - @Test - public void testMalformedPortInAddress() throws Exception { - assertMemoryLeak(() -> { - Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.TCP); - try { - builder.address("foo:nonsense12334"); - fail("should fail with malformated port"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "cannot parse a port from the address"); - } - }); - } - - @Test - public void testMaxRequestBufferSizeCannotBeLessThanDefault() throws Exception { - assertMemoryLeak(() -> { - try (Sender ignored = Sender.builder(Sender.Transport.HTTP) - .address("localhost:1") - .maxBufferCapacity(65535) - .build() - ) { - Assert.fail(); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "maximum buffer capacity cannot be less than initial buffer capacity [maximumBufferCapacity=65535, initialBufferCapacity=65536]"); - } - }); - } - - @Test - public void testMaxRequestBufferSizeCannotBeLessThanInitialBufferSize() throws Exception { - assertMemoryLeak(() -> { - try (Sender ignored = Sender.builder(Sender.Transport.HTTP) - .address("localhost:1") - .maxBufferCapacity(100_000) - .bufferCapacity(200_000) - .build() - ) { - Assert.fail(); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "maximum buffer capacity cannot be less than initial buffer capacity [maximumBufferCapacity=100000, initialBufferCapacity=200000]"); - } - }); - } - - @Test - public void testMaxRetriesNotSupportedForTcp() throws Exception { - assertMemoryLeak(() -> { - try { - Sender.builder(Sender.Transport.TCP).address(LOCALHOST).retryTimeoutMillis(100).build(); - fail("max retries should not be supported for TCP"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "retrying is not supported for TCP protocol"); - } - }); - } - - @Test - public void testMinRequestThroughputCannotBeNegative() throws Exception { - assertMemoryLeak(() -> { - try { - Sender.builder(Sender.Transport.HTTP).address(LOCALHOST).minRequestThroughput(-100).build(); - fail("minimum request throughput must not be negative"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "minimum request throughput must not be negative [minRequestThroughput=-100]"); - } - }); - } - - @Test - public void testMinRequestThroughputNotSupportedForTcp() throws Exception { - assertMemoryLeak(() -> { - try { - Sender.builder(Sender.Transport.TCP).address(LOCALHOST).minRequestThroughput(1).build(); - fail("min request throughput is not be supported for TCP and the builder should fail-fast"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "minimum request throughput is not supported for TCP protocol"); - } - }); - } - - @Test - public void testPlainAuth_connectionRefused() throws Exception { - assertMemoryLeak(() -> { - Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.TCP) - .enableAuth("foo").authToken(AUTH_TOKEN_KEY1).address(LOCALHOST + ":19003"); - try { - builder.build(); - fail("connection refused should fail fast"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "could not connect"); - } - }); - } - - @Test - public void testPlainOldTokenNotSupportedForHttpProtocol() throws Exception { - assertMemoryLeak(() -> { - try { - Sender.builder(Sender.Transport.HTTP).address("localhost:9000").enableAuth("key").authToken(AUTH_TOKEN_KEY1).build(); - fail("HTTP token should not be supported for TCP"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "old token authentication is not supported for HTTP protocol"); - } - }); - } - - @Test - public void testPlain_connectionRefused() throws Exception { - assertMemoryLeak(() -> { - Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.TCP).address(LOCALHOST + ":19003"); - try { - builder.build(); - fail("connection refused should fail fast"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "could not connect"); - } - }); - } - - @Test - public void testPortDoubleSet_firstAddressThenPort() throws Exception { - assertMemoryLeak(() -> { - Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.TCP).address(LOCALHOST + ":9000"); - try { - builder.port(9000); - builder.build(); - fail("should not allow double port set"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "mismatch"); - } - }); - } - - @Test - public void testPortDoubleSet_firstPortThenAddress() throws Exception { - assertMemoryLeak(() -> { - Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.TCP).port(9000); - try { - builder.address(LOCALHOST + ":9000"); - builder.build(); - fail("should not allow double port set"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "mismatch"); - } - }); - } - - @Test - public void testPortDoubleSet_firstPortThenPort() throws Exception { - assertMemoryLeak(() -> { - Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.TCP).port(9000); - try { - builder.port(9000); - builder.build(); - fail("should not allow double port set"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "questdb server address not set"); - } - }); - } - - @Test - public void testSmallMaxNameLen() throws Exception { - assertMemoryLeak(() -> { - try { - Sender.LineSenderBuilder ignored = Sender - .builder(Sender.Transport.TCP) - .maxNameLength(10); - fail("should not allow double buffer capacity set"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "max_name_len must be at least 16 bytes [max_name_len=10]"); - } - }); - } - - @Test - public void testTlsDoubleSet() throws Exception { - assertMemoryLeak(() -> { - Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.TCP).enableTls(); - try { - builder.enableTls(); - fail("should not allow double tls set"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "already enabled"); - } - }); - } - - @Test - public void testTlsValidationDisabledButTlsNotEnabled() throws Exception { - assertMemoryLeak(() -> { - Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.TCP) - .advancedTls().disableCertificateValidation() - .address(LOCALHOST); - try { - builder.build(); - fail("should fail when TLS validation is disabled, but TLS not enabled"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "TLS was not enabled"); - } - }); - } - - @Test - public void testTlsValidationDisabledDoubleSet() throws Exception { - assertMemoryLeak(() -> { - Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.TCP) - .advancedTls().disableCertificateValidation(); - try { - builder.advancedTls().disableCertificateValidation(); - fail("should not allow double TLS validation disabled"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "TLS validation was already disabled"); - } - }); - } - - @Test - public void testTls_connectionRefused() throws Exception { - assertMemoryLeak(() -> { - Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.TCP).enableTls().address(LOCALHOST + ":19003"); - try { - builder.build(); - fail("connection refused should fail fast"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "could not connect"); - } - }); - } - - @Test - public void testUsernamePasswordAuthNotSupportedForTcp() throws Exception { - assertMemoryLeak(() -> { - try { - Sender.builder(Sender.Transport.TCP).address(LOCALHOST).httpUsernamePassword("foo", "bar").build(); - fail("HTTP token should not be supported for TCP"); - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), "username/password authentication is not supported for TCP protocol"); - } - }); - } - - private static void assertConfStrError(String conf, String expectedError) { - try { - try (Sender ignored = Sender.fromConfig(conf)) { - fail("should fail with bad conf string"); - } - } catch (LineSenderException e) { - TestUtils.assertContains(e.getMessage(), expectedError); - } - } - - private static void assertConfStrOk(String... params) { - StringBuilder sb = new StringBuilder(); - sb.append("http").append("::"); - shuffle(params); - for (int i = 0; i < params.length; i++) { - sb.append(params[i]).append(";"); - } - assertConfStrOk(sb.toString()); - } - - private static void assertConfStrOk(String conf) { - Sender.fromConfig(conf).close(); - } - - private static void shuffle(String[] input) { - for (int i = 0; i < input.length; i++) { - int j = (int) (Math.random() * input.length); - String tmp = input[i]; - input[i] = input[j]; - input[j] = tmp; - } - } -} diff --git a/core/src/test/java/io/questdb/client/test/cairo/ColumnTypeTest.java b/core/src/test/java/io/questdb/client/test/cairo/ColumnTypeTest.java index debb3ed..20429a0 100644 --- a/core/src/test/java/io/questdb/client/test/cairo/ColumnTypeTest.java +++ b/core/src/test/java/io/questdb/client/test/cairo/ColumnTypeTest.java @@ -30,14 +30,8 @@ public class ColumnTypeTest { @Test - public void testArrayWithWeakDims() { - int arrayType = ColumnType.encodeArrayTypeWithWeakDims(ColumnType.DOUBLE, true); - Assert.assertTrue(ColumnType.isArray(arrayType)); - // arrays with weak dimensions are considered undefined - Assert.assertEquals(ColumnType.DOUBLE, ColumnType.decodeArrayElementType(arrayType)); - Assert.assertEquals(-1, ColumnType.decodeWeakArrayDimensionality(arrayType)); - - arrayType = ColumnType.encodeArrayType(ColumnType.DOUBLE, 5); + public void testArrayEncoding() { + int arrayType = ColumnType.encodeArrayType(ColumnType.DOUBLE, 5); Assert.assertTrue(ColumnType.isArray(arrayType)); Assert.assertEquals(ColumnType.DOUBLE, ColumnType.decodeArrayElementType(arrayType)); Assert.assertEquals(5, ColumnType.decodeWeakArrayDimensionality(arrayType)); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/http/HttpHeaderParserTest.java b/core/src/test/java/io/questdb/client/test/cutlass/http/HttpHeaderParserTest.java index f7a9201..4cfc8c0 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/http/HttpHeaderParserTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/http/HttpHeaderParserTest.java @@ -33,6 +33,7 @@ import io.questdb.client.std.str.DirectUtf8String; import io.questdb.client.std.str.Utf8String; import io.questdb.client.test.tools.TestUtils; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; import org.junit.Assert; import org.junit.Test; @@ -53,7 +54,7 @@ public class HttpHeaderParserTest { @Test public void testContentLengthLarge() throws Exception { - TestUtils.assertMemoryLeak(() -> { + assertMemoryLeak(() -> { String v = "Content-Length: 81136060058\r\n" + "\r\n"; long p = TestUtils.toMemory(v); @@ -135,7 +136,7 @@ public void testProtocolLineFuzz() { @Test public void testQueryDanglingEncoding() throws Exception { - TestUtils.assertMemoryLeak(() -> { + assertMemoryLeak(() -> { String v = "GET /status?x=1&a=% HTTP/1.1\r\n" + "\r\n"; long p = TestUtils.toMemory(v); @@ -152,7 +153,7 @@ public void testQueryDanglingEncoding() throws Exception { @Test public void testQueryInvalidEncoding() throws Exception { - TestUtils.assertMemoryLeak(() -> { + assertMemoryLeak(() -> { String v = "GET /status?x=1&a=%i6b&c&d=x HTTP/1.1\r\n" + "\r\n"; long p = TestUtils.toMemory(v); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/http/TestHttpClient.java b/core/src/test/java/io/questdb/client/test/cutlass/http/TestHttpClient.java index af938b3..67bba2b 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/http/TestHttpClient.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/http/TestHttpClient.java @@ -32,7 +32,6 @@ import io.questdb.client.std.str.MutableUtf8Sink; import io.questdb.client.std.str.Utf8Sequence; import io.questdb.client.std.str.Utf8StringSink; -import io.questdb.client.std.str.Utf8s; import io.questdb.client.test.tools.TestUtils; import org.jetbrains.annotations.Nullable; import org.junit.Assert; @@ -489,7 +488,8 @@ protected String reqToSink0( @SuppressWarnings("resource") HttpClient.ResponseHeaders rsp = req.send(); rsp.await(); - String statusCode = Utf8s.toString(rsp.getStatusCode()); + Utf8Sequence sc = rsp.getStatusCode(); + String statusCode = sc == null ? null : sc.toString(); sink.clear(); rsp.getResponse().copyTextTo(sink); return statusCode; diff --git a/core/src/test/java/io/questdb/client/test/cutlass/http/client/WebSocketClientTest.java b/core/src/test/java/io/questdb/client/test/cutlass/http/client/WebSocketClientTest.java new file mode 100644 index 0000000..aa8d142 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/http/client/WebSocketClientTest.java @@ -0,0 +1,282 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.http.client; + +import io.questdb.client.DefaultHttpClientConfiguration; +import io.questdb.client.cutlass.http.client.HttpClientException; +import io.questdb.client.cutlass.http.client.WebSocketClient; +import io.questdb.client.cutlass.http.client.WebSocketFrameHandler; +import io.questdb.client.cutlass.http.client.WebSocketSendBuffer; +import io.questdb.client.network.NetworkFacade; +import io.questdb.client.network.PlainSocketFactory; +import io.questdb.client.network.Socket; +import io.questdb.client.network.TlsSessionInitFailedException; +import io.questdb.client.std.Unsafe; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; +import org.junit.Assert; +import org.junit.Test; + +import java.lang.reflect.Field; + +public class WebSocketClientTest { + + @Test + public void testSendCloseFrameDoesNotClobberSendBuffer() throws Exception { + assertMemoryLeak(() -> { + try (StubWebSocketClient client = new StubWebSocketClient()) { + WebSocketSendBuffer sendBuffer = client.getSendBuffer(); + + // User starts building a data frame + sendBuffer.beginFrame(); + sendBuffer.putLong(0xDEADBEEFL); + int posBeforeClose = sendBuffer.getWritePos(); + Assert.assertTrue("sendBuffer should have data", posBeforeClose > 0); + + // sendCloseFrame() should use controlFrameBuffer, not sendBuffer + try { + client.sendCloseFrame(1000, null, 1000); + } catch (HttpClientException ignored) { + // Expected: doSend() fails because there's no real socket + } + + // Verify sendBuffer was NOT clobbered + Assert.assertEquals( + "sendCloseFrame() must not reset the main sendBuffer", + posBeforeClose, + sendBuffer.getWritePos() + ); + } + }); + } + + @Test + public void testSendPingDoesNotClobberSendBuffer() throws Exception { + assertMemoryLeak(() -> { + try (StubWebSocketClient client = new StubWebSocketClient()) { + // Set upgraded=true so checkConnected() passes + setField(client, "upgraded", true); + + WebSocketSendBuffer sendBuffer = client.getSendBuffer(); + + // User starts building a data frame + sendBuffer.beginFrame(); + sendBuffer.putLong(0xCAFEBABEL); + int posBeforePing = sendBuffer.getWritePos(); + Assert.assertTrue("sendBuffer should have data", posBeforePing > 0); + + // sendPing() should use controlFrameBuffer, not sendBuffer + try { + client.sendPing(1000); + } catch (HttpClientException ignored) { + // Expected: doSend() fails because there's no real socket + } + + // Verify sendBuffer was NOT clobbered + Assert.assertEquals( + "sendPing() must not reset the main sendBuffer", + posBeforePing, + sendBuffer.getWritePos() + ); + } + }); + } + + @Test + public void testRecvOrTimeoutPropagatesNonTimeoutError() throws Exception { + assertMemoryLeak(() -> { + try (RecvTestWebSocketClient client = new RecvTestWebSocketClient()) { + setField(client, "upgraded", true); + + // socket.recv() returns 0, triggering the ioWait path + // ioWait throws a non-timeout error (e.g., queue/poll failure) + client.ioWaitAction = () -> { + throw new HttpClientException("queue error [errno=").put(5).put(']'); + }; + + WebSocketFrameHandler noOpHandler = new WebSocketFrameHandler() { + @Override + public void onBinaryMessage(long payloadPtr, int payloadLen) { + } + + @Override + public void onClose(int code, String reason) { + } + }; + + try { + client.receiveFrame(noOpHandler, 1000); + Assert.fail("expected HttpClientException for queue error"); + } catch (HttpClientException e) { + Assert.assertFalse("non-timeout error must not be flagged as timeout", e.isTimeout()); + Assert.assertTrue( + "expected queue error message, got: " + e.getMessage(), + e.getMessage().contains("queue error") + ); + } + } + }); + } + + @Test + public void testRecvOrTimeoutReturnsFalseOnTimeout() throws Exception { + assertMemoryLeak(() -> { + try (RecvTestWebSocketClient client = new RecvTestWebSocketClient()) { + setField(client, "upgraded", true); + + // socket.recv() returns 0, triggering the ioWait path + // ioWait throws a timeout error + client.ioWaitAction = () -> { + throw new HttpClientException("timed out [errno=").put(0).put(']').flagAsTimeout(); + }; + + WebSocketFrameHandler noOpHandler = new WebSocketFrameHandler() { + @Override + public void onBinaryMessage(long payloadPtr, int payloadLen) { + } + + @Override + public void onClose(int code, String reason) { + } + }; + + boolean result = client.receiveFrame(noOpHandler, 1000); + Assert.assertFalse("receiveFrame should return false on timeout", result); + } + }); + } + + private static void setField(Object obj, String fieldName, Object value) throws Exception { + Class clazz = obj.getClass(); + while (clazz != null) { + try { + Field field = clazz.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(obj, value); + return; + } catch (NoSuchFieldException e) { + clazz = clazz.getSuperclass(); + } + } + throw new NoSuchFieldException(fieldName); + } + + /** + * Minimal concrete WebSocketClient that throws on any I/O, + * allowing us to test buffer management without a real socket. + */ + private static class StubWebSocketClient extends WebSocketClient { + + StubWebSocketClient() { + super(DefaultHttpClientConfiguration.INSTANCE, PlainSocketFactory.INSTANCE); + } + + @Override + protected void ioWait(int timeout, int op) { + throw new HttpClientException("stub: no socket"); + } + + @Override + protected void setupIoWait() { + // no-op + } + } + + /** + * WebSocketClient subclass with a fake socket that always returns 0 + * from recv(), forcing the ioWait path in recvOrTimeout(). + */ + private static class RecvTestWebSocketClient extends WebSocketClient { + Runnable ioWaitAction; + + RecvTestWebSocketClient() { + super(DefaultHttpClientConfiguration.INSTANCE, (nf, log) -> new FakeSocket()); + } + + @Override + protected void ioWait(int timeout, int op) { + ioWaitAction.run(); + } + + @Override + protected void setupIoWait() { + // no-op + } + } + + /** + * Minimal Socket that always returns 0 from recv() (no data available), + * triggering the ioWait path in recvOrTimeout(). + */ + private static class FakeSocket implements Socket { + + @Override + public void close() { + } + + @Override + public int getFd() { + return 0; + } + + @Override + public boolean isClosed() { + return false; + } + + @Override + public void of(int fd) { + } + + @Override + public int recv(long bufferPtr, int bufferLen) { + return 0; + } + + @Override + public int send(long bufferPtr, int bufferLen) { + return 0; + } + + @Override + public void startTlsSession(CharSequence peerName) throws TlsSessionInitFailedException { + throw new UnsupportedOperationException(); + } + + @Override + public boolean supportsTls() { + return false; + } + + @Override + public int tlsIO(int readinessFlags) { + return 0; + } + + @Override + public boolean wantsTlsWrite() { + return false; + } + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/http/HttpHeaderParameterValue.java b/core/src/test/java/io/questdb/client/test/cutlass/http/client/WebSocketSendBufferTest.java similarity index 51% rename from core/src/main/java/io/questdb/client/cutlass/http/HttpHeaderParameterValue.java rename to core/src/test/java/io/questdb/client/test/cutlass/http/client/WebSocketSendBufferTest.java index 980b017..606c729 100644 --- a/core/src/main/java/io/questdb/client/cutlass/http/HttpHeaderParameterValue.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/http/client/WebSocketSendBufferTest.java @@ -22,25 +22,28 @@ * ******************************************************************************/ -package io.questdb.client.cutlass.http; +package io.questdb.client.test.cutlass.http.client; -import io.questdb.client.std.str.DirectUtf8String; +import io.questdb.client.cutlass.http.client.WebSocketSendBuffer; +import io.questdb.client.std.Unsafe; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; +import org.junit.Test; -public class HttpHeaderParameterValue { - private long hi; - private DirectUtf8String str; +import static org.junit.Assert.assertEquals; - public long getHi() { - return hi; - } - - public DirectUtf8String getStr() { - return str; - } +public class WebSocketSendBufferTest { - public HttpHeaderParameterValue of(long hi, DirectUtf8String str) { - this.hi = hi; - this.str = str; - return this; + @Test + public void testPutUtf8InvalidSurrogatePair() throws Exception { + assertMemoryLeak(() -> { + try (WebSocketSendBuffer buf = new WebSocketSendBuffer(256)) { + // High surrogate \uD800 followed by non-low-surrogate 'X'. + // Should produce '?' for the lone high surrogate, then 'X'. + buf.putUtf8("\uD800X"); + assertEquals(2, buf.getWritePos()); + assertEquals((byte) '?', Unsafe.getUnsafe().getByte(buf.getBufferPtr())); + assertEquals((byte) 'X', Unsafe.getUnsafe().getByte(buf.getBufferPtr() + 1)); + } + }); } -} \ No newline at end of file +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/json/JsonLexerTest.java b/core/src/test/java/io/questdb/client/test/cutlass/json/JsonLexerTest.java index fafad57..9f7f067 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/json/JsonLexerTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/json/JsonLexerTest.java @@ -33,6 +33,7 @@ import io.questdb.client.std.Mutable; import io.questdb.client.std.Unsafe; import io.questdb.client.test.tools.TestUtils; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; import org.junit.AfterClass; import org.junit.Assert; import org.junit.Before; @@ -78,7 +79,7 @@ public void testBreakOnValue() throws Exception { @Test public void testCacheDisabled() throws Exception { - TestUtils.assertMemoryLeak(() -> { + assertMemoryLeak(() -> { String json = "{\"a\":1, \"b\": \"123456789012345678901234567890\"}"; int len = json.length(); long address = TestUtils.toMemory(json); @@ -251,7 +252,7 @@ public void testSimpleJson() throws Exception { @Test public void testStringTooLong() throws Exception { - TestUtils.assertMemoryLeak(() -> { + assertMemoryLeak(() -> { String json = "{\"a\":1, \"b\": \"123456789012345678901234567890\"]}"; int len = json.length() - 6; long address = TestUtils.toMemory(json); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderBuilderTest.java b/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderBuilderTest.java new file mode 100644 index 0000000..031f204 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderBuilderTest.java @@ -0,0 +1,493 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.line; + +import io.questdb.client.Sender; +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.test.tools.TestUtils; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; +import org.junit.Test; + +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; +import static org.junit.Assert.fail; + +/** + * Unit tests for LineSenderBuilder that don't require a running QuestDB instance. + * Tests that require an actual QuestDB connection have been moved to integration tests. + */ +public class LineSenderBuilderTest { + private static final String AUTH_TOKEN_KEY1 = "UvuVb1USHGRRT08gEnwN2zGZrvM4MsLQ5brgF6SVkAw="; + private static final String LOCALHOST = "localhost"; + private static final char[] TRUSTSTORE_PASSWORD = "questdb".toCharArray(); + private static final String TRUSTSTORE_PATH = "/keystore/server.keystore"; + + @Test + public void testAddressDoubleSet_firstAddressThenAddress() throws Exception { + assertMemoryLeak(() -> assertThrows("mismatch", + Sender.builder(Sender.Transport.TCP).address(LOCALHOST).address("127.0.0.1"))); + } + + @Test + public void testAddressEmpty() throws Exception { + assertMemoryLeak(() -> assertThrows("address cannot be empty", + () -> Sender.builder(Sender.Transport.TCP).address(""))); + } + + @Test + public void testAddressEndsWithColon() throws Exception { + assertMemoryLeak(() -> assertThrows("invalid address", + () -> Sender.builder(Sender.Transport.TCP).address("foo:"))); + } + + @Test + public void testAddressNull() throws Exception { + assertMemoryLeak(() -> assertThrows("null", + () -> Sender.builder(Sender.Transport.TCP).address(null))); + } + + @Test + public void testAuthDoubleSet() throws Exception { + assertMemoryLeak(() -> assertThrows("already configured", + () -> Sender.builder(Sender.Transport.TCP).enableAuth("foo").authToken(AUTH_TOKEN_KEY1).enableAuth("bar"))); + } + + @Test + public void testAuthTooSmallBuffer() throws Exception { + assertMemoryLeak(() -> assertThrows("minimalCapacity", + Sender.builder(Sender.Transport.TCP) + .enableAuth("foo").authToken(AUTH_TOKEN_KEY1).address(LOCALHOST + ":9001") + .bufferCapacity(1))); + } + + @Test + public void testAuthWithBadToken() throws Exception { + assertMemoryLeak(() -> assertThrows("could not import token", + () -> Sender.builder(Sender.Transport.TCP).enableAuth("foo").authToken("bar token"))); + } + + @Test + public void testAutoFlushIntervalMustBePositive() { + assertThrows("auto flush interval cannot be negative [autoFlushIntervalMillis=0]", + () -> Sender.builder(Sender.Transport.HTTP).autoFlushIntervalMillis(0)); + assertThrows("auto flush interval cannot be negative [autoFlushIntervalMillis=-1]", + () -> Sender.builder(Sender.Transport.HTTP).autoFlushIntervalMillis(-1)); + } + + @Test + public void testAutoFlushIntervalNotSupportedForTcp() throws Exception { + assertMemoryLeak(() -> assertThrows("auto flush interval is not supported for TCP protocol", + Sender.builder(Sender.Transport.TCP).address(LOCALHOST).autoFlushIntervalMillis(1))); + } + + @Test + public void testAutoFlushInterval_afterAutoFlushDisabled() throws Exception { + assertMemoryLeak(() -> assertThrows("cannot set auto flush interval when interval based auto-flush is already disabled", + () -> Sender.builder(Sender.Transport.HTTP).disableAutoFlush().autoFlushIntervalMillis(1))); + } + + @Test + public void testAutoFlushInterval_doubleConfiguration() throws Exception { + assertMemoryLeak(() -> assertThrows("auto flush interval was already configured [autoFlushIntervalMillis=1]", + () -> Sender.builder(Sender.Transport.HTTP).autoFlushIntervalMillis(1).autoFlushIntervalMillis(1))); + } + + @Test + public void testAutoFlushRowsCannotBeNegative() { + assertThrows("auto flush rows cannot be negative [autoFlushRows=-1]", + () -> Sender.builder(Sender.Transport.HTTP).autoFlushRows(-1)); + } + + @Test + public void testAutoFlushRowsNotSupportedForTcp() throws Exception { + assertMemoryLeak(() -> assertThrows("auto flush rows is not supported for TCP protocol", + Sender.builder(Sender.Transport.TCP).address(LOCALHOST).autoFlushRows(1))); + } + + @Test + public void testAutoFlushRows_doubleConfiguration() throws Exception { + assertMemoryLeak(() -> assertThrows("auto flush rows was already configured [autoFlushRows=1]", + () -> Sender.builder(Sender.Transport.HTTP).autoFlushRows(1).autoFlushRows(1))); + } + + @Test + public void testBufferSizeDoubleSet() throws Exception { + assertMemoryLeak(() -> assertThrows("already configured", + () -> Sender.builder(Sender.Transport.TCP).bufferCapacity(1024).bufferCapacity(1024))); + } + + @Test + public void testConfStringValidation() throws Exception { + assertMemoryLeak(() -> { + assertConfStrError("foo", "invalid schema [schema=foo, supported-schemas=[http, https, tcp, tcps, ws, wss]]"); + assertConfStrError("badschema::addr=bar;", "invalid schema [schema=badschema, supported-schemas=[http, https, tcp, tcps, ws, wss]]"); + assertConfStrError("http::addr=localhost:-1;", "invalid port [port=-1]"); + assertConfStrError("http::auto_flush=on;", "addr is missing"); + assertConfStrError("http::addr=localhost;tls_roots=/some/path;", "tls_roots was configured, but tls_roots_password is missing"); + assertConfStrError("http::addr=localhost;tls_roots_password=hunter123;", "tls_roots_password was configured, but tls_roots is missing"); + assertConfStrError("tcp::addr=localhost;user=foo;", "token cannot be empty nor null"); + assertConfStrError("tcp::addr=localhost;username=foo;", "token cannot be empty nor null"); + assertConfStrError("tcp::addr=localhost;token=foo;", "TCP token is configured, but user is missing"); + assertConfStrError("http::addr=localhost;user=foo;", "password cannot be empty nor null"); + assertConfStrError("http::addr=localhost;username=foo;", "password cannot be empty nor null"); + assertConfStrError("http::addr=localhost;pass=foo;", "HTTP password is configured, but username is missing"); + assertConfStrError("http::addr=localhost;password=foo;", "HTTP password is configured, but username is missing"); + assertConfStrError("tcp::addr=localhost;pass=foo;", "password is not supported for TCP protocol"); + assertConfStrError("tcp::addr=localhost;password=foo;", "password is not supported for TCP protocol"); + assertConfStrError("tcp::addr=localhost;retry_timeout=;", "retry_timeout cannot be empty"); + assertConfStrError("tcp::addr=localhost;max_buf_size=;", "max_buf_size cannot be empty"); + assertConfStrError("tcp::addr=localhost;init_buf_size=;", "init_buf_size cannot be empty"); + assertConfStrError("http::addr=localhost:8080;tls_verify=unsafe_off;", "TLS validation disabled, but TLS was not enabled"); + assertConfStrError("http::addr=localhost:8080;tls_verify=bad;", "invalid tls_verify [value=bad, allowed-values=[on, unsafe_off]]"); + assertConfStrError("tcps::addr=localhost;pass=unsafe_off;", "password is not supported for TCP protocol"); + assertConfStrError("tcps::addr=localhost;password=unsafe_off;", "password is not supported for TCP protocol"); + assertConfStrError("http::addr=localhost:8080;max_buf_size=-32;", "maximum buffer capacity cannot be less than initial buffer capacity [maximumBufferCapacity=-32, initialBufferCapacity=65536]"); + assertConfStrError("http::addr=localhost:8080;max_buf_size=notanumber;", "invalid max_buf_size [value=notanumber]"); + assertConfStrError("http::addr=localhost:8080;init_buf_size=notanumber;", "invalid init_buf_size [value=notanumber]"); + assertConfStrError("http::addr=localhost:8080;init_buf_size=-42;", "buffer capacity cannot be negative [capacity=-42]"); + assertConfStrError("http::addr=localhost:8080;auto_flush_rows=0;", "invalid auto_flush_rows [value=0]"); + assertConfStrError("http::addr=localhost:8080;auto_flush_rows=notanumber;", "invalid auto_flush_rows [value=notanumber]"); + assertConfStrError("http::addr=localhost:8080;auto_flush=invalid;", "invalid auto_flush [value=invalid, allowed-values=[on, off]]"); + assertConfStrError("http::addr=localhost:8080;auto_flush=off;auto_flush_rows=100;", "cannot set auto flush rows when auto-flush is already disabled"); + assertConfStrError("http::addr=localhost:8080;auto_flush_rows=100;auto_flush=off;", "auto flush rows was already configured [autoFlushRows=100]"); + assertConfStrError("HTTP::addr=localhost;", "invalid schema [schema=HTTP, supported-schemas=[http, https, tcp, tcps, ws, wss]]"); + assertConfStrError("HTTPS::addr=localhost;", "invalid schema [schema=HTTPS, supported-schemas=[http, https, tcp, tcps, ws, wss]]"); + assertConfStrError("TCP::addr=localhost;", "invalid schema [schema=TCP, supported-schemas=[http, https, tcp, tcps, ws, wss]]"); + assertConfStrError("TCPS::addr=localhost;", "invalid schema [schema=TCPS, supported-schemas=[http, https, tcp, tcps, ws, wss]]"); + assertConfStrError("http::addr=localhost;auto_flush=off;auto_flush_interval=1;", "cannot set auto flush interval when interval based auto-flush is already disabled"); + assertConfStrError("http::addr=localhost;auto_flush=off;auto_flush_rows=1;", "cannot set auto flush rows when auto-flush is already disabled"); + assertConfStrError("http::addr=localhost;auto_flush_bytes=1024;", "auto_flush_bytes is only supported for TCP transport"); + assertConfStrError("http::addr=localhost;protocol_version=10", "current client only supports protocol version 1(text format for all datatypes), 2(binary format for part datatypes), 3(decimal datatype) or explicitly unset"); + assertConfStrError("http::addr=localhost:48884;max_name_len=10;", "max_name_len must be at least 16 bytes [max_name_len=10]"); + assertConfStrError("ws::addr=localhost;token=foo;", "token is not supported for WebSocket protocol"); + assertConfStrError("wss::addr=localhost;token=foo;", "token is not supported for WebSocket protocol"); + + assertConfStrOk("addr=localhost:8080", "auto_flush_rows=100", "protocol_version=1"); + assertConfStrOk("addr=localhost:8080", "auto_flush=on", "auto_flush_rows=100", "protocol_version=2"); + assertConfStrOk("addr=localhost:8080", "auto_flush_rows=100", "auto_flush=on", "protocol_version=2"); + assertConfStrOk("addr=localhost", "auto_flush=on", "protocol_version=2"); + assertConfStrOk("addr=localhost:8080", "max_name_len=1024", "protocol_version=2"); + + assertConfStrError("tcp::addr=localhost;auto_flush_bytes=1024;init_buf_size=2048;", "TCP transport requires init_buf_size and auto_flush_bytes to be set to the same value [init_buf_size=2048, auto_flush_bytes=1024]"); + assertConfStrError("tcp::addr=localhost;init_buf_size=1024;auto_flush_bytes=2048;", "TCP transport requires init_buf_size and auto_flush_bytes to be set to the same value [init_buf_size=1024, auto_flush_bytes=2048]"); + assertConfStrError("tcp::addr=localhost;auto_flush_bytes=off;", "TCP transport must have auto_flush_bytes enabled"); + + assertConfStrOk("http::addr=localhost;auto_flush=off;protocol_version=2;"); + assertConfStrOk("http::addr=localhost;protocol_version=2;"); + assertConfStrOk("http::addr=localhost;auto_flush_interval=off;protocol_version=2;"); + assertConfStrOk("http::addr=localhost;auto_flush_rows=off;protocol_version=2;"); + assertConfStrOk("http::addr=localhost;auto_flush_interval=off;auto_flush_rows=off;protocol_version=1;"); + assertConfStrOk("http::addr=localhost;auto_flush_interval=off;auto_flush_rows=1;protocol_version=2;"); + assertConfStrOk("http::addr=localhost;auto_flush_rows=off;auto_flush_interval=1;protocol_version=2;"); + assertConfStrOk("http::addr=localhost;auto_flush_interval=off;auto_flush_rows=off;auto_flush=off;protocol_version=2;"); + assertConfStrOk("http::addr=localhost;auto_flush=off;auto_flush_interval=off;auto_flush_rows=off;protocol_version=1;"); + assertConfStrOk("http::addr=localhost:8080;protocol_version=2;"); + assertConfStrOk("http::addr=localhost:8080;token=foo;protocol_version=2;"); + assertConfStrOk("http::addr=localhost:8080;token=foo=bar;protocol_version=2;"); + assertConfStrOk("addr=localhost:8080", "token=foo", "retry_timeout=1000", "max_buf_size=1000000", "protocol_version=2"); + assertConfStrOk("addr=localhost:8080", "token=foo", "retry_timeout=1000", "max_buf_size=1000000", "protocol_version=1"); + assertConfStrOk("http::addr=localhost:8080;token=foo;max_buf_size=1000000;retry_timeout=1000;protocol_version=2;"); + assertConfStrOk("https::addr=localhost:8080;tls_verify=unsafe_off;auto_flush_rows=100;protocol_version=2;"); + assertConfStrOk("https::addr=localhost:8080;tls_verify=unsafe_off;auto_flush_rows=100;protocol_version=2;max_name_len=256;"); + assertConfStrOk("https::addr=localhost:8080;tls_verify=on;protocol_version=2;"); + assertConfStrOk("https::addr=localhost:8080;tls_verify=on;protocol_version=3;"); + assertConfStrError("https::addr=2001:0db8:85a3:0000:0000:8a2e:0370:7334;tls_verify=on;", "cannot parse a port from the address, use IPv4 address or a domain name [address=2001:0db8:85a3:0000:0000:8a2e:0370:7334]"); + assertConfStrError("https::addr=[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:9000;tls_verify=on;", "cannot parse a port from the address, use IPv4 address or a domain name [address=[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:9000]"); + }); + } + + @Test + public void testCustomTruststoreButTlsNotEnabled() throws Exception { + assertMemoryLeak(() -> assertThrows("TLS was not enabled", + Sender.builder(Sender.Transport.TCP) + .advancedTls().customTrustStore(TRUSTSTORE_PATH, TRUSTSTORE_PASSWORD) + .address(LOCALHOST))); + } + + @Test + public void testCustomTruststoreDoubleSet() throws Exception { + assertMemoryLeak(() -> assertThrows("already configured", + () -> Sender.builder(Sender.Transport.TCP) + .advancedTls().customTrustStore(TRUSTSTORE_PATH, TRUSTSTORE_PASSWORD) + .advancedTls().customTrustStore(TRUSTSTORE_PATH, TRUSTSTORE_PASSWORD))); + } + + @Test + public void testCustomTruststorePasswordCannotBeNull() { + assertThrows("trust store password cannot be null", + () -> Sender.builder(Sender.Transport.TCP).advancedTls().customTrustStore(TRUSTSTORE_PATH, null)); + } + + @Test + public void testCustomTruststorePathCannotBeBlank() { + assertThrows("trust store path cannot be empty nor null", + () -> Sender.builder(Sender.Transport.TCP).advancedTls().customTrustStore("", TRUSTSTORE_PASSWORD)); + assertThrows("trust store path cannot be empty nor null", + () -> Sender.builder(Sender.Transport.TCP).advancedTls().customTrustStore(null, TRUSTSTORE_PASSWORD)); + } + + @Test + public void testDisableAutoFlushNotSupportedForTcp() throws Exception { + assertMemoryLeak(() -> assertThrows("auto-flush is not supported for TCP protocol", + Sender.builder(Sender.Transport.TCP).address(LOCALHOST).disableAutoFlush())); + } + + @Test + public void testDnsResolutionFail() throws Exception { + assertMemoryLeak(() -> assertThrows("could not resolve", + Sender.builder(Sender.Transport.TCP).address("this-domain-does-not-exist-i-hope-better-to-use-a-silly-tld.silly-tld"))); + } + + @Test + public void testDuplicatedAddresses() throws Exception { + assertMemoryLeak(() -> { + assertThrows("duplicated addresses are not allowed [address=localhost:9000]", + () -> Sender.builder(Sender.Transport.TCP).address("localhost:9000").address("localhost:9000")); + assertThrows("duplicated addresses are not allowed [address=localhost:9000]", + () -> Sender.builder(Sender.Transport.TCP).address("localhost:9000").address("localhost:9001").address("localhost:9000")); + }); + } + + @Test + public void testDuplicatedAddressesWithDifferentPortsAllowed() throws Exception { + assertMemoryLeak(() -> Sender.builder(Sender.Transport.TCP).address("localhost:9000").address("localhost:9001")); + } + + @Test + public void testDuplicatedAddressesWithNoPortsAllowed() throws Exception { + assertMemoryLeak(() -> { + Sender.builder(Sender.Transport.TCP).address("localhost:9000").address("localhost"); + Sender.builder(Sender.Transport.TCP).address("localhost").address("localhost:9000"); + }); + } + + @Test + public void testFailFastWhenSetCustomTrustStoreTwice() { + assertThrows("already configured", + () -> Sender.builder(Sender.Transport.TCP) + .advancedTls().customTrustStore(TRUSTSTORE_PATH, TRUSTSTORE_PASSWORD) + .advancedTls().customTrustStore(TRUSTSTORE_PATH, TRUSTSTORE_PASSWORD)); + } + + @Test + public void testFirstTlsValidationDisabledThenCustomTruststore() throws Exception { + assertMemoryLeak(() -> assertThrows("TLS validation was already disabled", + () -> Sender.builder(Sender.Transport.TCP) + .advancedTls().disableCertificateValidation() + .advancedTls().customTrustStore(TRUSTSTORE_PATH, TRUSTSTORE_PASSWORD))); + } + + @Test + public void testHostNorAddressSet() throws Exception { + assertMemoryLeak(() -> assertThrows("server address not set", + Sender.builder(Sender.Transport.TCP))); + } + + @Test + public void testHttpTokenNotSupportedForTcp() throws Exception { + assertMemoryLeak(() -> assertThrows("HTTP token authentication is not supported for TCP protocol", + Sender.builder(Sender.Transport.TCP).address(LOCALHOST).httpToken("foo"))); + } + + @Test + public void testInvalidHttpTimeout() throws Exception { + assertMemoryLeak(() -> { + assertThrows("HTTP timeout must be positive [timeout=0]", + () -> Sender.builder(Sender.Transport.HTTP).address("someurl").httpTimeoutMillis(0)); + assertThrows("HTTP timeout must be positive [timeout=-1]", + () -> Sender.builder(Sender.Transport.HTTP).address("someurl").httpTimeoutMillis(-1)); + assertThrows("HTTP timeout was already configured [timeout=100]", + () -> Sender.builder(Sender.Transport.HTTP).address("someurl").httpTimeoutMillis(100).httpTimeoutMillis(200)); + assertThrows("HTTP timeout is not supported for TCP protocol", + Sender.builder(Sender.Transport.TCP).address("localhost").httpTimeoutMillis(5000)); + }); + } + + @Test + public void testInvalidRetryTimeout() { + assertThrows("retry timeout cannot be negative [retryTimeoutMillis=-1]", + () -> Sender.builder(Sender.Transport.HTTP).retryTimeoutMillis(-1)); + assertThrows("retry timeout was already configured [retryTimeoutMillis=100]", + () -> Sender.builder(Sender.Transport.HTTP).retryTimeoutMillis(100).retryTimeoutMillis(200)); + } + + @Test + public void testMalformedPortInAddress() throws Exception { + assertMemoryLeak(() -> assertThrows("cannot parse a port from the address", + () -> Sender.builder(Sender.Transport.TCP).address("foo:nonsense12334"))); + } + + @Test + public void testMaxRequestBufferSizeCannotBeLessThanDefault() throws Exception { + assertMemoryLeak(() -> assertThrows("maximum buffer capacity cannot be less than initial buffer capacity [maximumBufferCapacity=65535, initialBufferCapacity=65536]", + () -> Sender.builder(Sender.Transport.HTTP).address("localhost:1").maxBufferCapacity(65535))); + } + + @Test + public void testMaxRequestBufferSizeCannotBeLessThanInitialBufferSize() throws Exception { + assertMemoryLeak(() -> assertThrows("maximum buffer capacity cannot be less than initial buffer capacity [maximumBufferCapacity=100000, initialBufferCapacity=200000]", + Sender.builder(Sender.Transport.HTTP).address("localhost:1").maxBufferCapacity(100_000).bufferCapacity(200_000))); + } + + @Test + public void testMaxRetriesNotSupportedForTcp() throws Exception { + assertMemoryLeak(() -> assertThrows("retrying is not supported for TCP protocol", + () -> Sender.builder(Sender.Transport.TCP).address(LOCALHOST).retryTimeoutMillis(100))); + } + + @Test + public void testMinRequestThroughputCannotBeNegative() throws Exception { + assertMemoryLeak(() -> assertThrows("minimum request throughput must not be negative [minRequestThroughput=-100]", + () -> Sender.builder(Sender.Transport.HTTP).address(LOCALHOST).minRequestThroughput(-100))); + } + + @Test + public void testMinRequestThroughputNotSupportedForTcp() throws Exception { + assertMemoryLeak(() -> assertThrows("minimum request throughput is not supported for TCP protocol", + Sender.builder(Sender.Transport.TCP).address(LOCALHOST).minRequestThroughput(1))); + } + + @Test + public void testPlainAuth_connectionRefused() throws Exception { + assertMemoryLeak(() -> assertThrows("could not connect", + Sender.builder(Sender.Transport.TCP) + .enableAuth("foo").authToken(AUTH_TOKEN_KEY1).address(LOCALHOST + ":19003"))); + } + + @Test + public void testPlainOldTokenNotSupportedForHttpProtocol() throws Exception { + assertMemoryLeak(() -> assertThrows("old token authentication is not supported for HTTP protocol", + Sender.builder(Sender.Transport.HTTP).address("localhost:9000").enableAuth("key").authToken(AUTH_TOKEN_KEY1))); + } + + @Test + public void testPlain_connectionRefused() throws Exception { + assertMemoryLeak(() -> assertThrows("could not connect", + Sender.builder(Sender.Transport.TCP).address(LOCALHOST + ":19003"))); + } + + @Test + public void testPortDoubleSet_firstAddressThenPort() throws Exception { + assertMemoryLeak(() -> assertThrows("mismatch", + Sender.builder(Sender.Transport.TCP).address(LOCALHOST + ":9000").port(9000))); + } + + @Test + public void testPortDoubleSet_firstPortThenAddress() throws Exception { + assertMemoryLeak(() -> assertThrows("mismatch", + Sender.builder(Sender.Transport.TCP).port(9000).address(LOCALHOST + ":9000"))); + } + + @Test + public void testPortDoubleSet_firstPortThenPort() throws Exception { + assertMemoryLeak(() -> assertThrows("questdb server address not set", + Sender.builder(Sender.Transport.TCP).port(9000).port(9000))); + } + + @Test + public void testSmallMaxNameLen() throws Exception { + assertMemoryLeak(() -> assertThrows("max_name_len must be at least 16 bytes [max_name_len=10]", + () -> Sender.builder(Sender.Transport.TCP).maxNameLength(10))); + } + + @Test + public void testTlsDoubleSet() throws Exception { + assertMemoryLeak(() -> assertThrows("already enabled", + () -> Sender.builder(Sender.Transport.TCP).enableTls().enableTls())); + } + + @Test + public void testTlsValidationDisabledButTlsNotEnabled() throws Exception { + assertMemoryLeak(() -> assertThrows("TLS was not enabled", + Sender.builder(Sender.Transport.TCP) + .advancedTls().disableCertificateValidation() + .address(LOCALHOST))); + } + + @Test + public void testTlsValidationDisabledDoubleSet() throws Exception { + assertMemoryLeak(() -> assertThrows("TLS validation was already disabled", + () -> Sender.builder(Sender.Transport.TCP) + .advancedTls().disableCertificateValidation() + .advancedTls().disableCertificateValidation())); + } + + @Test + public void testTls_connectionRefused() throws Exception { + assertMemoryLeak(() -> assertThrows("could not connect", + Sender.builder(Sender.Transport.TCP).enableTls().address(LOCALHOST + ":19003"))); + } + + @Test + public void testUsernamePasswordAuthNotSupportedForTcp() throws Exception { + assertMemoryLeak(() -> assertThrows("username/password authentication is not supported for TCP protocol", + Sender.builder(Sender.Transport.TCP).address(LOCALHOST).httpUsernamePassword("foo", "bar"))); + } + + private static void assertConfStrError(String conf, String expectedError) { + try { + try (Sender ignored = Sender.fromConfig(conf)) { + fail("should fail with bad conf string"); + } + } catch (LineSenderException e) { + TestUtils.assertContains(e.getMessage(), expectedError); + } + } + + private static void assertConfStrOk(String... params) { + StringBuilder sb = new StringBuilder(); + sb.append("http").append("::"); + shuffle(params); + for (int i = 0; i < params.length; i++) { + sb.append(params[i]).append(";"); + } + assertConfStrOk(sb.toString()); + } + + private static void assertConfStrOk(String conf) { + Sender.fromConfig(conf).close(); + } + + private static void assertThrows(String expectedSubstring, Sender.LineSenderBuilder builder) { + assertThrows(expectedSubstring, builder::build); + } + + private static void assertThrows(String expectedSubstring, Runnable action) { + try { + action.run(); + fail("Expected LineSenderException containing '" + expectedSubstring + "'"); + } catch (LineSenderException e) { + TestUtils.assertContains(e.getMessage(), expectedSubstring); + } + } + + private static void shuffle(String[] input) { + for (int i = 0; i < input.length; i++) { + int j = (int) (Math.random() * input.length); + String tmp = input[i]; + input[i] = input[j]; + input[j] = tmp; + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/line/tcp/PlainTcpLineChannelTest.java b/core/src/test/java/io/questdb/client/test/cutlass/line/tcp/PlainTcpLineChannelTest.java index 712cf72..f95f8ab 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/line/tcp/PlainTcpLineChannelTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/line/tcp/PlainTcpLineChannelTest.java @@ -28,7 +28,7 @@ import io.questdb.client.cutlass.line.tcp.PlainTcpLineChannel; import io.questdb.client.network.NetworkFacade; import io.questdb.client.network.NetworkFacadeImpl; -import io.questdb.client.test.tools.TestUtils; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; import org.junit.Test; import static org.junit.Assert.fail; @@ -44,7 +44,7 @@ public int socketTcp(boolean blocking) { @Test public void testConstructorLeak_Hostname_CannotConnect() throws Exception { NetworkFacade nf = NetworkFacadeImpl.INSTANCE; - TestUtils.assertMemoryLeak(() -> { + assertMemoryLeak(() -> { try { new PlainTcpLineChannel(nf, "localhost", 1000, 1000); fail("there should be nothing listening on the port 1000, the channel should have failed to connect"); @@ -57,7 +57,7 @@ public void testConstructorLeak_Hostname_CannotConnect() throws Exception { @Test public void testConstructorLeak_Hostname_CannotResolveHost() throws Exception { NetworkFacade nf = NetworkFacadeImpl.INSTANCE; - TestUtils.assertMemoryLeak(() -> { + assertMemoryLeak(() -> { try { new PlainTcpLineChannel(nf, "nonsense-fails-to-resolve", 1000, 1000); fail("the host should not resolved and the channel should have failed to connect"); @@ -69,7 +69,7 @@ public void testConstructorLeak_Hostname_CannotResolveHost() throws Exception { @Test public void testConstructorLeak_Hostname_DescriptorsExhausted() throws Exception { - TestUtils.assertMemoryLeak(() -> { + assertMemoryLeak(() -> { try { new PlainTcpLineChannel(FD_EXHAUSTED_NET_FACADE, "localhost", 1000, 1000); fail("the channel should fail to instantiate when NF fails to create a new socket"); @@ -82,7 +82,7 @@ public void testConstructorLeak_Hostname_DescriptorsExhausted() throws Exception @Test public void testConstructorLeak_IP_CannotConnect() throws Exception { NetworkFacade nf = NetworkFacadeImpl.INSTANCE; - TestUtils.assertMemoryLeak(() -> { + assertMemoryLeak(() -> { try { new PlainTcpLineChannel(nf, -1, 1000, 1000); fail("the channel should have failed to connect to address -1"); @@ -94,7 +94,7 @@ public void testConstructorLeak_IP_CannotConnect() throws Exception { @Test public void testConstructorLeak_IP_DescriptorsExhausted() throws Exception { - TestUtils.assertMemoryLeak(() -> { + assertMemoryLeak(() -> { try { new PlainTcpLineChannel(FD_EXHAUSTED_NET_FACADE, -1, 1000, 1000); fail("the channel should fail to instantiate when NF fails to create a new socket"); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/line/tcp/v4/QwpAllocationTestClient.java b/core/src/test/java/io/questdb/client/test/cutlass/line/tcp/v4/QwpAllocationTestClient.java new file mode 100644 index 0000000..46c0a9c --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/line/tcp/v4/QwpAllocationTestClient.java @@ -0,0 +1,369 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.line.tcp.v4; + +import io.questdb.client.Sender; + +import java.io.IOException; +import java.time.temporal.ChronoUnit; +import java.util.concurrent.TimeUnit; + +/** + * Test client for ILP allocation profiling. + *

    + * Supports 3 protocol modes: + *

      + *
    • ilp-tcp: Old ILP text protocol over TCP (port 9009)
    • + *
    • ilp-http: Old ILP text protocol over HTTP (port 9000)
    • + *
    • qwp-websocket: New QWP binary protocol over WebSocket (port 9000)
    • + *
    + *

    + * Sends rows with various column types to exercise all code paths. + * Run with an allocation profiler (async-profiler, JFR, etc.) to find hotspots. + *

    + * Usage: + *

    + * java -cp ... QwpAllocationTestClient [options]
    + *
    + * Options:
    + *   --protocol=PROTOCOL   Protocol: ilp-tcp, ilp-http, qwp-websocket (default: qwp-websocket)
    + *   --host=HOST           Server host (default: localhost)
    + *   --port=PORT           Server port (default: 9009 for TCP, 9000 for HTTP)
    + *   --rows=N              Total rows to send (default: 10000000)
    + *   --batch=N             Batch/flush size (default: 10000)
    + *   --warmup=N            Warmup rows (default: 100000)
    + *   --report=N            Report progress every N rows (default: 1000000)
    + *   --no-warmup           Skip warmup phase
    + *   --help                Show this help
    + *
    + * Examples:
    + *   QwpAllocationTestClient --protocol=qwp-websocket --rows=1000000 --batch=5000
    + *   QwpAllocationTestClient --protocol=ilp-tcp --host=remote-server --port=9009
    + * 
    + */ +public class QwpAllocationTestClient { + + private static final int DEFAULT_BATCH_SIZE = 10_000; + private static final int DEFAULT_FLUSH_BYTES = 0; // 0 = use protocol default + private static final long DEFAULT_FLUSH_INTERVAL_MS = 0; // 0 = use protocol default + // Default configuration + private static final String DEFAULT_HOST = "localhost"; + private static final int DEFAULT_IN_FLIGHT_WINDOW = 0; // 0 = use protocol default (8) + private static final int DEFAULT_REPORT_INTERVAL = 1_000_000; + private static final int DEFAULT_ROWS = 80_000_000; + private static final int DEFAULT_WARMUP_ROWS = 100_000; + private static final String PROTOCOL_ILP_HTTP = "ilp-http"; + // Protocol modes + private static final String PROTOCOL_ILP_TCP = "ilp-tcp"; + private static final String PROTOCOL_QWP_WEBSOCKET = "qwp-websocket"; + private static final String[] STRINGS = { + "New York", "London", "Tokyo", "Paris", "Berlin", "Sydney", "Toronto", "Singapore", + "Hong Kong", "Dubai", "Mumbai", "Shanghai", "Moscow", "Seoul", "Bangkok", + "Amsterdam", "Zurich", "Frankfurt", "Milan", "Madrid" + }; + // Pre-computed test data to avoid allocation during the test + private static final String[] SYMBOLS = { + "AAPL", "GOOGL", "MSFT", "AMZN", "META", "NVDA", "TSLA", "BRK.A", "JPM", "JNJ", + "V", "PG", "UNH", "HD", "MA", "DIS", "PYPL", "BAC", "ADBE", "CMCSA" + }; + + public static void main(String[] args) { + // Parse command-line options + String protocol = PROTOCOL_QWP_WEBSOCKET; + String host = DEFAULT_HOST; + int port = -1; // -1 means use default for protocol + int totalRows = DEFAULT_ROWS; + int batchSize = DEFAULT_BATCH_SIZE; + int flushBytes = DEFAULT_FLUSH_BYTES; + long flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS; + int inFlightWindow = DEFAULT_IN_FLIGHT_WINDOW; + int warmupRows = DEFAULT_WARMUP_ROWS; + int reportInterval = DEFAULT_REPORT_INTERVAL; + + for (String arg : args) { + if (arg.equals("--help") || arg.equals("-h")) { + printUsage(); + System.exit(0); + } else if (arg.startsWith("--protocol=")) { + protocol = arg.substring("--protocol=".length()).toLowerCase(); + } else if (arg.startsWith("--host=")) { + host = arg.substring("--host=".length()); + } else if (arg.startsWith("--port=")) { + port = Integer.parseInt(arg.substring("--port=".length())); + } else if (arg.startsWith("--rows=")) { + totalRows = Integer.parseInt(arg.substring("--rows=".length())); + } else if (arg.startsWith("--batch=")) { + batchSize = Integer.parseInt(arg.substring("--batch=".length())); + } else if (arg.startsWith("--flush-bytes=")) { + flushBytes = Integer.parseInt(arg.substring("--flush-bytes=".length())); + } else if (arg.startsWith("--flush-interval-ms=")) { + flushIntervalMs = Long.parseLong(arg.substring("--flush-interval-ms=".length())); + } else if (arg.startsWith("--in-flight-window=")) { + inFlightWindow = Integer.parseInt(arg.substring("--in-flight-window=".length())); + } else if (arg.startsWith("--warmup=")) { + warmupRows = Integer.parseInt(arg.substring("--warmup=".length())); + } else if (arg.startsWith("--report=")) { + reportInterval = Integer.parseInt(arg.substring("--report=".length())); + } else if (arg.equals("--no-warmup")) { + warmupRows = 0; + } else if (!arg.startsWith("--")) { + // Legacy positional args: protocol [host] [port] [rows] + protocol = arg.toLowerCase(); + } else { + System.err.println("Unknown option: " + arg); + printUsage(); + System.exit(1); + } + } + + // Use default port if not specified + if (port == -1) { + port = getDefaultPort(protocol); + } + + System.out.println("ILP Allocation Test Client"); + System.out.println("=========================="); + System.out.println("Protocol: " + protocol); + System.out.println("Host: " + host); + System.out.println("Port: " + port); + System.out.println("Total rows: " + String.format("%,d", totalRows)); + System.out.println("Batch size (rows): " + String.format("%,d", batchSize) + (batchSize == 0 ? " (default)" : "")); + System.out.println("Flush bytes: " + (flushBytes == 0 ? "(default)" : String.format("%,d", flushBytes))); + System.out.println("Flush interval: " + (flushIntervalMs == 0 ? "(default)" : flushIntervalMs + " ms")); + System.out.println("In-flight window: " + (inFlightWindow == 0 ? "(default: 8)" : inFlightWindow)); + System.out.println("Warmup rows: " + String.format("%,d", warmupRows)); + System.out.println("Report interval: " + String.format("%,d", reportInterval)); + System.out.println(); + + try { + runTest(protocol, host, port, totalRows, batchSize, flushBytes, flushIntervalMs, + inFlightWindow, warmupRows, reportInterval); + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + e.printStackTrace(System.err); + System.exit(1); + } + } + + private static Sender createSender(String protocol, String host, int port, + int batchSize, int flushBytes, long flushIntervalMs, + int inFlightWindow) { + switch (protocol) { + case PROTOCOL_ILP_TCP: + return Sender.builder(Sender.Transport.TCP) + .address(host) + .port(port) + .build(); + case PROTOCOL_ILP_HTTP: + return Sender.builder(Sender.Transport.HTTP) + .address(host) + .port(port) + .autoFlushRows(batchSize) + .build(); + case PROTOCOL_QWP_WEBSOCKET: + Sender.LineSenderBuilder b = Sender.builder(Sender.Transport.WEBSOCKET) + .address(host) + .port(port) + .asyncMode(true); + if (batchSize > 0) b.autoFlushRows(batchSize); + if (flushBytes > 0) b.autoFlushBytes(flushBytes); + if (flushIntervalMs > 0) b.autoFlushIntervalMillis((int) flushIntervalMs); + if (inFlightWindow > 0) b.inFlightWindowSize(inFlightWindow); + return b.build(); + default: + throw new IllegalArgumentException("Unknown protocol: " + protocol + + ". Use one of: ilp-tcp, ilp-http, qwp-websocket"); + } + } + + /** + * Estimates the size of a single row in bytes for throughput calculation. + */ + private static int estimatedRowSize() { + // Rough estimate (binary protocol): + // - 2 symbols: ~10 bytes each = 20 bytes + // - 3 longs: 8 bytes each = 24 bytes + // - 4 doubles: 8 bytes each = 32 bytes + // - 1 string: ~10 bytes average + // - 1 boolean: 1 byte + // - 2 timestamps: 8 bytes each = 16 bytes + // - Overhead: ~20 bytes + // Total: ~123 bytes + return 123; + } + + private static int getDefaultPort(String protocol) { + if (PROTOCOL_ILP_HTTP.equals(protocol) || PROTOCOL_QWP_WEBSOCKET.equals(protocol)) { + return 9000; + } + return 9009; + } + + private static void printUsage() { + System.out.println("ILP Allocation Test Client"); + System.out.println(); + System.out.println("Usage: QwpAllocationTestClient [options]"); + System.out.println(); + System.out.println("Options:"); + System.out.println(" --protocol=PROTOCOL Protocol to use (default: qwp-websocket)"); + System.out.println(" --host=HOST Server host (default: localhost)"); + System.out.println(" --port=PORT Server port (default: 9009 for TCP, 9000 for HTTP/WebSocket)"); + System.out.println(" --rows=N Total rows to send (default: 80000000)"); + System.out.println(" --batch=N Auto-flush after N rows (default: 10000)"); + System.out.println(" --flush-bytes=N Auto-flush after N bytes (default: protocol default)"); + System.out.println(" --flush-interval-ms=N Auto-flush after N ms (default: protocol default)"); + System.out.println(" --in-flight-window=N Max batches awaiting server ACK (default: 8, WebSocket only)"); + System.out.println(" --send-queue=N Max batches waiting to send (default: 16, WebSocket only)"); + System.out.println(" --warmup=N Warmup rows (default: 100000)"); + System.out.println(" --report=N Report progress every N rows (default: 1000000)"); + System.out.println(" --no-warmup Skip warmup phase"); + System.out.println(" --help Show this help"); + System.out.println(); + System.out.println("Protocols:"); + System.out.println(" ilp-tcp Old ILP text protocol over TCP (default port: 9009)"); + System.out.println(" ilp-http Old ILP text protocol over HTTP (default port: 9000)"); + System.out.println(" qwp-websocket New QWP binary protocol over WebSocket (default port: 9000)"); + System.out.println(); + System.out.println("Examples:"); + System.out.println(" QwpAllocationTestClient --protocol=qwp-websocket --rows=1000000 --batch=5000"); + System.out.println(" QwpAllocationTestClient --protocol=ilp-tcp --host=remote-server"); + System.out.println(" QwpAllocationTestClient --protocol=ilp-tcp --rows=100000 --no-warmup"); + } + + private static void runTest(String protocol, String host, int port, int totalRows, + int batchSize, int flushBytes, long flushIntervalMs, + int inFlightWindow, + int warmupRows, int reportInterval) throws IOException { + System.out.println("Connecting to " + host + ":" + port + "..."); + + try (Sender sender = createSender(protocol, host, port, batchSize, flushBytes, flushIntervalMs, + inFlightWindow)) { + System.out.println("Connected! Protocol: " + protocol); + System.out.println(); + + // Warm-up phase + if (warmupRows > 0) { + System.out.println("Warming up (" + String.format("%,d", warmupRows) + " rows)..."); + long warmupStart = System.nanoTime(); + for (int i = 0; i < warmupRows; i++) { + sendRow(sender, i); + } + sender.flush(); + long warmupTime = System.nanoTime() - warmupStart; + System.out.println("Warmup complete in " + TimeUnit.NANOSECONDS.toMillis(warmupTime) + " ms"); + System.out.println(); + + // Give GC a chance to clean up warmup allocations + System.gc(); + Thread.sleep(100); + } + + // Main test phase + System.out.println("Starting main test (" + String.format("%,d", totalRows) + " rows)..."); + if (reportInterval > 0 && reportInterval <= totalRows) { + System.out.println("Progress will be reported every " + String.format("%,d", reportInterval) + " rows"); + } + System.out.println(); + + long startTime = System.nanoTime(); + long lastReportTime = startTime; + int lastReportRows = 0; + + for (int i = 0; i < totalRows; i++) { + sendRow(sender, i); + + // Report progress + if (reportInterval > 0 && (i + 1) % reportInterval == 0) { + long now = System.nanoTime(); + long elapsedSinceReport = now - lastReportTime; + int rowsSinceReport = (i + 1) - lastReportRows; + double rowsPerSec = rowsSinceReport / (elapsedSinceReport / 1_000_000_000.0); + + System.out.printf("Progress: %,d / %,d rows (%.1f%%) - %.0f rows/sec%n", + i + 1, totalRows, + (i + 1) * 100.0 / totalRows, + rowsPerSec); + + lastReportTime = now; + lastReportRows = i + 1; + } + } + + // Final flush + sender.flush(); + + long endTime = System.nanoTime(); + long totalTime = endTime - startTime; + double totalSeconds = totalTime / 1_000_000_000.0; + double rowsPerSecond = totalRows / totalSeconds; + + System.out.println(); + System.out.println("Test Complete!"); + System.out.println("=============="); + System.out.println("Protocol: " + protocol); + System.out.println("Total rows: " + String.format("%,d", totalRows)); + System.out.println("Batch size: " + String.format("%,d", batchSize)); + System.out.println("Total time: " + String.format("%.2f", totalSeconds) + " seconds"); + System.out.println("Throughput: " + String.format("%,.0f", rowsPerSecond) + " rows/second"); + System.out.println("Data rate (before compression): " + String.format("%.2f", ((long) totalRows * estimatedRowSize()) / (1024.0 * 1024.0 * totalSeconds)) + " MB/s (estimated)"); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted", e); + } + } + + private static void sendRow(Sender sender, int rowIndex) { + // Base timestamp with small variations + long baseTimestamp = 1704067200000000L; // 2024-01-01 00:00:00 UTC in micros + long timestamp = baseTimestamp + (rowIndex * 1000L) + (rowIndex % 100); + + sender.table("ilp_alloc_test") + // Symbol columns + .symbol("exchange", SYMBOLS[rowIndex % SYMBOLS.length]) + .symbol("currency", rowIndex % 2 == 0 ? "USD" : "EUR") + + // Numeric columns + .longColumn("trade_id", rowIndex) + .longColumn("volume", 100 + (rowIndex % 10000)) + .doubleColumn("price", 100.0 + (rowIndex % 1000) * 0.01) + .doubleColumn("bid", 99.5 + (rowIndex % 1000) * 0.01) + .doubleColumn("ask", 100.5 + (rowIndex % 1000) * 0.01) + .longColumn("sequence", rowIndex % 1000000) + .doubleColumn("spread", 0.5 + (rowIndex % 100) * 0.01) + + // String column + .stringColumn("venue", STRINGS[rowIndex % STRINGS.length]) + + // Boolean column + .boolColumn("is_buy", rowIndex % 2 == 0) + + // Additional timestamp column + .timestampColumn("event_time", timestamp - 1000, ChronoUnit.MICROS) + + // Designated timestamp + .at(timestamp, ChronoUnit.MICROS); + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/line/tcp/v4/StacBenchmarkClient.java b/core/src/test/java/io/questdb/client/test/cutlass/line/tcp/v4/StacBenchmarkClient.java new file mode 100644 index 0000000..1eb044d --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/line/tcp/v4/StacBenchmarkClient.java @@ -0,0 +1,413 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.line.tcp.v4; + +import io.questdb.client.Sender; + +import java.io.IOException; +import java.time.temporal.ChronoUnit; +import java.util.Random; +import java.util.concurrent.TimeUnit; + +/** + * STAC benchmark ingestion test client. + *

    + * Tests ingestion performance for a STAC-like quotes table with this schema: + *

    + * CREATE TABLE q (
    + *     s SYMBOL,     -- 4-letter ticker symbol (8512 unique)
    + *     x CHAR,       -- exchange code
    + *     b FLOAT,      -- bid price
    + *     a FLOAT,      -- ask price
    + *     v SHORT,      -- bid volume
    + *     w SHORT,      -- ask volume
    + *     m BOOLEAN,    -- market flag
    + *     T TIMESTAMP   -- designated timestamp
    + * ) timestamp(T) PARTITION BY DAY WAL;
    + * 
    + *

    + * The table MUST be pre-created before running this test so the server uses + * the correct narrow column types (FLOAT, SHORT, CHAR). Otherwise ILP + * auto-creation would use DOUBLE, LONG, STRING. + *

    + * Supports 3 protocol modes: + *

      + *
    • ilp-tcp: Old ILP text protocol over TCP (port 9009)
    • + *
    • ilp-http: Old ILP text protocol over HTTP (port 9000)
    • + *
    • qwp-websocket: New QWP binary protocol over WebSocket (port 9000)
    • + *
    + */ +public class StacBenchmarkClient { + + private static final int DEFAULT_BATCH_SIZE = 10_000; + private static final int DEFAULT_FLUSH_BYTES = 0; + private static final long DEFAULT_FLUSH_INTERVAL_MS = 0; + private static final String DEFAULT_HOST = "localhost"; + private static final int DEFAULT_IN_FLIGHT_WINDOW = 0; + private static final int DEFAULT_REPORT_INTERVAL = 1_000_000; + private static final int DEFAULT_ROWS = 80_000_000; + private static final String DEFAULT_TABLE = "q"; + private static final int DEFAULT_WARMUP_ROWS = 100_000; + // Estimated row size for throughput calculation: + // - 1 symbol: ~6 bytes (4-char + overhead) + // - 1 char: 2 bytes + // - 2 floats: 4 bytes each = 8 bytes + // - 2 shorts: 2 bytes each = 4 bytes + // - 1 boolean: 1 byte + // - 1 timestamp: 8 bytes + // - overhead: ~10 bytes + // Total: ~39 bytes + private static final int ESTIMATED_ROW_SIZE = 39; + // Exchange codes (single characters) + private static final char[] EXCHANGES = {'N', 'Q', 'A', 'B', 'C', 'D', 'P', 'Z'}; + // Pre-computed single-char strings to avoid allocation + private static final String[] EXCHANGE_STRINGS = new String[EXCHANGES.length]; + private static final String PROTOCOL_ILP_HTTP = "ilp-http"; + private static final String PROTOCOL_ILP_TCP = "ilp-tcp"; + private static final String PROTOCOL_QWP_WEBSOCKET = "qwp-websocket"; + // 8512 unique 4-letter symbols, as per STAC NYSE benchmark + private static final int SYMBOL_COUNT = 8512; + private static final String[] SYMBOLS = generateSymbols(SYMBOL_COUNT); + // Pre-computed bid base prices per symbol (to generate realistic spreads) + private static final float[] BASE_PRICES = generateBasePrices(SYMBOL_COUNT); + + public static void main(String[] args) { + String protocol = PROTOCOL_QWP_WEBSOCKET; + String host = DEFAULT_HOST; + int port = -1; + int totalRows = DEFAULT_ROWS; + int batchSize = DEFAULT_BATCH_SIZE; + int flushBytes = DEFAULT_FLUSH_BYTES; + long flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS; + int inFlightWindow = DEFAULT_IN_FLIGHT_WINDOW; + int warmupRows = DEFAULT_WARMUP_ROWS; + int reportInterval = DEFAULT_REPORT_INTERVAL; + String table = DEFAULT_TABLE; + + for (String arg : args) { + if (arg.equals("--help") || arg.equals("-h")) { + printUsage(); + System.exit(0); + } else if (arg.startsWith("--protocol=")) { + protocol = arg.substring("--protocol=".length()).toLowerCase(); + } else if (arg.startsWith("--host=")) { + host = arg.substring("--host=".length()); + } else if (arg.startsWith("--port=")) { + port = Integer.parseInt(arg.substring("--port=".length())); + } else if (arg.startsWith("--rows=")) { + totalRows = Integer.parseInt(arg.substring("--rows=".length())); + } else if (arg.startsWith("--batch=")) { + batchSize = Integer.parseInt(arg.substring("--batch=".length())); + } else if (arg.startsWith("--flush-bytes=")) { + flushBytes = Integer.parseInt(arg.substring("--flush-bytes=".length())); + } else if (arg.startsWith("--flush-interval-ms=")) { + flushIntervalMs = Long.parseLong(arg.substring("--flush-interval-ms=".length())); + } else if (arg.startsWith("--in-flight-window=")) { + inFlightWindow = Integer.parseInt(arg.substring("--in-flight-window=".length())); + } else if (arg.startsWith("--warmup=")) { + warmupRows = Integer.parseInt(arg.substring("--warmup=".length())); + } else if (arg.startsWith("--report=")) { + reportInterval = Integer.parseInt(arg.substring("--report=".length())); + } else if (arg.startsWith("--table=")) { + table = arg.substring("--table=".length()); + } else if (arg.equals("--no-warmup")) { + warmupRows = 0; + } else { + System.err.println("Unknown option: " + arg); + printUsage(); + System.exit(1); + } + } + + if (port == -1) { + port = getDefaultPort(protocol); + } + + System.out.println("STAC Benchmark Ingestion Client"); + System.out.println("================================"); + System.out.println("Protocol: " + protocol); + System.out.println("Host: " + host); + System.out.println("Port: " + port); + System.out.println("Table: " + table); + System.out.println("Total rows: " + String.format("%,d", totalRows)); + System.out.println("Batch size (rows): " + String.format("%,d", batchSize) + (batchSize == 0 ? " (default)" : "")); + System.out.println("Flush bytes: " + (flushBytes == 0 ? "(default)" : String.format("%,d", flushBytes))); + System.out.println("Flush interval: " + (flushIntervalMs == 0 ? "(default)" : flushIntervalMs + " ms")); + System.out.println("In-flight window: " + (inFlightWindow == 0 ? "(default: 8)" : inFlightWindow)); + System.out.println("Warmup rows: " + String.format("%,d", warmupRows)); + System.out.println("Report interval: " + String.format("%,d", reportInterval)); + System.out.println("Symbols: " + String.format("%,d", SYMBOL_COUNT) + " unique 4-letter tickers"); + System.out.println(); + + try { + runTest(protocol, host, port, table, totalRows, batchSize, flushBytes, flushIntervalMs, + inFlightWindow, warmupRows, reportInterval); + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + e.printStackTrace(); + System.exit(1); + } + } + + private static Sender createSender(String protocol, String host, int port, + int batchSize, int flushBytes, long flushIntervalMs, + int inFlightWindow) { + switch (protocol) { + case PROTOCOL_ILP_TCP: + return Sender.builder(Sender.Transport.TCP) + .address(host) + .port(port) + .build(); + case PROTOCOL_ILP_HTTP: + return Sender.builder(Sender.Transport.HTTP) + .address(host) + .port(port) + .autoFlushRows(batchSize) + .build(); + case PROTOCOL_QWP_WEBSOCKET: + Sender.LineSenderBuilder b = Sender.builder(Sender.Transport.WEBSOCKET) + .address(host) + .port(port) + .asyncMode(true); + if (batchSize > 0) b.autoFlushRows(batchSize); + if (flushBytes > 0) b.autoFlushBytes(flushBytes); + if (flushIntervalMs > 0) b.autoFlushIntervalMillis((int) flushIntervalMs); + if (inFlightWindow > 0) b.inFlightWindowSize(inFlightWindow); + return b.build(); + default: + throw new IllegalArgumentException("Unknown protocol: " + protocol + + ". Use one of: ilp-tcp, ilp-http, qwp-websocket"); + } + } + + /** + * Generates pseudo-random base prices for each symbol. + * Prices range from $1 to $500 to simulate realistic stock prices. + */ + private static float[] generateBasePrices(int count) { + float[] prices = new float[count]; + Random rng = new Random(42); // fixed seed for reproducibility + for (int i = 0; i < count; i++) { + prices[i] = 1.0f + rng.nextFloat() * 499.0f; + } + return prices; + } + + /** + * Generates N unique 4-letter symbols. + * Uses combinations of uppercase letters to produce predictable, reproducible symbols. + */ + private static String[] generateSymbols(int count) { + String[] symbols = new String[count]; + int idx = 0; + // 26^4 = 456,976 possible 4-letter combinations, far more than 8512 + outer: + for (char a = 'A'; a <= 'Z' && idx < count; a++) { + for (char b = 'A'; b <= 'Z' && idx < count; b++) { + for (char c = 'A'; c <= 'Z' && idx < count; c++) { + for (char d = 'A'; d <= 'Z' && idx < count; d++) { + symbols[idx++] = new String(new char[]{a, b, c, d}); + if (idx >= count) break outer; + } + } + } + } + return symbols; + } + + private static int getDefaultPort(String protocol) { + if (PROTOCOL_ILP_HTTP.equals(protocol) || PROTOCOL_QWP_WEBSOCKET.equals(protocol)) { + return 9000; + } + return 9009; + } + + private static void printUsage() { + System.out.println("STAC Benchmark Ingestion Client"); + System.out.println(); + System.out.println("Tests ingestion performance for a STAC-like quotes table."); + System.out.println("The table must be pre-created with the correct schema."); + System.out.println(); + System.out.println("Usage: StacBenchmarkClient [options]"); + System.out.println(); + System.out.println("Options:"); + System.out.println(" --protocol=PROTOCOL Protocol to use (default: qwp-websocket)"); + System.out.println(" --host=HOST Server host (default: localhost)"); + System.out.println(" --port=PORT Server port (default: 9009 for TCP, 9000 for HTTP/WebSocket)"); + System.out.println(" --table=TABLE Table name (default: q)"); + System.out.println(" --rows=N Total rows to send (default: 80000000)"); + System.out.println(" --batch=N Auto-flush after N rows (default: 10000)"); + System.out.println(" --flush-bytes=N Auto-flush after N bytes (default: protocol default)"); + System.out.println(" --flush-interval-ms=N Auto-flush after N ms (default: protocol default)"); + System.out.println(" --in-flight-window=N Max batches awaiting server ACK (default: 8, WebSocket only)"); + System.out.println(" --send-queue=N Max batches waiting to send (default: 16, WebSocket only)"); + System.out.println(" --warmup=N Warmup rows (default: 100000)"); + System.out.println(" --report=N Report progress every N rows (default: 1000000)"); + System.out.println(" --no-warmup Skip warmup phase"); + System.out.println(" --help Show this help"); + System.out.println(); + System.out.println("Protocols:"); + System.out.println(" ilp-tcp Old ILP text protocol over TCP (default port: 9009)"); + System.out.println(" ilp-http Old ILP text protocol over HTTP (default port: 9000)"); + System.out.println(" qwp-websocket New QWP binary protocol over WebSocket (default port: 9000)"); + System.out.println(); + System.out.println("Table schema (must be pre-created):"); + System.out.println(" CREATE TABLE q ("); + System.out.println(" s SYMBOL, x CHAR, b FLOAT, a FLOAT,"); + System.out.println(" v SHORT, w SHORT, m BOOLEAN, T TIMESTAMP"); + System.out.println(" ) timestamp(T) PARTITION BY DAY WAL;"); + } + + private static void runTest(String protocol, String host, int port, String table, + int totalRows, int batchSize, int flushBytes, long flushIntervalMs, + int inFlightWindow, + int warmupRows, int reportInterval) throws IOException { + System.out.println("Connecting to " + host + ":" + port + "..."); + + try (Sender sender = createSender(protocol, host, port, batchSize, flushBytes, flushIntervalMs, + inFlightWindow)) { + System.out.println("Connected! Protocol: " + protocol); + System.out.println(); + + // Warmup phase + if (warmupRows > 0) { + System.out.println("Warming up (" + String.format("%,d", warmupRows) + " rows)..."); + long warmupStart = System.nanoTime(); + for (int i = 0; i < warmupRows; i++) { + sendQuoteRow(sender, table, i); + } + sender.flush(); + long warmupTime = System.nanoTime() - warmupStart; + double warmupRowsPerSec = warmupRows / (warmupTime / 1_000_000_000.0); + System.out.printf("Warmup complete in %d ms (%.0f rows/sec)%n", + TimeUnit.NANOSECONDS.toMillis(warmupTime), warmupRowsPerSec); + System.out.println(); + + System.gc(); + Thread.sleep(100); + } + + // Main test phase + System.out.println("Starting main test (" + String.format("%,d", totalRows) + " rows)..."); + if (reportInterval > 0 && reportInterval <= totalRows) { + System.out.println("Progress will be reported every " + String.format("%,d", reportInterval) + " rows"); + } + System.out.println(); + + long startTime = System.nanoTime(); + long lastReportTime = startTime; + int lastReportRows = 0; + + for (int i = 0; i < totalRows; i++) { + sendQuoteRow(sender, table, i); + + if (reportInterval > 0 && (i + 1) % reportInterval == 0) { + long now = System.nanoTime(); + long elapsedSinceReport = now - lastReportTime; + int rowsSinceReport = (i + 1) - lastReportRows; + double rowsPerSec = rowsSinceReport / (elapsedSinceReport / 1_000_000_000.0); + long totalElapsed = now - startTime; + double overallRowsPerSec = (i + 1) / (totalElapsed / 1_000_000_000.0); + + System.out.printf("Progress: %,d / %,d rows (%.1f%%) - %.0f rows/sec (interval) - %.0f rows/sec (overall)%n", + i + 1, totalRows, + (i + 1) * 100.0 / totalRows, + rowsPerSec, overallRowsPerSec); + + lastReportTime = now; + lastReportRows = i + 1; + } + } + + sender.flush(); + + long endTime = System.nanoTime(); + long totalTime = endTime - startTime; + double totalSeconds = totalTime / 1_000_000_000.0; + double rowsPerSecond = totalRows / totalSeconds; + + System.out.println(); + System.out.println("Test Complete!"); + System.out.println("=============="); + System.out.println("Protocol: " + protocol); + System.out.println("Table: " + table); + System.out.println("Total rows: " + String.format("%,d", totalRows)); + System.out.println("Batch size: " + String.format("%,d", batchSize)); + System.out.println("Total time: " + String.format("%.2f", totalSeconds) + " seconds"); + System.out.println("Throughput: " + String.format("%,.0f", rowsPerSecond) + " rows/second"); + System.out.println("Data rate (before compression): " + String.format("%.2f", + ((long) totalRows * ESTIMATED_ROW_SIZE) / (1024.0 * 1024.0 * totalSeconds)) + " MB/s (estimated)"); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted", e); + } + } + + /** + * Sends a single quote row matching the STAC schema. + *

    + * Schema: s SYMBOL, x CHAR, b FLOAT, a FLOAT, v SHORT, w SHORT, m BOOLEAN, T TIMESTAMP + *

    + * The server downcasts doubleColumn->FLOAT, longColumn->SHORT, stringColumn->CHAR + * when the table is pre-created with the correct schema. + */ + private static void sendQuoteRow(Sender sender, String table, int rowIndex) { + int symbolIdx = rowIndex % SYMBOL_COUNT; + int exchangeIdx = rowIndex % EXCHANGES.length; + + // Bid/ask prices: base price with small variation + float basePrice = BASE_PRICES[symbolIdx]; + // Use rowIndex bits for fast pseudo-random variation without Random object + float variation = ((rowIndex * 7 + symbolIdx * 13) % 200 - 100) * 0.01f; + float bid = basePrice + variation; + float ask = bid + 0.01f + (rowIndex % 10) * 0.01f; // spread: 1-10 cents + + // Volumes: 100-32000 range fits SHORT + short bidVol = (short) (100 + ((rowIndex * 3 + symbolIdx) % 31901)); + short askVol = (short) (100 + ((rowIndex * 7 + symbolIdx * 5) % 31901)); + + // Timestamp: 1 day of data with microsecond precision + // 86,400,000,000 micros per day, spread across totalRows + long baseTimestamp = 1704067200000000L; // 2024-01-01 00:00:00 UTC in micros + long timestamp = baseTimestamp + (rowIndex * 10L) + (rowIndex % 7); + + sender.table(table) + .symbol("s", SYMBOLS[symbolIdx]) + .stringColumn("x", EXCHANGE_STRINGS[exchangeIdx]) + .doubleColumn("b", bid) + .doubleColumn("a", ask) + .longColumn("v", bidVol) + .longColumn("w", askVol) + .boolColumn("m", (rowIndex & 1) == 0) + .at(timestamp, ChronoUnit.MICROS); + } + + static { + for (int i = 0; i < EXCHANGES.length; i++) { + EXCHANGE_STRINGS[i] = String.valueOf(EXCHANGES[i]); + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/line/udp/UdpLineChannelTest.java b/core/src/test/java/io/questdb/client/test/cutlass/line/udp/UdpLineChannelTest.java index 95fed98..cf3619f 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/line/udp/UdpLineChannelTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/line/udp/UdpLineChannelTest.java @@ -28,7 +28,7 @@ import io.questdb.client.cutlass.line.udp.UdpLineChannel; import io.questdb.client.network.NetworkFacade; import io.questdb.client.network.NetworkFacadeImpl; -import io.questdb.client.test.tools.TestUtils; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; import org.junit.Test; import static org.junit.Assert.fail; @@ -55,7 +55,7 @@ public int socketUdp() { @Test public void testConstructorLeak_DescriptorsExhausted() throws Exception { - TestUtils.assertMemoryLeak(() -> { + assertMemoryLeak(() -> { try { new UdpLineChannel(FD_EXHAUSTED_NET_FACADE, 1, 1, 9000, 10); fail("the channel should fail to instantiate when NetworkFacade fails to create a new socket"); @@ -67,7 +67,7 @@ public void testConstructorLeak_DescriptorsExhausted() throws Exception { @Test public void testConstructorLeak_FailsToSendInterface() throws Exception { - TestUtils.assertMemoryLeak(() -> { + assertMemoryLeak(() -> { try { new UdpLineChannel(FAILS_TO_SET_MULTICAST_IFACE_NET_FACADE, 1, 1, 9000, 10); fail("the channel should fail to instantiate when NF fails to set multicast interface"); @@ -79,7 +79,7 @@ public void testConstructorLeak_FailsToSendInterface() throws Exception { @Test public void testConstructorLeak_FailsToSetTTL() throws Exception { - TestUtils.assertMemoryLeak(() -> { + assertMemoryLeak(() -> { try { new UdpLineChannel(FAILS_SET_SET_TTL_NET_FACADE, 1, 1, 9000, 10); fail("the channel should fail to instantiate when NF fails to set multicast interface"); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/AsyncModeIntegrationTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/AsyncModeIntegrationTest.java new file mode 100644 index 0000000..a2065a4 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/AsyncModeIntegrationTest.java @@ -0,0 +1,614 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.DefaultHttpClientConfiguration; +import io.questdb.client.cutlass.http.client.WebSocketClient; +import io.questdb.client.cutlass.http.client.WebSocketFrameHandler; +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.cutlass.qwp.client.InFlightWindow; +import io.questdb.client.cutlass.qwp.client.MicrobatchBuffer; +import io.questdb.client.cutlass.qwp.client.WebSocketResponse; +import io.questdb.client.cutlass.qwp.client.WebSocketSendQueue; +import io.questdb.client.network.PlainSocketFactory; +import io.questdb.client.std.MemoryTag; +import io.questdb.client.std.Unsafe; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; +import org.junit.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.*; + +/** + * Integration tests for async mode: double-buffering, send queue, and + * in-flight window working together. + *

    + * These tests verify the interaction between the three async mode components + * ({@link MicrobatchBuffer}, {@link WebSocketSendQueue}, {@link InFlightWindow}) + * without requiring a running QuestDB server. They use {@link FakeWebSocketClient} + * to simulate server behavior and control ACK timing. + */ +public class AsyncModeIntegrationTest { + + /** + * Window of 2. Sends 2 batches (fills window), then enqueues a 3rd to + * occupy the pending slot. The 4th enqueue blocks because the pending + * slot is occupied and the I/O thread cannot poll it (window full). + * Delivering ACKs unblocks the pipeline. + */ + @Test + public void testBackpressureBlocksEnqueueUntilAck() throws Exception { + assertMemoryLeak(() -> { + InFlightWindow window = new InFlightWindow(2, 5_000); + FakeWebSocketClient client = new FakeWebSocketClient(); + AtomicLong highestSent = new AtomicLong(-1); + AtomicLong highestAcked = new AtomicLong(-1); + CountDownLatch twoSent = new CountDownLatch(2); + AtomicBoolean deliverAcks = new AtomicBoolean(false); + + client.setSendBehavior((ptr, len) -> { + highestSent.incrementAndGet(); + twoSent.countDown(); + }); + client.setTryReceiveBehavior(handler -> { + if (deliverAcks.get()) { + long sent = highestSent.get(); + long acked = highestAcked.get(); + if (sent > acked) { + highestAcked.set(sent); + emitAck(handler, sent); + return true; + } + } + return false; + }); + + WebSocketSendQueue queue = null; + MicrobatchBuffer buf0 = new MicrobatchBuffer(256); + MicrobatchBuffer buf1 = new MicrobatchBuffer(256); + + try { + queue = new WebSocketSendQueue(client, window, 3_000, 500); + + // Send 2 batches to fill the window. + buf0.writeByte((byte) 1); + buf0.incrementRowCount(); + buf0.seal(); + queue.enqueue(buf0); + + buf1.writeByte((byte) 2); + buf1.incrementRowCount(); + buf1.seal(); + queue.enqueue(buf1); + + assertTrue("Both batches should be sent", twoSent.await(2, TimeUnit.SECONDS)); + assertEquals("Window should be full", 2, window.getInFlightCount()); + + // Reuse buf0 (recycled by I/O thread) and enqueue a 3rd batch. + // The I/O thread cannot poll it because the window is full. + assertTrue(buf0.awaitRecycled(2, TimeUnit.SECONDS)); + buf0.reset(); + buf0.writeByte((byte) 3); + buf0.incrementRowCount(); + buf0.seal(); + queue.enqueue(buf0); + + // Reuse buf1 and try to enqueue a 4th batch on a background + // thread. It should block because the pending slot is still + // occupied by the 3rd batch. + assertTrue(buf1.awaitRecycled(2, TimeUnit.SECONDS)); + buf1.reset(); + buf1.writeByte((byte) 4); + buf1.incrementRowCount(); + buf1.seal(); + + CountDownLatch enqueueStarted = new CountDownLatch(1); + CountDownLatch enqueueDone = new CountDownLatch(1); + AtomicReference errorRef = new AtomicReference<>(); + WebSocketSendQueue q = queue; + + Thread enqueueThread = new Thread(() -> { + enqueueStarted.countDown(); + try { + q.enqueue(buf1); + } catch (Throwable t) { + errorRef.set(t); + } finally { + enqueueDone.countDown(); + } + }); + enqueueThread.start(); + + assertTrue(enqueueStarted.await(1, TimeUnit.SECONDS)); + Thread.sleep(200); + assertEquals("Enqueue should still be blocked", 1, enqueueDone.getCount()); + + // Deliver ACKs to unblock the pipeline. + deliverAcks.set(true); + + assertTrue("Enqueue should complete after ACK", enqueueDone.await(3, TimeUnit.SECONDS)); + assertNull("No error expected", errorRef.get()); + + queue.flush(); + window.awaitEmpty(); + } finally { + deliverAcks.set(true); + window.acknowledgeUpTo(Long.MAX_VALUE); + closeQuietly(queue); + buf0.close(); + buf1.close(); + client.close(); + } + }); + } + + /** + * Sends 10 batches through 2 alternating buffers with auto-ACK. + * Each buffer cycles through all states multiple times: + * FILLING -> SEALED -> SENDING -> RECYCLED -> FILLING. + */ + @Test + public void testBatchesCycleThroughDoubleBuffers() throws Exception { + assertMemoryLeak(() -> { + InFlightWindow window = new InFlightWindow(4, 5_000); + FakeWebSocketClient client = new FakeWebSocketClient(); + AtomicLong highestSent = new AtomicLong(-1); + AtomicLong highestAcked = new AtomicLong(-1); + + client.setSendBehavior((ptr, len) -> highestSent.incrementAndGet()); + client.setTryReceiveBehavior(handler -> { + long sent = highestSent.get(); + long acked = highestAcked.get(); + if (sent > acked) { + highestAcked.set(sent); + emitAck(handler, sent); + return true; + } + return false; + }); + + WebSocketSendQueue queue = null; + MicrobatchBuffer buf0 = new MicrobatchBuffer(256); + MicrobatchBuffer buf1 = new MicrobatchBuffer(256); + int batchCount = 10; + + try { + queue = new WebSocketSendQueue(client, window, 5_000, 500); + MicrobatchBuffer active = buf0; + + for (int i = 0; i < batchCount; i++) { + if (active.isRecycled()) { + active.reset(); + } + assertTrue("Buffer should be FILLING on iteration " + i, active.isFilling()); + + active.writeByte((byte) (i & 0xFF)); + active.incrementRowCount(); + active.seal(); + queue.enqueue(active); + + // Swap to the other buffer, waiting for it if still in use. + MicrobatchBuffer other = (active == buf0) ? buf1 : buf0; + if (other.isInUse()) { + assertTrue("Other buffer should recycle", + other.awaitRecycled(2, TimeUnit.SECONDS)); + } + if (other.isRecycled()) { + other.reset(); + } + active = other; + } + + queue.flush(); + window.awaitEmpty(); + + assertEquals(batchCount, queue.getTotalBatchesSent()); + assertEquals(0, window.getInFlightCount()); + } finally { + window.acknowledgeUpTo(Long.MAX_VALUE); + closeQuietly(queue); + buf0.close(); + buf1.close(); + client.close(); + } + }); + } + + /** + * The first send blocks in sendBinary (simulating slow I/O). + * The user enqueues a second batch, then tries to swap back to the + * first buffer which is still in SENDING state. The user must wait + * until the I/O thread finishes and recycles the buffer. + */ + @Test + public void testBufferSwapWaitsForSlowSend() throws Exception { + assertMemoryLeak(() -> { + InFlightWindow window = new InFlightWindow(4, 5_000); + FakeWebSocketClient client = new FakeWebSocketClient(); + AtomicLong highestSent = new AtomicLong(-1); + AtomicLong highestAcked = new AtomicLong(-1); + CountDownLatch sendStarted = new CountDownLatch(1); + CountDownLatch sendGate = new CountDownLatch(1); + + client.setSendBehavior((ptr, len) -> { + long seq = highestSent.incrementAndGet(); + if (seq == 0) { + // Block on first send to simulate slow I/O. + sendStarted.countDown(); + try { + if (!sendGate.await(5, TimeUnit.SECONDS)) { + throw new RuntimeException("sendGate timed out"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + }); + client.setTryReceiveBehavior(handler -> { + long sent = highestSent.get(); + long acked = highestAcked.get(); + if (sent > acked) { + highestAcked.set(sent); + emitAck(handler, sent); + return true; + } + return false; + }); + + WebSocketSendQueue queue = null; + MicrobatchBuffer buf0 = new MicrobatchBuffer(256); + MicrobatchBuffer buf1 = new MicrobatchBuffer(256); + + try { + queue = new WebSocketSendQueue(client, window, 5_000, 500); + + // Enqueue buf0. The I/O thread starts sending and blocks. + buf0.writeByte((byte) 1); + buf0.incrementRowCount(); + buf0.seal(); + queue.enqueue(buf0); + + assertTrue("I/O thread should start sending", sendStarted.await(2, TimeUnit.SECONDS)); + assertTrue("buf0 should be in use (SENDING)", buf0.isInUse()); + + // Enqueue buf1 into the pending slot (I/O thread is blocked). + buf1.writeByte((byte) 2); + buf1.incrementRowCount(); + buf1.seal(); + queue.enqueue(buf1); + + // The user wants to reuse buf0, but it is still SENDING. + assertTrue("buf0 should still be in use", buf0.isInUse()); + + // Release the gate so the I/O thread can finish sending buf0. + sendGate.countDown(); + + // buf0 transitions SENDING -> RECYCLED. + assertTrue("buf0 should be recycled after send completes", + buf0.awaitRecycled(2, TimeUnit.SECONDS)); + assertTrue(buf0.isRecycled()); + + // Reset and verify buf0 is reusable. + buf0.reset(); + assertTrue(buf0.isFilling()); + + queue.flush(); + window.awaitEmpty(); + assertEquals(2, queue.getTotalBatchesSent()); + } finally { + sendGate.countDown(); + window.acknowledgeUpTo(Long.MAX_VALUE); + closeQuietly(queue); + buf0.close(); + buf1.close(); + client.close(); + } + }); + } + + /** + * Verifies that {@link WebSocketSendQueue#flush()} returns once the + * batch has been sent over the wire, even though the server has not + * ACKed it yet. The caller must separately call + * {@link InFlightWindow#awaitEmpty()} to wait for the ACK. + */ + @Test + public void testFlushWaitsForSendButNotForAcks() throws Exception { + assertMemoryLeak(() -> { + InFlightWindow window = new InFlightWindow(4, 5_000); + FakeWebSocketClient client = new FakeWebSocketClient(); + AtomicLong highestSent = new AtomicLong(-1); + AtomicBoolean deliverAcks = new AtomicBoolean(false); + + client.setSendBehavior((ptr, len) -> highestSent.incrementAndGet()); + client.setTryReceiveBehavior(handler -> { + if (deliverAcks.get()) { + long sent = highestSent.get(); + if (sent >= 0 && window.getInFlightCount() > 0) { + emitAck(handler, sent); + return true; + } + } + return false; + }); + + WebSocketSendQueue queue = null; + MicrobatchBuffer buf0 = new MicrobatchBuffer(256); + + try { + queue = new WebSocketSendQueue(client, window, 2_000, 500); + + buf0.writeByte((byte) 1); + buf0.incrementRowCount(); + buf0.seal(); + queue.enqueue(buf0); + + // flush() returns once the batch is sent, not when ACKed. + queue.flush(); + assertEquals(1, queue.getTotalBatchesSent()); + assertEquals("Batch should still be in flight", 1, window.getInFlightCount()); + + // Deliver ACK and wait for the window to drain. + deliverAcks.set(true); + window.awaitEmpty(); + assertEquals(0, window.getInFlightCount()); + } finally { + window.acknowledgeUpTo(Long.MAX_VALUE); + closeQuietly(queue); + buf0.close(); + client.close(); + } + }); + } + + /** + * Sends 50 batches through 2 buffers with a window of 4. + * ACKs arrive one-at-a-time (non-cumulative) to test sustained flow + * control under moderate backpressure. + */ + @Test + public void testHighThroughputWithManyBatches() throws Exception { + assertMemoryLeak(() -> { + int batchCount = 50; + int windowSize = 4; + + InFlightWindow window = new InFlightWindow(windowSize, 10_000); + FakeWebSocketClient client = new FakeWebSocketClient(); + AtomicLong highestSent = new AtomicLong(-1); + AtomicLong highestAcked = new AtomicLong(-1); + + client.setSendBehavior((ptr, len) -> highestSent.incrementAndGet()); + client.setTryReceiveBehavior(handler -> { + long sent = highestSent.get(); + long acked = highestAcked.get(); + if (sent > acked) { + // ACK one batch at a time to test sustained flow. + long next = acked + 1; + highestAcked.set(next); + emitAck(handler, next); + return true; + } + return false; + }); + + WebSocketSendQueue queue = null; + MicrobatchBuffer buf0 = new MicrobatchBuffer(256); + MicrobatchBuffer buf1 = new MicrobatchBuffer(256); + + try { + queue = new WebSocketSendQueue(client, window, 10_000, 2_000); + MicrobatchBuffer active = buf0; + + for (int i = 0; i < batchCount; i++) { + if (!active.isFilling()) { + if (active.isInUse()) { + assertTrue("Buffer should recycle on iteration " + i, + active.awaitRecycled(5, TimeUnit.SECONDS)); + } + active.reset(); + } + + active.writeByte((byte) (i & 0xFF)); + active.incrementRowCount(); + active.seal(); + queue.enqueue(active); + + active = (active == buf0) ? buf1 : buf0; + } + + queue.flush(); + window.awaitEmpty(); + + assertEquals(batchCount, queue.getTotalBatchesSent()); + assertEquals(0, window.getInFlightCount()); + } finally { + window.acknowledgeUpTo(Long.MAX_VALUE); + closeQuietly(queue); + buf0.close(); + buf1.close(); + client.close(); + } + }); + } + + /** + * The server ACKs the first batch but returns a WRITE_ERROR for the + * second. {@link WebSocketSendQueue#flush()} completes (both batches + * were sent) but {@link InFlightWindow#awaitEmpty()} surfaces the error. + */ + @Test + public void testServerErrorPropagatesOnFlush() throws Exception { + assertMemoryLeak(() -> { + InFlightWindow window = new InFlightWindow(4, 5_000); + FakeWebSocketClient client = new FakeWebSocketClient(); + AtomicLong highestSent = new AtomicLong(-1); + AtomicLong highestDelivered = new AtomicLong(-1); + + client.setSendBehavior((ptr, len) -> highestSent.incrementAndGet()); + client.setTryReceiveBehavior(handler -> { + long sent = highestSent.get(); + long delivered = highestDelivered.get(); + if (sent > delivered) { + long next = delivered + 1; + highestDelivered.set(next); + if (next == 1) { + emitError(handler, next, WebSocketResponse.STATUS_WRITE_ERROR, "disk full"); + } else { + emitAck(handler, next); + } + return true; + } + return false; + }); + + WebSocketSendQueue queue = null; + MicrobatchBuffer buf0 = new MicrobatchBuffer(256); + MicrobatchBuffer buf1 = new MicrobatchBuffer(256); + + try { + queue = new WebSocketSendQueue(client, window, 2_000, 500); + + buf0.writeByte((byte) 1); + buf0.incrementRowCount(); + buf0.seal(); + queue.enqueue(buf0); + + buf1.writeByte((byte) 2); + buf1.incrementRowCount(); + buf1.seal(); + queue.enqueue(buf1); + + // flush() waits for the queue to drain (both batches sent). + queue.flush(); + + // awaitEmpty() surfaces the server error for batch 1. + try { + window.awaitEmpty(); + fail("Expected server error to propagate"); + } catch (LineSenderException e) { + assertTrue("Error should mention server failure", + e.getMessage().contains("disk full") || e.getMessage().contains("Server error")); + } + } finally { + closeQuietly(queue); + buf0.close(); + buf1.close(); + client.close(); + } + }); + } + + private static void closeQuietly(WebSocketSendQueue queue) { + if (queue != null) { + queue.close(); + } + } + + private static void emitAck(WebSocketFrameHandler handler, long sequence) { + WebSocketResponse resp = WebSocketResponse.success(sequence); + int size = resp.serializedSize(); + long ptr = Unsafe.malloc(size, MemoryTag.NATIVE_DEFAULT); + try { + resp.writeTo(ptr); + handler.onBinaryMessage(ptr, size); + } finally { + Unsafe.free(ptr, size, MemoryTag.NATIVE_DEFAULT); + } + } + + private static void emitError(WebSocketFrameHandler handler, long sequence, byte status, String message) { + WebSocketResponse resp = WebSocketResponse.error(sequence, status, message); + int size = resp.serializedSize(); + long ptr = Unsafe.malloc(size, MemoryTag.NATIVE_DEFAULT); + try { + resp.writeTo(ptr); + handler.onBinaryMessage(ptr, size); + } finally { + Unsafe.free(ptr, size, MemoryTag.NATIVE_DEFAULT); + } + } + + private interface SendBehavior { + void send(long dataPtr, int length); + } + + private interface TryReceiveBehavior { + boolean tryReceive(WebSocketFrameHandler handler); + } + + private static class FakeWebSocketClient extends WebSocketClient { + private volatile boolean connected = true; + private volatile SendBehavior sendBehavior = (dataPtr, length) -> {}; + private volatile TryReceiveBehavior tryReceiveBehavior = handler -> false; + + private FakeWebSocketClient() { + super(DefaultHttpClientConfiguration.INSTANCE, PlainSocketFactory.INSTANCE); + } + + @Override + public void close() { + connected = false; + super.close(); + } + + @Override + public boolean isConnected() { + return connected; + } + + @Override + public void sendBinary(long dataPtr, int length) { + sendBehavior.send(dataPtr, length); + } + + public void setSendBehavior(SendBehavior sendBehavior) { + this.sendBehavior = sendBehavior; + } + + public void setTryReceiveBehavior(TryReceiveBehavior tryReceiveBehavior) { + this.tryReceiveBehavior = tryReceiveBehavior; + } + + @Override + public boolean tryReceiveFrame(WebSocketFrameHandler handler) { + return tryReceiveBehavior.tryReceive(handler); + } + + @Override + protected void ioWait(int timeout, int op) { + // no-op + } + + @Override + protected void setupIoWait() { + // no-op + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaSymbolDictionaryTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaSymbolDictionaryTest.java new file mode 100644 index 0000000..f24dc35 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaSymbolDictionaryTest.java @@ -0,0 +1,647 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.cutlass.qwp.client.GlobalSymbolDictionary; +import io.questdb.client.cutlass.qwp.client.QwpBufferWriter; +import io.questdb.client.cutlass.qwp.client.QwpWebSocketEncoder; +import io.questdb.client.cutlass.qwp.protocol.QwpTableBuffer; +import io.questdb.client.std.ObjList; +import io.questdb.client.std.Unsafe; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; +import org.junit.Assert; +import org.junit.Test; + +import static io.questdb.client.cutlass.qwp.protocol.QwpConstants.*; + +/** + * Comprehensive tests for delta symbol dictionary encoding and decoding. + *

    + * Tests cover: + * - Multiple tables sharing the same global dictionary + * - Multiple batches with progressive symbol accumulation + * - Reconnection scenarios where the dictionary resets + * - Multiple symbol columns in the same table + * - Edge cases (empty batches, no symbols, etc.) + */ +public class DeltaSymbolDictionaryTest { + + @Test + public void testEdgeCase_batchWithNoSymbols() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder()) { + GlobalSymbolDictionary globalDict = new GlobalSymbolDictionary(); + + // Table with only non-symbol columns + try (QwpTableBuffer batch = new QwpTableBuffer("metrics")) { + QwpTableBuffer.ColumnBuffer valueCol = batch.getOrCreateColumn("value", TYPE_LONG, false); + valueCol.addLong(100L); + batch.nextRow(); + + // MaxId is -1 (no symbols) + int batchMaxId = -1; + + // Can still encode with delta dict (empty delta) + int size = encoder.encodeWithDeltaDict(batch, globalDict, -1, batchMaxId, false); + Assert.assertTrue(size > 0); + + // Verify flag is set + QwpBufferWriter buf = encoder.getBuffer(); + byte flags = Unsafe.getUnsafe().getByte(buf.getBufferPtr() + HEADER_OFFSET_FLAGS); + Assert.assertTrue((flags & FLAG_DELTA_SYMBOL_DICT) != 0); + } + } + }); + } + + @Test + public void testEdgeCase_duplicateSymbolsInBatch() throws Exception { + assertMemoryLeak(() -> { + GlobalSymbolDictionary globalDict = new GlobalSymbolDictionary(); + + try (QwpTableBuffer batch = new QwpTableBuffer("test")) { + QwpTableBuffer.ColumnBuffer col = batch.getOrCreateColumn("sym", TYPE_SYMBOL, false); + + // Same symbol used multiple times + int aaplId = globalDict.getOrAddSymbol("AAPL"); + col.addSymbolWithGlobalId("AAPL", aaplId); + batch.nextRow(); + col.addSymbolWithGlobalId("AAPL", aaplId); + batch.nextRow(); + col.addSymbolWithGlobalId("AAPL", aaplId); + batch.nextRow(); + + Assert.assertEquals(3, batch.getRowCount()); + Assert.assertEquals(1, globalDict.size()); // Only 1 unique symbol + + int maxGlobalId = col.getMaxGlobalSymbolId(); + Assert.assertEquals(0, maxGlobalId); // Max ID is 0 (AAPL) + } + }); + } + + @Test + public void testEdgeCase_emptyBatch() throws Exception { + assertMemoryLeak(() -> { + GlobalSymbolDictionary globalDict = new GlobalSymbolDictionary(); + + // Pre-populate dictionary and send + globalDict.getOrAddSymbol("AAPL"); + int maxSentSymbolId = 0; + + // Empty batch (no rows, no symbols used) + try (QwpTableBuffer emptyBatch = new QwpTableBuffer("test")) { + Assert.assertEquals(0, emptyBatch.getRowCount()); + + // Delta should still work (deltaCount = 0) + int deltaStart = maxSentSymbolId + 1; + int deltaCount = 0; + Assert.assertEquals(1, deltaStart); + Assert.assertEquals(0, deltaCount); + } + }); + } + + @Test + public void testEdgeCase_gapFill() throws Exception { + assertMemoryLeak(() -> { + // Client dictionary: AAPL(0), GOOG(1), MSFT(2), TSLA(3) + GlobalSymbolDictionary globalDict = new GlobalSymbolDictionary(); + globalDict.getOrAddSymbol("AAPL"); + globalDict.getOrAddSymbol("GOOG"); + globalDict.getOrAddSymbol("MSFT"); + globalDict.getOrAddSymbol("TSLA"); + + // Batch uses AAPL(0) and TSLA(3), skipping GOOG(1) and MSFT(2) + // Delta must include gap-fill: send all symbols from maxSentSymbolId+1 to batchMaxId + int maxSentSymbolId = -1; + int batchMaxId = 3; // TSLA + + int deltaStart = maxSentSymbolId + 1; + int deltaCount = batchMaxId - maxSentSymbolId; + + // Must send symbols 0, 1, 2, 3 (even though 1, 2 aren't used in this batch) + Assert.assertEquals(0, deltaStart); + Assert.assertEquals(4, deltaCount); + + // This ensures server has contiguous dictionary + for (int id = deltaStart; id < deltaStart + deltaCount; id++) { + String symbol = globalDict.getSymbol(id); + Assert.assertNotNull("Symbol " + id + " should exist", symbol); + } + }); + } + + @Test + public void testEdgeCase_largeSymbolDictionary() throws Exception { + assertMemoryLeak(() -> { + GlobalSymbolDictionary globalDict = new GlobalSymbolDictionary(); + + // Add 1000 unique symbols + for (int i = 0; i < 1000; i++) { + int id = globalDict.getOrAddSymbol("SYM_" + i); + Assert.assertEquals(i, id); + } + + Assert.assertEquals(1000, globalDict.size()); + + // Send first batch with symbols 0-99 + int maxSentSymbolId = 99; + + // Next batch uses symbols 0-199, delta is 100-199 + int deltaStart = maxSentSymbolId + 1; + int deltaCount = 199 - maxSentSymbolId; + Assert.assertEquals(100, deltaStart); + Assert.assertEquals(100, deltaCount); + }); + } + + @Test + public void testEdgeCase_nullSymbolValues() throws Exception { + assertMemoryLeak(() -> { + GlobalSymbolDictionary globalDict = new GlobalSymbolDictionary(); + + try (QwpTableBuffer batch = new QwpTableBuffer("test")) { + QwpTableBuffer.ColumnBuffer col = batch.getOrCreateColumn("sym", TYPE_SYMBOL, true); // nullable + + int aaplId = globalDict.getOrAddSymbol("AAPL"); + col.addSymbolWithGlobalId("AAPL", aaplId); + batch.nextRow(); + + col.addSymbol(null); // NULL value + batch.nextRow(); + + col.addSymbolWithGlobalId("AAPL", aaplId); + batch.nextRow(); + + Assert.assertEquals(3, batch.getRowCount()); + // Dictionary only has 1 symbol (AAPL), NULL doesn't add to dictionary + Assert.assertEquals(1, globalDict.size()); + } + }); + } + + @Test + public void testEdgeCase_unicodeSymbols() throws Exception { + assertMemoryLeak(() -> { + GlobalSymbolDictionary globalDict = new GlobalSymbolDictionary(); + + // Unicode symbols + int id1 = globalDict.getOrAddSymbol("日本"); + int id2 = globalDict.getOrAddSymbol("中国"); + int id3 = globalDict.getOrAddSymbol("한국"); + int id4 = globalDict.getOrAddSymbol("Émoji🚀"); + + Assert.assertEquals(0, id1); + Assert.assertEquals(1, id2); + Assert.assertEquals(2, id3); + Assert.assertEquals(3, id4); + + Assert.assertEquals("日本", globalDict.getSymbol(0)); + Assert.assertEquals("中国", globalDict.getSymbol(1)); + Assert.assertEquals("한국", globalDict.getSymbol(2)); + Assert.assertEquals("Émoji🚀", globalDict.getSymbol(3)); + }); + } + + @Test + public void testEdgeCase_veryLongSymbol() throws Exception { + assertMemoryLeak(() -> { + GlobalSymbolDictionary globalDict = new GlobalSymbolDictionary(); + + // Create a very long symbol (1000 chars) + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 1000; i++) { + sb.append('X'); + } + String longSymbol = sb.toString(); + + int id = globalDict.getOrAddSymbol(longSymbol); + Assert.assertEquals(0, id); + + String retrieved = globalDict.getSymbol(0); + Assert.assertEquals(longSymbol, retrieved); + Assert.assertEquals(1000, retrieved.length()); + }); + } + + @Test + public void testMultipleBatches_encodeAndDecode() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder()) { + GlobalSymbolDictionary clientDict = new GlobalSymbolDictionary(); + ObjList serverDict = new ObjList<>(); + int maxSentSymbolId = -1; + + int aaplId = clientDict.getOrAddSymbol("AAPL"); + int googId = clientDict.getOrAddSymbol("GOOG"); + + try (QwpTableBuffer batch1 = new QwpTableBuffer("test")) { + QwpTableBuffer.ColumnBuffer col1 = batch1.getOrCreateColumn("sym", TYPE_SYMBOL, false); + + col1.addSymbolWithGlobalId("AAPL", aaplId); + batch1.nextRow(); + col1.addSymbolWithGlobalId("GOOG", googId); + batch1.nextRow(); + + int batch1MaxId = 1; + int size1 = encoder.encodeWithDeltaDict(batch1, clientDict, maxSentSymbolId, batch1MaxId, false); + Assert.assertTrue(size1 > 0); + maxSentSymbolId = batch1MaxId; + + // Decode on server side + QwpBufferWriter buf1 = encoder.getBuffer(); + decodeAndAccumulateDict(buf1.getBufferPtr(), size1, serverDict); + + // Verify server dictionary + Assert.assertEquals(2, serverDict.size()); + Assert.assertEquals("AAPL", serverDict.get(0)); + Assert.assertEquals("GOOG", serverDict.get(1)); + } + + try (QwpTableBuffer batch2 = new QwpTableBuffer("test")) { + QwpTableBuffer.ColumnBuffer col2 = batch2.getOrCreateColumn("sym", TYPE_SYMBOL, false); + + int msftId = clientDict.getOrAddSymbol("MSFT"); + col2.addSymbolWithGlobalId("AAPL", aaplId); // Existing + batch2.nextRow(); + col2.addSymbolWithGlobalId("MSFT", msftId); // New + batch2.nextRow(); + + int batch2MaxId = 2; + int size2 = encoder.encodeWithDeltaDict(batch2, clientDict, maxSentSymbolId, batch2MaxId, false); + Assert.assertTrue(size2 > 0); + maxSentSymbolId = batch2MaxId; + + // Decode batch 2 + QwpBufferWriter buf2 = encoder.getBuffer(); + decodeAndAccumulateDict(buf2.getBufferPtr(), size2, serverDict); + + // Server dictionary should now have 3 symbols + Assert.assertEquals(3, serverDict.size()); + Assert.assertEquals("MSFT", serverDict.get(2)); + } + } + }); + } + + @Test + public void testMultipleBatches_progressiveSymbolAccumulation() throws Exception { + assertMemoryLeak(() -> { + GlobalSymbolDictionary globalDict = new GlobalSymbolDictionary(); + + // Batch 1: AAPL, GOOG + int aaplId = globalDict.getOrAddSymbol("AAPL"); + int googId = globalDict.getOrAddSymbol("GOOG"); + int batch1MaxId = Math.max(aaplId, googId); + + // Simulate sending batch 1 - maxSentSymbolId = 1 after send + int maxSentSymbolId = batch1MaxId; // 1 + + // Batch 2: AAPL (existing), MSFT (new), TSLA (new) + globalDict.getOrAddSymbol("AAPL"); // Returns 0, already exists + int msftId = globalDict.getOrAddSymbol("MSFT"); + int tslaId = globalDict.getOrAddSymbol("TSLA"); + int batch2MaxId = Math.max(msftId, tslaId); + + // Delta for batch 2 should be [2, 3] (MSFT, TSLA) + int deltaStart = maxSentSymbolId + 1; + int deltaCount = batch2MaxId - maxSentSymbolId; + Assert.assertEquals(2, deltaStart); + Assert.assertEquals(2, deltaCount); + + // Simulate sending batch 2 + maxSentSymbolId = batch2MaxId; // 3 + + // Batch 3: All existing symbols (no delta needed) + globalDict.getOrAddSymbol("AAPL"); + globalDict.getOrAddSymbol("GOOG"); + int batch3MaxId = 1; // Max used is GOOG(1) + + deltaStart = maxSentSymbolId + 1; + deltaCount = Math.max(0, batch3MaxId - maxSentSymbolId); + Assert.assertEquals(4, deltaStart); + Assert.assertEquals(0, deltaCount); // No new symbols + }); + } + + @Test + public void testMultipleTables_encodedInSameBatch() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder()) { + GlobalSymbolDictionary globalDict = new GlobalSymbolDictionary(); + + // Create two tables + try (QwpTableBuffer table1 = new QwpTableBuffer("trades"); + QwpTableBuffer table2 = new QwpTableBuffer("quotes")) { + + // Table 1: ticker column + QwpTableBuffer.ColumnBuffer col1 = table1.getOrCreateColumn("ticker", TYPE_SYMBOL, false); + int aaplId = globalDict.getOrAddSymbol("AAPL"); + int googId = globalDict.getOrAddSymbol("GOOG"); + col1.addSymbolWithGlobalId("AAPL", aaplId); + table1.nextRow(); + col1.addSymbolWithGlobalId("GOOG", googId); + table1.nextRow(); + + // Table 2: symbol column (different name, but shares dictionary) + QwpTableBuffer.ColumnBuffer col2 = table2.getOrCreateColumn("symbol", TYPE_SYMBOL, false); + int msftId = globalDict.getOrAddSymbol("MSFT"); + col2.addSymbolWithGlobalId("AAPL", aaplId); // Reuse AAPL + table2.nextRow(); + col2.addSymbolWithGlobalId("MSFT", msftId); + table2.nextRow(); + + // Encode first table with delta dict + int confirmedMaxId = -1; + int batchMaxId = 2; // AAPL(0), GOOG(1), MSFT(2) + + int size = encoder.encodeWithDeltaDict(table1, globalDict, confirmedMaxId, batchMaxId, false); + Assert.assertTrue(size > 0); + + // Verify delta section contains all 3 symbols + QwpBufferWriter buf = encoder.getBuffer(); + long ptr = buf.getBufferPtr(); + + byte flags = Unsafe.getUnsafe().getByte(ptr + HEADER_OFFSET_FLAGS); + Assert.assertTrue((flags & FLAG_DELTA_SYMBOL_DICT) != 0); + + // After header: deltaStart=0, deltaCount=3 + long pos = ptr + HEADER_SIZE; + int deltaStart = readVarint(pos); + Assert.assertEquals(0, deltaStart); + } + } + }); + } + + @Test + public void testMultipleTables_multipleSymbolColumns() throws Exception { + assertMemoryLeak(() -> { + GlobalSymbolDictionary globalDict = new GlobalSymbolDictionary(); + + try (QwpTableBuffer table = new QwpTableBuffer("market_data")) { + + // Column 1: exchange + QwpTableBuffer.ColumnBuffer exchangeCol = table.getOrCreateColumn("exchange", TYPE_SYMBOL, false); + int nyseId = globalDict.getOrAddSymbol("NYSE"); + int nasdaqId = globalDict.getOrAddSymbol("NASDAQ"); + + // Column 2: currency + QwpTableBuffer.ColumnBuffer currencyCol = table.getOrCreateColumn("currency", TYPE_SYMBOL, false); + int usdId = globalDict.getOrAddSymbol("USD"); + int eurId = globalDict.getOrAddSymbol("EUR"); + + // Column 3: ticker + QwpTableBuffer.ColumnBuffer tickerCol = table.getOrCreateColumn("ticker", TYPE_SYMBOL, false); + int aaplId = globalDict.getOrAddSymbol("AAPL"); + + // Add row with all three columns + exchangeCol.addSymbolWithGlobalId("NYSE", nyseId); + currencyCol.addSymbolWithGlobalId("USD", usdId); + tickerCol.addSymbolWithGlobalId("AAPL", aaplId); + table.nextRow(); + + exchangeCol.addSymbolWithGlobalId("NASDAQ", nasdaqId); + currencyCol.addSymbolWithGlobalId("EUR", eurId); + tickerCol.addSymbolWithGlobalId("AAPL", aaplId); // Reuse AAPL + table.nextRow(); + + // All symbols share the same global dictionary + Assert.assertEquals(5, globalDict.size()); + Assert.assertEquals("NYSE", globalDict.getSymbol(0)); + Assert.assertEquals("NASDAQ", globalDict.getSymbol(1)); + Assert.assertEquals("USD", globalDict.getSymbol(2)); + Assert.assertEquals("EUR", globalDict.getSymbol(3)); + Assert.assertEquals("AAPL", globalDict.getSymbol(4)); + } + }); + } + + @Test + public void testMultipleTables_sharedGlobalDictionary() throws Exception { + assertMemoryLeak(() -> { + GlobalSymbolDictionary globalDict = new GlobalSymbolDictionary(); + + // Table 1 uses symbols AAPL, GOOG + int aaplId = globalDict.getOrAddSymbol("AAPL"); + int googId = globalDict.getOrAddSymbol("GOOG"); + + // Table 2 uses symbols AAPL (reused), MSFT (new) + int aaplId2 = globalDict.getOrAddSymbol("AAPL"); // Should return same ID + int msftId = globalDict.getOrAddSymbol("MSFT"); + + // Verify deduplication + Assert.assertEquals(0, aaplId); + Assert.assertEquals(1, googId); + Assert.assertEquals(0, aaplId2); // Same as aaplId + Assert.assertEquals(2, msftId); + + // Total symbols should be 3 + Assert.assertEquals(3, globalDict.size()); + }); + } + + @Test + public void testReconnection_fullDeltaAfterReconnect() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder()) { + GlobalSymbolDictionary clientDict = new GlobalSymbolDictionary(); + + // First connection: add symbols + int aaplId = clientDict.getOrAddSymbol("AAPL"); + clientDict.getOrAddSymbol("GOOG"); + + // Send batch - maxSentSymbolId = 1 + int maxSentSymbolId = 1; + + // Reconnect - reset maxSentSymbolId + maxSentSymbolId = -1; + + // Create new batch using existing symbols + try (QwpTableBuffer batch = new QwpTableBuffer("test")) { + QwpTableBuffer.ColumnBuffer col = batch.getOrCreateColumn("sym", TYPE_SYMBOL, false); + col.addSymbolWithGlobalId("AAPL", aaplId); + batch.nextRow(); + + // Encode - should send full delta (all symbols from 0) + int size = encoder.encodeWithDeltaDict(batch, clientDict, maxSentSymbolId, 1, false); + Assert.assertTrue(size > 0); + + // Verify deltaStart is 0 + QwpBufferWriter buf = encoder.getBuffer(); + long pos = buf.getBufferPtr() + HEADER_SIZE; + int deltaStart = readVarint(pos); + Assert.assertEquals(0, deltaStart); + } + } + }); + } + + @Test + public void testReconnection_resetsWatermark() throws Exception { + assertMemoryLeak(() -> { + GlobalSymbolDictionary globalDict = new GlobalSymbolDictionary(); + + // Build up dictionary and "send" some symbols + globalDict.getOrAddSymbol("AAPL"); + globalDict.getOrAddSymbol("GOOG"); + globalDict.getOrAddSymbol("MSFT"); + + int maxSentSymbolId = 2; + + // Simulate reconnection - reset maxSentSymbolId + maxSentSymbolId = -1; + Assert.assertEquals(-1, maxSentSymbolId); + + // Global dictionary is NOT cleared (it's client-side) + Assert.assertEquals(3, globalDict.size()); + + // Next batch must send full delta from 0 + int deltaStart = maxSentSymbolId + 1; + Assert.assertEquals(0, deltaStart); + }); + } + + @Test + public void testReconnection_serverDictionaryCleared() throws Exception { + assertMemoryLeak(() -> { + ObjList serverDict = new ObjList<>(); + + // Simulate first connection + serverDict.add("AAPL"); + serverDict.add("GOOG"); + Assert.assertEquals(2, serverDict.size()); + + // Simulate reconnection - server clears dictionary + serverDict.clear(); + Assert.assertEquals(0, serverDict.size()); + + // New connection starts fresh + serverDict.add("MSFT"); + Assert.assertEquals(1, serverDict.size()); + Assert.assertEquals("MSFT", serverDict.get(0)); + }); + } + + @Test + public void testServerSide_accumulateDelta() throws Exception { + assertMemoryLeak(() -> { + ObjList serverDict = new ObjList<>(); + + // First batch: symbols 0-2 + accumulateDelta(serverDict, 0, new String[]{"AAPL", "GOOG", "MSFT"}); + + Assert.assertEquals(3, serverDict.size()); + Assert.assertEquals("AAPL", serverDict.get(0)); + Assert.assertEquals("GOOG", serverDict.get(1)); + Assert.assertEquals("MSFT", serverDict.get(2)); + + // Second batch: symbols 3-4 + accumulateDelta(serverDict, 3, new String[]{"TSLA", "AMZN"}); + + Assert.assertEquals(5, serverDict.size()); + Assert.assertEquals("TSLA", serverDict.get(3)); + Assert.assertEquals("AMZN", serverDict.get(4)); + + // Third batch: no new symbols (empty delta) + accumulateDelta(serverDict, 5, new String[]{}); + Assert.assertEquals(5, serverDict.size()); + }); + } + + @Test + public void testServerSide_resolveSymbol() throws Exception { + assertMemoryLeak(() -> { + ObjList serverDict = new ObjList<>(); + serverDict.add("AAPL"); + serverDict.add("GOOG"); + serverDict.add("MSFT"); + + // Resolve by global ID + Assert.assertEquals("AAPL", serverDict.get(0)); + Assert.assertEquals("GOOG", serverDict.get(1)); + Assert.assertEquals("MSFT", serverDict.get(2)); + }); + } + + private void accumulateDelta(ObjList serverDict, int deltaStart, String[] symbols) { + // Ensure capacity + while (serverDict.size() < deltaStart + symbols.length) { + serverDict.add(null); + } + // Add symbols + for (int i = 0; i < symbols.length; i++) { + serverDict.setQuick(deltaStart + i, symbols[i]); + } + } + + private void decodeAndAccumulateDict(long ptr, int size, ObjList serverDict) { + // Parse header + byte flags = Unsafe.getUnsafe().getByte(ptr + HEADER_OFFSET_FLAGS); + if ((flags & FLAG_DELTA_SYMBOL_DICT) == 0) { + return; // No delta dict + } + + // Parse delta section after header + long pos = ptr + HEADER_SIZE; + + // Read deltaStart + int deltaStart = readVarint(pos); + pos += 1; // Assuming single-byte varint + + // Read deltaCount + int deltaCount = readVarint(pos); + pos += 1; + + // Ensure capacity + while (serverDict.size() < deltaStart + deltaCount) { + serverDict.add(null); + } + + // Read symbols + for (int i = 0; i < deltaCount; i++) { + int len = readVarint(pos); + pos += 1; + + byte[] bytes = new byte[len]; + for (int j = 0; j < len; j++) { + bytes[j] = Unsafe.getUnsafe().getByte(pos + j); + } + pos += len; + + serverDict.setQuick(deltaStart + i, new String(bytes, java.nio.charset.StandardCharsets.UTF_8)); + } + } + + private int readVarint(long address) { + byte b = Unsafe.getUnsafe().getByte(address); + if ((b & 0x80) == 0) { + return b & 0x7F; + } + // For simplicity, only handle single-byte varints in tests + return b & 0x7F; + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/GlobalSymbolDictionaryTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/GlobalSymbolDictionaryTest.java new file mode 100644 index 0000000..c5eb84e --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/GlobalSymbolDictionaryTest.java @@ -0,0 +1,249 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.cutlass.qwp.client.GlobalSymbolDictionary; +import org.junit.Test; + +import static org.junit.Assert.*; + +public class GlobalSymbolDictionaryTest { + + @Test + public void testAddSymbol_assignsSequentialIds() { + GlobalSymbolDictionary dict = new GlobalSymbolDictionary(); + + assertEquals(0, dict.getOrAddSymbol("AAPL")); + assertEquals(1, dict.getOrAddSymbol("GOOG")); + assertEquals(2, dict.getOrAddSymbol("MSFT")); + assertEquals(3, dict.getOrAddSymbol("TSLA")); + + assertEquals(4, dict.size()); + } + + @Test + public void testAddSymbol_deduplicatesSameSymbol() { + GlobalSymbolDictionary dict = new GlobalSymbolDictionary(); + + int id1 = dict.getOrAddSymbol("AAPL"); + int id2 = dict.getOrAddSymbol("AAPL"); + int id3 = dict.getOrAddSymbol("AAPL"); + + assertEquals(id1, id2); + assertEquals(id2, id3); + assertEquals(0, id1); + assertEquals(1, dict.size()); + } + + @Test + public void testClear() { + GlobalSymbolDictionary dict = new GlobalSymbolDictionary(); + + dict.getOrAddSymbol("AAPL"); + dict.getOrAddSymbol("GOOG"); + assertEquals(2, dict.size()); + + dict.clear(); + + assertTrue(dict.isEmpty()); + assertEquals(0, dict.size()); + assertFalse(dict.contains("AAPL")); + } + + @Test + public void testClear_thenAddRestartsFromZero() { + GlobalSymbolDictionary dict = new GlobalSymbolDictionary(); + + dict.getOrAddSymbol("AAPL"); + dict.getOrAddSymbol("GOOG"); + dict.clear(); + + // New IDs should start from 0 + assertEquals(0, dict.getOrAddSymbol("MSFT")); + assertEquals(1, dict.getOrAddSymbol("TSLA")); + } + + @Test + public void testContains() { + GlobalSymbolDictionary dict = new GlobalSymbolDictionary(); + + assertFalse(dict.contains("AAPL")); + + dict.getOrAddSymbol("AAPL"); + dict.getOrAddSymbol("GOOG"); + + assertTrue(dict.contains("AAPL")); + assertTrue(dict.contains("GOOG")); + assertFalse(dict.contains("MSFT")); + assertFalse(dict.contains(null)); + } + + @Test + public void testCustomInitialCapacity() { + GlobalSymbolDictionary dict = new GlobalSymbolDictionary(1024); + + // Should work normally + for (int i = 0; i < 100; i++) { + assertEquals(i, dict.getOrAddSymbol("SYM_" + i)); + } + assertEquals(100, dict.size()); + } + + @Test + public void testGetId_returnsCorrectId() { + GlobalSymbolDictionary dict = new GlobalSymbolDictionary(); + + dict.getOrAddSymbol("AAPL"); + dict.getOrAddSymbol("GOOG"); + dict.getOrAddSymbol("MSFT"); + + assertEquals(0, dict.getId("AAPL")); + assertEquals(1, dict.getId("GOOG")); + assertEquals(2, dict.getId("MSFT")); + } + + @Test + public void testGetId_returnsMinusOneForNull() { + GlobalSymbolDictionary dict = new GlobalSymbolDictionary(); + assertEquals(-1, dict.getId(null)); + } + + @Test + public void testGetId_returnsMinusOneForUnknown() { + GlobalSymbolDictionary dict = new GlobalSymbolDictionary(); + dict.getOrAddSymbol("AAPL"); + + assertEquals(-1, dict.getId("GOOG")); + assertEquals(-1, dict.getId("UNKNOWN")); + } + + @Test(expected = IllegalArgumentException.class) + public void testGetOrAddSymbol_throwsForNull() { + GlobalSymbolDictionary dict = new GlobalSymbolDictionary(); + dict.getOrAddSymbol(null); + } + + @Test + public void testGetSymbol_returnsCorrectSymbol() { + GlobalSymbolDictionary dict = new GlobalSymbolDictionary(); + + dict.getOrAddSymbol("AAPL"); + dict.getOrAddSymbol("GOOG"); + dict.getOrAddSymbol("MSFT"); + + assertEquals("AAPL", dict.getSymbol(0)); + assertEquals("GOOG", dict.getSymbol(1)); + assertEquals("MSFT", dict.getSymbol(2)); + } + + @Test(expected = IndexOutOfBoundsException.class) + public void testGetSymbol_throwsForInvalidId() { + GlobalSymbolDictionary dict = new GlobalSymbolDictionary(); + dict.getOrAddSymbol("AAPL"); + dict.getSymbol(1); // Only id 0 exists + } + + @Test(expected = IndexOutOfBoundsException.class) + public void testGetSymbol_throwsForNegativeId() { + GlobalSymbolDictionary dict = new GlobalSymbolDictionary(); + dict.getOrAddSymbol("AAPL"); + dict.getSymbol(-1); + } + + @Test + public void testIsEmpty() { + GlobalSymbolDictionary dict = new GlobalSymbolDictionary(); + + assertTrue(dict.isEmpty()); + + dict.getOrAddSymbol("AAPL"); + assertFalse(dict.isEmpty()); + } + + @Test + public void testLargeNumberOfSymbols() { + GlobalSymbolDictionary dict = new GlobalSymbolDictionary(); + + // Add 10000 symbols + for (int i = 0; i < 10000; i++) { + assertEquals(i, dict.getOrAddSymbol("SYMBOL_" + i)); + } + + assertEquals(10000, dict.size()); + + // Verify retrieval + for (int i = 0; i < 10000; i++) { + assertEquals("SYMBOL_" + i, dict.getSymbol(i)); + assertEquals(i, dict.getId("SYMBOL_" + i)); + } + } + + @Test + public void testMixedSymbolsAcrossTables() { + // Simulates symbols from multiple tables sharing the dictionary + GlobalSymbolDictionary dict = new GlobalSymbolDictionary(); + + // Table "trades": exchange column + int nyse = dict.getOrAddSymbol("NYSE"); // 0 + int nasdaq = dict.getOrAddSymbol("NASDAQ"); // 1 + + // Table "prices": currency column + int usd = dict.getOrAddSymbol("USD"); // 2 + int eur = dict.getOrAddSymbol("EUR"); // 3 + + // Table "orders": exchange column (reuses) + int nyse2 = dict.getOrAddSymbol("NYSE"); // Still 0 + + assertEquals(nyse, nyse2); + assertEquals(4, dict.size()); + + // All symbols accessible + assertEquals("NYSE", dict.getSymbol(nyse)); + assertEquals("NASDAQ", dict.getSymbol(nasdaq)); + assertEquals("USD", dict.getSymbol(usd)); + assertEquals("EUR", dict.getSymbol(eur)); + } + + @Test + public void testSpecialCharactersInSymbols() { + GlobalSymbolDictionary dict = new GlobalSymbolDictionary(); + + dict.getOrAddSymbol(""); // Empty string + dict.getOrAddSymbol(" "); // Space + dict.getOrAddSymbol("a b c"); // With spaces + dict.getOrAddSymbol("AAPL\u0000"); // With null char + dict.getOrAddSymbol("\u00E9"); // Unicode + dict.getOrAddSymbol("\uD83D\uDE00"); // Emoji + + assertEquals(6, dict.size()); + + assertEquals("", dict.getSymbol(0)); + assertEquals(" ", dict.getSymbol(1)); + assertEquals("a b c", dict.getSymbol(2)); + assertEquals("AAPL\u0000", dict.getSymbol(3)); + assertEquals("\u00E9", dict.getSymbol(4)); + assertEquals("\uD83D\uDE00", dict.getSymbol(5)); + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/InFlightWindowTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/InFlightWindowTest.java new file mode 100644 index 0000000..36d098d --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/InFlightWindowTest.java @@ -0,0 +1,843 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.cutlass.qwp.client.InFlightWindow; +import org.junit.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.*; + +/** + * Tests for InFlightWindow. + *

    + * The window assumes sequential batch IDs and cumulative acknowledgments. It + * tracks only the range [lastAcked+1, highestSent] rather than individual batch + * IDs. + */ +public class InFlightWindowTest { + + @Test + public void testAcknowledgeAlreadyAcked() { + InFlightWindow window = new InFlightWindow(8, 1000); + + window.addInFlight(0); + window.addInFlight(1); + + // ACK up to 1 + assertTrue(window.acknowledge(1)); + assertTrue(window.isEmpty()); + + // ACK for already acknowledged sequence returns true (idempotent) + assertTrue(window.acknowledge(0)); + assertTrue(window.acknowledge(1)); + assertTrue(window.isEmpty()); + } + + @Test + public void testAcknowledgeUpToAllBatches() { + InFlightWindow window = new InFlightWindow(16, 1000); + + // Add batches + for (int i = 0; i < 10; i++) { + window.addInFlight(i); + } + + // ACK all with high sequence + int acked = window.acknowledgeUpTo(Long.MAX_VALUE); + assertEquals(10, acked); + assertTrue(window.isEmpty()); + } + + @Test + public void testAcknowledgeUpToBasic() { + InFlightWindow window = new InFlightWindow(16, 1000); + + // Add batches 0-9 + for (int i = 0; i < 10; i++) { + window.addInFlight(i); + } + assertEquals(10, window.getInFlightCount()); + + // ACK up to 5 (should remove 0-5, leaving 6-9) + int acked = window.acknowledgeUpTo(5); + assertEquals(6, acked); + assertEquals(4, window.getInFlightCount()); + assertEquals(6, window.getTotalAcked()); + } + + @Test + public void testAcknowledgeUpToEmpty() { + InFlightWindow window = new InFlightWindow(16, 1000); + + // ACK on empty window should be no-op + assertEquals(0, window.acknowledgeUpTo(100)); + assertTrue(window.isEmpty()); + } + + @Test + public void testAcknowledgeUpToIdempotent() { + InFlightWindow window = new InFlightWindow(16, 1000); + + window.addInFlight(0); + window.addInFlight(1); + window.addInFlight(2); + + // First ACK + assertEquals(3, window.acknowledgeUpTo(2)); + assertTrue(window.isEmpty()); + + // Duplicate ACK - should be no-op + assertEquals(0, window.acknowledgeUpTo(2)); + assertTrue(window.isEmpty()); + + // ACK with lower sequence - should be no-op + assertEquals(0, window.acknowledgeUpTo(1)); + assertTrue(window.isEmpty()); + } + + @Test + public void testAcknowledgeUpToWakesAwaitEmpty() throws Exception { + InFlightWindow window = new InFlightWindow(16, 5000); + + window.addInFlight(0); + window.addInFlight(1); + window.addInFlight(2); + + AtomicBoolean waiting = new AtomicBoolean(true); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch finished = new CountDownLatch(1); + + // Start thread waiting for empty + Thread waitThread = new Thread(() -> { + started.countDown(); + window.awaitEmpty(); + waiting.set(false); + finished.countDown(); + }); + waitThread.start(); + + assertTrue(started.await(1, TimeUnit.SECONDS)); + awaitThreadBlocked(waitThread); + assertTrue(waiting.get()); + + // Single cumulative ACK clears all + window.acknowledgeUpTo(2); + + assertTrue(finished.await(1, TimeUnit.SECONDS)); + assertFalse(waiting.get()); + assertTrue(window.isEmpty()); + } + + @Test + public void testAcknowledgeUpToWakesBlockedAdder() throws Exception { + InFlightWindow window = new InFlightWindow(3, 5000); + + // Fill the window + window.addInFlight(0); + window.addInFlight(1); + window.addInFlight(2); + assertTrue(window.isFull()); + + AtomicBoolean blocked = new AtomicBoolean(true); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch finished = new CountDownLatch(1); + + // Start thread that will block + Thread addThread = new Thread(() -> { + started.countDown(); + window.addInFlight(3); + blocked.set(false); + finished.countDown(); + }); + addThread.start(); + + assertTrue(started.await(1, TimeUnit.SECONDS)); + awaitThreadBlocked(addThread); + assertTrue(blocked.get()); + + // Cumulative ACK frees multiple slots + window.acknowledgeUpTo(1); // Removes 0 and 1 + + // Thread should complete + assertTrue(finished.await(1, TimeUnit.SECONDS)); + assertFalse(blocked.get()); + assertEquals(2, window.getInFlightCount()); // batch 2 and 3 + } + + @Test + public void testAwaitEmpty() throws Exception { + InFlightWindow window = new InFlightWindow(8, 5000); + + window.addInFlight(0); + window.addInFlight(1); + window.addInFlight(2); + + AtomicBoolean waiting = new AtomicBoolean(true); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch finished = new CountDownLatch(1); + + // Start thread waiting for empty + Thread waitThread = new Thread(() -> { + started.countDown(); + window.awaitEmpty(); + waiting.set(false); + finished.countDown(); + }); + waitThread.start(); + + assertTrue(started.await(1, TimeUnit.SECONDS)); + awaitThreadBlocked(waitThread); + assertTrue(waiting.get()); + + // Cumulative ACK all batches + window.acknowledgeUpTo(2); + assertTrue(finished.await(1, TimeUnit.SECONDS)); + assertFalse(waiting.get()); + } + + @Test + public void testAwaitEmptyAlreadyEmpty() { + InFlightWindow window = new InFlightWindow(8, 1000); + + // Should return immediately + window.awaitEmpty(); + assertTrue(window.isEmpty()); + } + + @Test + public void testAwaitEmptyTimeout() { + InFlightWindow window = new InFlightWindow(8, 100); // 100ms timeout + + window.addInFlight(0); + + long start = System.currentTimeMillis(); + try { + window.awaitEmpty(); + fail("Expected timeout exception"); + } catch (LineSenderException e) { + assertTrue(e.getMessage().contains("Timeout")); + } + long elapsed = System.currentTimeMillis() - start; + assertTrue("Should have waited at least 100ms", elapsed >= 90); + } + + @Test + public void testBasicAddAndAcknowledge() { + InFlightWindow window = new InFlightWindow(8, 1000); + + assertTrue(window.isEmpty()); + assertEquals(0, window.getInFlightCount()); + + // Add a batch (sequential: 0) + window.addInFlight(0); + assertFalse(window.isEmpty()); + assertEquals(1, window.getInFlightCount()); + + // Acknowledge it (cumulative ACK up to 0) + assertTrue(window.acknowledge(0)); + assertTrue(window.isEmpty()); + assertEquals(0, window.getInFlightCount()); + assertEquals(1, window.getTotalAcked()); + } + + @Test + public void testClearError() { + InFlightWindow window = new InFlightWindow(8, 1000); + + window.addInFlight(0); + window.fail(0, new RuntimeException("Test error")); + + assertNotNull(window.getLastError()); + + window.clearError(); + assertNull(window.getLastError()); + + // Should work again + window.addInFlight(1); + assertEquals(2, window.getInFlightCount()); // 0 and 1 both in window (fail doesn't remove) + } + + @Test + public void testConcurrentAddAndAck() throws Exception { + InFlightWindow window = new InFlightWindow(4, 5000); + int numOperations = 100; + CountDownLatch done = new CountDownLatch(2); + AtomicReference error = new AtomicReference<>(); + AtomicInteger highestAdded = new AtomicInteger(-1); + + // Sender thread + Thread sender = new Thread(() -> { + try { + for (int i = 0; i < numOperations; i++) { + window.addInFlight(i); + highestAdded.set(i); + Thread.sleep(1); // Small delay + } + } catch (Throwable t) { + error.set(t); + } finally { + done.countDown(); + } + }); + + // ACK thread (cumulative ACKs) + Thread acker = new Thread(() -> { + try { + Thread.sleep(10); // Let sender get ahead + int lastAcked = -1; + while (lastAcked < numOperations - 1) { + int highest = highestAdded.get(); + if (highest > lastAcked) { + window.acknowledgeUpTo(highest); + lastAcked = highest; + } + Thread.sleep(1); + } + } catch (Throwable t) { + error.set(t); + } finally { + done.countDown(); + } + }); + + sender.start(); + acker.start(); + + assertTrue(done.await(10, TimeUnit.SECONDS)); + assertNull(error.get()); + assertTrue(window.isEmpty()); + assertEquals(numOperations, window.getTotalAcked()); + } + + @Test + public void testConcurrentAddAndCumulativeAck() throws Exception { + InFlightWindow window = new InFlightWindow(100, 10000); + int numBatches = 500; + CountDownLatch done = new CountDownLatch(2); + AtomicReference error = new AtomicReference<>(); + AtomicInteger highestAdded = new AtomicInteger(-1); + + // Sender thread + Thread sender = new Thread(() -> { + try { + for (int i = 0; i < numBatches; i++) { + window.addInFlight(i); + highestAdded.set(i); + } + } catch (Throwable t) { + error.set(t); + } finally { + done.countDown(); + } + }); + + // ACK thread using cumulative ACKs + Thread acker = new Thread(() -> { + try { + int lastAcked = -1; + while (lastAcked < numBatches - 1) { + int highest = highestAdded.get(); + if (highest > lastAcked) { + window.acknowledgeUpTo(highest); + lastAcked = highest; + } else { + Thread.sleep(1); + } + } + } catch (Throwable t) { + error.set(t); + } finally { + done.countDown(); + } + }); + + sender.start(); + acker.start(); + + assertTrue(done.await(30, TimeUnit.SECONDS)); + assertNull(error.get()); + assertTrue(window.isEmpty()); + assertEquals(numBatches, window.getTotalAcked()); + } + + @Test + public void testDefaultWindowSize() { + InFlightWindow window = new InFlightWindow(); + assertEquals(InFlightWindow.DEFAULT_WINDOW_SIZE, window.getMaxWindowSize()); + } + + @Test + public void testFailAllPropagatesError() { + InFlightWindow window = new InFlightWindow(8, 1000); + + window.addInFlight(0); + window.addInFlight(1); + window.failAll(new RuntimeException("Transport down")); + + try { + window.awaitEmpty(); + fail("Expected exception due to failAll"); + } catch (LineSenderException e) { + assertTrue(e.getMessage().contains("failed")); + assertTrue(e.getMessage().contains("Transport down")); + } + } + + @Test + public void testFailBatch() { + InFlightWindow window = new InFlightWindow(8, 1000); + + window.addInFlight(0); + window.addInFlight(1); + + // Fail batch 0 + RuntimeException error = new RuntimeException("Test error"); + window.fail(0, error); + + assertEquals(1, window.getTotalFailed()); + assertNotNull(window.getLastError()); + } + + @Test + public void testFailPropagatesError() { + InFlightWindow window = new InFlightWindow(8, 1000); + + window.addInFlight(0); + window.fail(0, new RuntimeException("Test error")); + + // Subsequent operations should throw + try { + window.addInFlight(1); + fail("Expected exception due to error"); + } catch (LineSenderException e) { + assertTrue(e.getMessage().contains("failed")); + } + + try { + window.awaitEmpty(); + fail("Expected exception due to error"); + } catch (LineSenderException e) { + assertTrue(e.getMessage().contains("failed")); + } + } + + @Test + public void testFailThenClearThenAdd() { + InFlightWindow window = new InFlightWindow(8, 1000); + + window.addInFlight(0); + window.fail(0, new RuntimeException("Error")); + + // Should not be able to add + try { + window.addInFlight(1); + fail("Expected exception"); + } catch (LineSenderException e) { + assertTrue(e.getMessage().contains("failed")); + } + + // Clear error + window.clearError(); + + // Should work now + window.addInFlight(1); + assertEquals(2, window.getInFlightCount()); + } + + @Test + public void testFailWakesAwaitEmpty() throws Exception { + InFlightWindow window = new InFlightWindow(8, 5000); + + window.addInFlight(0); + + CountDownLatch started = new CountDownLatch(1); + AtomicReference caught = new AtomicReference<>(); + + // Thread waiting for empty + Thread waitThread = new Thread(() -> { + started.countDown(); + try { + window.awaitEmpty(); + } catch (LineSenderException e) { + caught.set(e); + } + }); + waitThread.start(); + + assertTrue(started.await(1, TimeUnit.SECONDS)); + awaitThreadBlocked(waitThread); + + // Fail a batch - should wake the blocked thread + window.fail(0, new RuntimeException("Test error")); + + waitThread.join(1000); + assertFalse(waitThread.isAlive()); + assertNotNull(caught.get()); + assertTrue(caught.get().getMessage().contains("failed")); + } + + @Test + public void testFailWakesBlockedAdder() throws Exception { + InFlightWindow window = new InFlightWindow(2, 5000); + + // Fill the window + window.addInFlight(0); + window.addInFlight(1); + + CountDownLatch started = new CountDownLatch(1); + AtomicReference caught = new AtomicReference<>(); + + // Thread that will block on add + Thread addThread = new Thread(() -> { + started.countDown(); + try { + window.addInFlight(2); + } catch (LineSenderException e) { + caught.set(e); + } + }); + addThread.start(); + + assertTrue(started.await(1, TimeUnit.SECONDS)); + awaitThreadBlocked(addThread); + + // Fail a batch - should wake the blocked thread + window.fail(0, new RuntimeException("Test error")); + + addThread.join(1000); + assertFalse(addThread.isAlive()); + assertNotNull(caught.get()); + assertTrue(caught.get().getMessage().contains("failed")); + } + + @Test + public void testFillAndDrainRepeatedly() { + InFlightWindow window = new InFlightWindow(4, 1000); + + int batchId = 0; + for (int cycle = 0; cycle < 100; cycle++) { + // Fill + int startBatch = batchId; + for (int i = 0; i < 4; i++) { + window.addInFlight(batchId++); + } + assertTrue(window.isFull()); + assertEquals(4, window.getInFlightCount()); + + // Drain with cumulative ACK + window.acknowledgeUpTo(batchId - 1); + assertTrue(window.isEmpty()); + } + + assertEquals(400, window.getTotalAcked()); + } + + @Test + public void testGetMaxWindowSize() { + InFlightWindow window = new InFlightWindow(16, 1000); + assertEquals(16, window.getMaxWindowSize()); + } + + @Test + public void testHasWindowSpace() { + InFlightWindow window = new InFlightWindow(2, 1000); + + assertTrue(window.hasWindowSpace()); + window.addInFlight(0); + assertTrue(window.hasWindowSpace()); + window.addInFlight(1); + assertFalse(window.hasWindowSpace()); + + window.acknowledge(0); + assertTrue(window.hasWindowSpace()); + } + + @Test + public void testHighConcurrencyStress() throws Exception { + InFlightWindow window = new InFlightWindow(8, 30000); + int numBatches = 10000; + CountDownLatch done = new CountDownLatch(2); + AtomicReference error = new AtomicReference<>(); + AtomicInteger highestAdded = new AtomicInteger(-1); + + // Fast sender thread + Thread sender = new Thread(() -> { + try { + for (int i = 0; i < numBatches; i++) { + window.addInFlight(i); + highestAdded.set(i); + } + } catch (Throwable t) { + error.set(t); + } finally { + done.countDown(); + } + }); + + // Fast ACK thread + Thread acker = new Thread(() -> { + try { + int lastAcked = -1; + while (lastAcked < numBatches - 1) { + int highest = highestAdded.get(); + if (highest > lastAcked) { + window.acknowledgeUpTo(highest); + lastAcked = highest; + } else { + Thread.sleep(1); + } + } + } catch (Throwable t) { + error.set(t); + } finally { + done.countDown(); + } + }); + + sender.start(); + acker.start(); + + assertTrue(done.await(60, TimeUnit.SECONDS)); + if (error.get() != null) { + error.get().printStackTrace(); + } + assertNull(error.get()); + assertTrue(window.isEmpty()); + assertEquals(numBatches, window.getTotalAcked()); + } + + @Test(expected = IllegalArgumentException.class) + public void testInvalidWindowSize() { + new InFlightWindow(0, 1000); + } + + @Test + public void testMultipleBatches() { + InFlightWindow window = new InFlightWindow(8, 1000); + + // Add sequential batches 0-4 + for (long i = 0; i < 5; i++) { + window.addInFlight(i); + } + assertEquals(5, window.getInFlightCount()); + + // Cumulative ACK up to 2 (acknowledges 0, 1, 2) + assertEquals(3, window.acknowledgeUpTo(2)); + assertEquals(2, window.getInFlightCount()); + + // Cumulative ACK up to 4 (acknowledges 3, 4) + assertEquals(2, window.acknowledgeUpTo(4)); + assertTrue(window.isEmpty()); + assertEquals(5, window.getTotalAcked()); + } + + @Test + public void testMultipleResets() { + InFlightWindow window = new InFlightWindow(8, 1000); + + for (int cycle = 0; cycle < 10; cycle++) { + window.addInFlight(cycle); + window.reset(); + + assertTrue(window.isEmpty()); + assertNull(window.getLastError()); + } + } + + @Test + public void testRapidAddAndAck() { + InFlightWindow window = new InFlightWindow(16, 5000); + + // Rapid add and ack in same thread + for (int i = 0; i < 10000; i++) { + window.addInFlight(i); + assertTrue(window.acknowledge(i)); + } + + assertTrue(window.isEmpty()); + assertEquals(10000, window.getTotalAcked()); + } + + @Test + public void testReset() { + InFlightWindow window = new InFlightWindow(8, 1000); + + window.addInFlight(0); + window.addInFlight(1); + window.fail(2, new RuntimeException("Test")); + + window.reset(); + + assertTrue(window.isEmpty()); + assertNull(window.getLastError()); + assertEquals(0, window.getInFlightCount()); + } + + @Test + public void testSmallestPossibleWindow() { + InFlightWindow window = new InFlightWindow(1, 1000); + + window.addInFlight(0); + assertTrue(window.isFull()); + + window.acknowledge(0); + assertFalse(window.isFull()); + } + + @Test + public void testTryAddInFlight() { + InFlightWindow window = new InFlightWindow(2, 1000); + + // Should succeed + assertTrue(window.tryAddInFlight(0)); + assertTrue(window.tryAddInFlight(1)); + + // Should fail - window full + assertFalse(window.tryAddInFlight(2)); + + // After ACK, should succeed + window.acknowledge(0); + assertTrue(window.tryAddInFlight(2)); + } + + @Test + public void testVeryLargeWindow() { + InFlightWindow window = new InFlightWindow(10000, 1000); + + // Add many batches + for (int i = 0; i < 5000; i++) { + window.addInFlight(i); + } + assertEquals(5000, window.getInFlightCount()); + assertFalse(window.isFull()); + + // ACK half + window.acknowledgeUpTo(2499); + assertEquals(2500, window.getInFlightCount()); + } + + @Test + public void testWindowBlocksTimeout() { + InFlightWindow window = new InFlightWindow(2, 100); // 100ms timeout + + // Fill the window + window.addInFlight(0); + window.addInFlight(1); + + // Try to add another - should timeout + long start = System.currentTimeMillis(); + try { + window.addInFlight(2); + fail("Expected timeout exception"); + } catch (LineSenderException e) { + assertTrue(e.getMessage().contains("Timeout")); + } + long elapsed = System.currentTimeMillis() - start; + assertTrue("Should have waited at least 100ms", elapsed >= 90); + } + + @Test + public void testWindowBlocksWhenFull() throws Exception { + InFlightWindow window = new InFlightWindow(2, 5000); + + // Fill the window + window.addInFlight(0); + window.addInFlight(1); + + AtomicBoolean blocked = new AtomicBoolean(true); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch finished = new CountDownLatch(1); + + // Start thread that will block + Thread addThread = new Thread(() -> { + started.countDown(); + window.addInFlight(2); + blocked.set(false); + finished.countDown(); + }); + addThread.start(); + + // Wait for thread to start and block + assertTrue(started.await(1, TimeUnit.SECONDS)); + awaitThreadBlocked(addThread); + assertTrue(blocked.get()); + + // Free a slot + window.acknowledge(0); + + // Thread should complete + assertTrue(finished.await(1, TimeUnit.SECONDS)); + assertFalse(blocked.get()); + assertEquals(2, window.getInFlightCount()); + } + + @Test + public void testWindowFull() { + InFlightWindow window = new InFlightWindow(3, 1000); + + // Fill the window + window.addInFlight(0); + window.addInFlight(1); + window.addInFlight(2); + + assertTrue(window.isFull()); + assertEquals(3, window.getInFlightCount()); + + // Free slots by ACKing + window.acknowledgeUpTo(1); + assertFalse(window.isFull()); + assertEquals(1, window.getInFlightCount()); + } + + @Test + public void testZeroBatchId() { + InFlightWindow window = new InFlightWindow(8, 1000); + + window.addInFlight(0); + assertEquals(1, window.getInFlightCount()); + + assertTrue(window.acknowledge(0)); + assertTrue(window.isEmpty()); + } + + private static void awaitThreadBlocked(Thread thread) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (System.nanoTime() < deadline) { + Thread.State state = thread.getState(); + if (state == Thread.State.WAITING || state == Thread.State.TIMED_WAITING) { + return; + } + Thread.sleep(1); + } + fail("Thread did not reach blocked state within 5s, state: " + thread.getState()); + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/LineSenderBuilderWebSocketTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/LineSenderBuilderWebSocketTest.java new file mode 100644 index 0000000..ae6480c --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/LineSenderBuilderWebSocketTest.java @@ -0,0 +1,738 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.Sender; +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; +import io.questdb.client.test.AbstractTest; +import io.questdb.client.test.tools.TestUtils; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; +import org.junit.Assert; +import org.junit.Ignore; +import org.junit.Test; + +/** + * Tests for WebSocket transport support in the Sender.builder() API. + * These tests verify the builder configuration and validation, + * not actual WebSocket connectivity (which requires a running server). + */ +public class LineSenderBuilderWebSocketTest extends AbstractTest { + + private static final String LOCALHOST = "localhost"; + + @Test + public void testAddressConfiguration() { + Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST + ":9000"); + Assert.assertNotNull(builder); + } + + @Test + public void testAddressEmpty_fails() { + assertThrows("address cannot be empty", + () -> Sender.builder(Sender.Transport.WEBSOCKET).address("")); + } + + @Test + public void testAddressEndsWithColon_fails() { + assertThrows("invalid address", + () -> Sender.builder(Sender.Transport.WEBSOCKET).address("foo:")); + } + + @Test + public void testAddressNull_fails() { + assertThrows("null", + () -> Sender.builder(Sender.Transport.WEBSOCKET).address(null)); + } + + @Test + public void testAddressWithoutPort_usesDefaultPort9000() { + Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST); + Assert.assertNotNull(builder); + } + + @Test + public void testAsyncModeCanBeSetMultipleTimes() { + Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .asyncMode(true) + .asyncMode(false); + Assert.assertNotNull(builder); + } + + @Test + public void testAsyncModeDisabled() { + Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .asyncMode(false); + Assert.assertNotNull(builder); + } + + @Test + public void testAsyncModeEnabled() { + Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .asyncMode(true); + Assert.assertNotNull(builder); + } + + @Test + public void testAsyncModeWithAllOptions() { + Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .asyncMode(true) + .autoFlushRows(500) + .autoFlushBytes(512 * 1024) + .autoFlushIntervalMillis(50) + .inFlightWindowSize(8); + Assert.assertNotNull(builder); + } + + @Test + public void testAutoFlushBytes() { + Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .autoFlushBytes(1024 * 1024); + Assert.assertNotNull(builder); + } + + @Test + public void testAutoFlushBytesDoubleSet_fails() { + assertThrows("already configured", + () -> Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .autoFlushBytes(1024) + .autoFlushBytes(2048)); + } + + @Test + public void testAutoFlushBytesNegative_fails() { + assertThrows("cannot be negative", + () -> Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .autoFlushBytes(-1)); + } + + @Test + public void testAutoFlushBytesZero() { + Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .autoFlushBytes(0); + Assert.assertNotNull(builder); + } + + @Test + public void testAutoFlushIntervalMillis() { + Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .autoFlushIntervalMillis(100); + Assert.assertNotNull(builder); + } + + @Test + public void testAutoFlushIntervalMillisDoubleSet_fails() { + assertThrows("already configured", + () -> Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .autoFlushIntervalMillis(100) + .autoFlushIntervalMillis(200)); + } + + @Test + public void testAutoFlushIntervalMillisNegative_fails() { + assertThrows("cannot be negative", + () -> Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .autoFlushIntervalMillis(-1)); + } + + @Test + public void testAutoFlushIntervalMillisZero_fails() { + assertThrows("cannot be negative", + () -> Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .autoFlushIntervalMillis(0)); + } + + @Test + public void testAutoFlushRows() { + Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .autoFlushRows(1000); + Assert.assertNotNull(builder); + } + + @Test + public void testAutoFlushRowsDoubleSet_fails() { + assertThrows("already configured", + () -> Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .autoFlushRows(100) + .autoFlushRows(200)); + } + + @Test + public void testAutoFlushRowsNegative_fails() { + assertThrows("cannot be negative", + () -> Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .autoFlushRows(-1)); + } + + @Test + public void testAutoFlushRowsZero_disablesRowBasedAutoFlush() { + Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .autoFlushRows(0); + Assert.assertNotNull(builder); + } + + @Test + public void testBufferCapacity() { + Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .bufferCapacity(128 * 1024); + Assert.assertNotNull(builder); + } + + @Test + public void testBufferCapacityDoubleSet_fails() { + assertThrows("already configured", + () -> Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .bufferCapacity(1024) + .bufferCapacity(2048)); + } + + @Test + public void testBufferCapacityNegative_fails() { + assertThrows("cannot be negative", + () -> Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .bufferCapacity(-1)); + } + + @Test + public void testBuilderWithWebSocketTransport() { + Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.WEBSOCKET); + Assert.assertNotNull("Builder should be created for WebSocket transport", builder); + } + + @Test + public void testBuilderWithWebSocketTransportCreatesCorrectSenderType() throws Exception { + assertMemoryLeak(() -> { + int port; + try (java.net.ServerSocket s = new java.net.ServerSocket(0)) { + port = s.getLocalPort(); + } + assertThrowsAny( + Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST + ":" + port), + "connect", "Failed" + ); + }); + } + + @Test + public void testConnectionRefused() throws Exception { + assertMemoryLeak(() -> { + int port = findUnusedPort(); + assertThrowsAny( + Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST + ":" + port), + "connect", "Failed" + ); + }); + } + + @Test + public void testCustomTrustStore_butTlsNotEnabled_fails() { + assertThrowsAny( + Sender.builder(Sender.Transport.WEBSOCKET) + .advancedTls().customTrustStore("/some/path", "password".toCharArray()) + .address(LOCALHOST), + "TLS was not enabled"); + } + + @Test + public void testDisableAutoFlush_notSupportedForWebSocket() { + assertThrowsAny( + Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .disableAutoFlush(), + "not supported for WebSocket"); + } + + @Test + public void testDnsResolutionFailure() throws Exception { + assertMemoryLeak(() -> { + assertThrowsAny( + Sender.builder(Sender.Transport.WEBSOCKET) + .address("this-domain-does-not-exist-i-hope-better-to-use-a-silly-tld.silly-tld:9000"), + "resolve", "connect", "Failed" + ); + }); + } + + @Test + public void testDuplicateAddresses_fails() { + assertThrows("duplicated addresses", + () -> Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST + ":9000") + .address(LOCALHOST + ":9000")); + } + + @Test + @Ignore("TCP authentication is not supported for WebSocket protocol") + public void testEnableAuth_notSupported() { + assertThrowsAny( + Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .enableAuth("keyId") + .authToken("token"), + "not supported for WebSocket"); + } + + @Test + public void testFullAsyncConfiguration() { + Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .asyncMode(true) + .autoFlushRows(1000) + .autoFlushBytes(1024 * 1024) + .autoFlushIntervalMillis(100) + .inFlightWindowSize(16); + Assert.assertNotNull(builder); + } + + @Test + public void testFullAsyncConfigurationWithTls() { + Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .enableTls() + .advancedTls().disableCertificateValidation() + .asyncMode(true) + .autoFlushRows(1000) + .autoFlushBytes(1024 * 1024) + .inFlightWindowSize(16); + Assert.assertNotNull(builder); + } + + @Test + public void testHttpPath_notSupportedForWebSocket() { + assertThrowsAny( + Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .httpPath("/custom/path"), + "not supported for WebSocket"); + } + + @Test + public void testHttpTimeout_notSupportedForWebSocket() { + assertThrowsAny( + Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .httpTimeoutMillis(5000), + "not supported for WebSocket"); + } + + @Test + public void testHttpToken_fails() { + assertThrowsAny( + Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .httpToken("token"), + "not yet supported"); + } + + @Test + @Ignore("HTTP token authentication is not yet supported for WebSocket protocol") + public void testHttpToken_notYetSupported() { + assertThrowsAny( + Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .httpToken("token"), + "not yet supported"); + } + + @Test + public void testInFlightWindowSizeDoubleSet_fails() { + assertThrows("already configured", + () -> Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .asyncMode(true) + .inFlightWindowSize(8) + .inFlightWindowSize(16)); + } + + @Test + public void testInFlightWindowSizeNegative_fails() { + assertThrows("must be positive", + () -> Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .asyncMode(true) + .inFlightWindowSize(-1)); + } + + @Test + public void testInFlightWindowSizeZero_fails() { + assertThrows("must be positive", + () -> Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .asyncMode(true) + .inFlightWindowSize(0)); + } + + @Test + public void testInFlightWindowSize_withAsyncMode() { + Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .asyncMode(true) + .inFlightWindowSize(16); + Assert.assertNotNull(builder); + } + + @Test + public void testInFlightWindowSize_withoutAsyncMode_fails() { + assertThrowsAny( + Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .inFlightWindowSize(16), + "requires async mode"); + } + + @Test + public void testInvalidPort_fails() { + assertThrows("invalid port", + () -> Sender.builder(Sender.Transport.WEBSOCKET).address(LOCALHOST + ":99999")); + } + + @Test + public void testInvalidSchema_fails() { + assertBadConfig("invalid::addr=localhost:9000;", "invalid schema [schema=invalid, supported-schemas=[http, https, tcp, tcps, ws, wss]]"); + } + + @Test + public void testMalformedPortInAddress_fails() { + assertThrows("cannot parse a port from the address", + () -> Sender.builder(Sender.Transport.WEBSOCKET).address("foo:nonsense12334")); + } + + @Test + public void testMaxBackoff_notSupportedForWebSocket() { + assertThrowsAny( + Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .maxBackoffMillis(1000), + "not supported for WebSocket"); + } + + @Test + public void testMaxNameLength() { + Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .maxNameLength(256); + Assert.assertNotNull(builder); + } + + @Test + public void testMaxNameLengthDoubleSet_fails() { + assertThrows("already configured", + () -> Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .maxNameLength(128) + .maxNameLength(256)); + } + + @Test + public void testMaxNameLengthTooSmall_fails() { + assertThrows("at least 16 bytes", + () -> Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .maxNameLength(10)); + } + + @Test + public void testMinRequestThroughput_notSupportedForWebSocket() { + assertThrowsAny( + Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .minRequestThroughput(10000), + "not supported for WebSocket"); + } + + @Test + public void testMultipleAddresses_fails() { + assertThrowsAny( + Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST + ":9000") + .address(LOCALHOST + ":9001"), + "single address"); + } + + @Test + public void testNoAddress_fails() { + assertThrowsAny( + Sender.builder(Sender.Transport.WEBSOCKET), + "address not set"); + } + + @Test + public void testPortMismatch_fails() { + assertThrowsAny( + Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST + ":9000") + .port(9001), + "mismatch"); + } + + @Test + public void testProtocolVersion_notSupportedForWebSocket() { + assertThrowsAny( + Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .protocolVersion(Sender.PROTOCOL_VERSION_V2), + "not supported for WebSocket"); + } + + @Test + public void testRetryTimeout_notSupportedForWebSocket() { + assertThrowsAny( + Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .retryTimeoutMillis(5000), + "not supported for WebSocket"); + } + + @Test + public void testSyncModeAutoFlushDefaults() throws Exception { + // Regression test: sync-mode connect() must not hardcode autoFlush to 0. + // createForTesting(host, port, windowSize) mirrors what connect(h,p,tls) + // creates internally. Verify it uses sensible defaults. + assertMemoryLeak(() -> { + QwpWebSocketSender sender = QwpWebSocketSender.createForTesting("localhost", 0, 1); + try { + Assert.assertEquals( + QwpWebSocketSender.DEFAULT_AUTO_FLUSH_ROWS, + sender.getAutoFlushRows() + ); + Assert.assertEquals( + QwpWebSocketSender.DEFAULT_AUTO_FLUSH_BYTES, + sender.getAutoFlushBytes() + ); + Assert.assertEquals( + QwpWebSocketSender.DEFAULT_AUTO_FLUSH_INTERVAL_NANOS, + sender.getAutoFlushIntervalNanos() + ); + } finally { + sender.close(); + } + }); + } + + @Test + public void testSyncModeDoesNotAllowInFlightWindowSize() { + assertThrowsAny( + Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .asyncMode(false) + .inFlightWindowSize(16), + "requires async mode"); + } + + @Test + public void testSyncModeIsDefault() { + Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST); + Assert.assertNotNull(builder); + } + + @Test + public void testTcpAuth_fails() { + assertThrowsAny( + Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .enableAuth("keyId") + .authToken("5UjEMuA0Pj5pjK8a-fa24dyIf-Es5mYny3oE_Wmus48"), + "not supported for WebSocket"); + } + + @Test + public void testTlsDoubleSet_fails() { + assertThrows("already enabled", + () -> Sender.builder(Sender.Transport.WEBSOCKET) + .enableTls() + .enableTls()); + } + + @Test + public void testTlsEnabled() { + Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .enableTls(); + Assert.assertNotNull(builder); + } + + @Test + public void testTlsValidationDisabled() { + Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .enableTls() + .advancedTls().disableCertificateValidation(); + Assert.assertNotNull(builder); + } + + @Test + public void testTlsValidationDisabled_butTlsNotEnabled_fails() { + assertThrowsAny( + Sender.builder(Sender.Transport.WEBSOCKET) + .advancedTls().disableCertificateValidation() + .address(LOCALHOST), + "TLS was not enabled"); + } + + @Test + public void testUsernamePassword_fails() { + assertThrowsAny( + Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .httpUsernamePassword("user", "pass"), + "not yet supported"); + } + + @Test + @Ignore("Username/password authentication is not yet supported for WebSocket protocol") + public void testUsernamePassword_notYetSupported() { + assertThrowsAny( + Sender.builder(Sender.Transport.WEBSOCKET) + .address(LOCALHOST) + .httpUsernamePassword("user", "pass"), + "not yet supported"); + } + + @Test + public void testWsConfigString() throws Exception { + assertMemoryLeak(() -> { + int port = findUnusedPort(); + assertBadConfig("ws::addr=localhost:" + port + ";", "connect", "Failed"); + }); + } + + @Test + public void testWsConfigString_missingAddr_fails() throws Exception { + assertMemoryLeak(() -> { + int port = findUnusedPort(); + assertBadConfig("ws::addr=localhost:" + port + ";", "connect", "Failed"); + assertBadConfig("ws::foo=bar;", "addr is missing"); + }); + } + + @Test + public void testWsConfigString_protocolAlreadyConfigured_fails() throws Exception { + assertMemoryLeak(() -> { + int port = findUnusedPort(); + assertThrowsAny( + Sender.builder("ws::addr=localhost:" + port + ";") + .enableTls(), + "TLS", "connect", "Failed" + ); + }); + } + + @Test + public void testWsConfigString_uppercaseNotSupported() { + assertBadConfig("WS::addr=localhost:9000;", "invalid schema"); + } + + @Test + @Ignore("Token authentication in ws config string is not yet supported") + public void testWsConfigString_withToken_notYetSupported() { + assertBadConfig("ws::addr=localhost:9000;token=mytoken;", "not yet supported"); + } + + @Test + @Ignore("Username/password in ws config string is not yet supported") + public void testWsConfigString_withUsernamePassword_notYetSupported() { + assertBadConfig("ws::addr=localhost:9000;username=user;password=pass;", "not yet supported"); + } + + @Test + public void testWssConfigString() throws Exception { + assertMemoryLeak(() -> { + assertBadConfig("wss::addr=localhost:9000;tls_verify=unsafe_off;", "connect", "Failed", "SSL"); + }); + } + + @Test + public void testWssConfigString_uppercaseNotSupported() { + assertBadConfig("WSS::addr=localhost:9000;", "invalid schema"); + } + + @SuppressWarnings("resource") + private static void assertBadConfig(String config, String... anyOf) { + assertThrowsAny(() -> Sender.fromConfig(config), anyOf); + } + + private static void assertThrows(String expectedSubstring, Runnable action) { + try { + action.run(); + Assert.fail("Expected LineSenderException containing '" + expectedSubstring + "'"); + } catch (LineSenderException e) { + TestUtils.assertContains(e.getMessage(), expectedSubstring); + } + } + + private static void assertThrowsAny(Sender.LineSenderBuilder builder, String... anyOf) { + assertThrowsAny(builder::build, anyOf); + } + + private static void assertThrowsAny(Runnable action, String... anyOf) { + try { + action.run(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + for (String s : anyOf) { + if (msg.contains(s)) { + return; + } + } + Assert.fail("Expected message containing one of [" + String.join(", ", anyOf) + "] but got: " + msg); + } + } + + // There is a TOCTOU race between closing the ServerSocket and the caller's + // connect attempt — another process could bind the port in between. This is + // acceptable because every caller is a negative test that expects the connection + // to fail. If the port is stolen, the test connects to a non-QuestDB endpoint, + // which also fails with the same error. + private static int findUnusedPort() throws Exception { + try (java.net.ServerSocket s = new java.net.ServerSocket(0)) { + return s.getLocalPort(); + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/MicrobatchBufferTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/MicrobatchBufferTest.java new file mode 100644 index 0000000..aebfc56 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/MicrobatchBufferTest.java @@ -0,0 +1,764 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.cutlass.qwp.client.MicrobatchBuffer; +import io.questdb.client.std.MemoryTag; +import io.questdb.client.std.Unsafe; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; +import org.junit.Assert; +import org.junit.Test; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class MicrobatchBufferTest { + + @Test + public void testAwaitRecycled() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + buffer.seal(); + buffer.markSending(); + + AtomicBoolean recycled = new AtomicBoolean(false); + CountDownLatch started = new CountDownLatch(1); + + Thread waiter = new Thread(() -> { + started.countDown(); + buffer.awaitRecycled(); + recycled.set(true); + }); + waiter.start(); + + started.await(); + Thread.sleep(50); // Give waiter time to start waiting + Assert.assertFalse(recycled.get()); + + buffer.markRecycled(); + waiter.join(1000); + + Assert.assertTrue(recycled.get()); + } + }); + } + + @Test + public void testAwaitRecycledWithTimeout() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + buffer.seal(); + buffer.markSending(); + + // Should timeout + boolean result = buffer.awaitRecycled(50, TimeUnit.MILLISECONDS); + Assert.assertFalse(result); + + buffer.markRecycled(); + + // Should succeed immediately now + result = buffer.awaitRecycled(50, TimeUnit.MILLISECONDS); + Assert.assertTrue(result); + } + }); + } + + @Test + public void testBatchIdIncrementsOnReset() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + long id1 = buffer.getBatchId(); + + buffer.seal(); + buffer.markSending(); + buffer.markRecycled(); + buffer.reset(); + + long id2 = buffer.getBatchId(); + Assert.assertNotEquals(id1, id2); + + buffer.seal(); + buffer.markSending(); + buffer.markRecycled(); + buffer.reset(); + + long id3 = buffer.getBatchId(); + Assert.assertNotEquals(id2, id3); + } + }); + } + + @Test + public void testConcurrentBatchIdUniqueness() throws Exception { + int threadCount = 8; + int buffersPerThread = 500; + int totalBuffers = threadCount * buffersPerThread; + Set batchIds = ConcurrentHashMap.newKeySet(); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + + Thread[] threads = new Thread[threadCount]; + for (int t = 0; t < threadCount; t++) { + threads[t] = new Thread(() -> { + try { + startLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + try { + for (int i = 0; i < buffersPerThread; i++) { + MicrobatchBuffer buf = new MicrobatchBuffer(64); + batchIds.add(buf.getBatchId()); + buf.close(); + } + } finally { + doneLatch.countDown(); + } + }); + threads[t].start(); + } + + startLatch.countDown(); + assertTrue("Threads did not finish in time", doneLatch.await(30, TimeUnit.SECONDS)); + + assertEquals( + "Duplicate batch IDs detected: expected " + totalBuffers + " unique IDs but got " + batchIds.size(), + totalBuffers, + batchIds.size() + ); + } + + @Test + public void testConcurrentResetBatchIdUniqueness() throws Exception { + int threadCount = 8; + int resetsPerThread = 500; + int totalIds = threadCount * resetsPerThread; + Set batchIds = ConcurrentHashMap.newKeySet(); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + + Thread[] threads = new Thread[threadCount]; + for (int t = 0; t < threadCount; t++) { + threads[t] = new Thread(() -> { + try { + startLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + try { + MicrobatchBuffer buf = new MicrobatchBuffer(64); + for (int i = 0; i < resetsPerThread; i++) { + buf.seal(); + buf.markSending(); + buf.markRecycled(); + buf.reset(); + batchIds.add(buf.getBatchId()); + } + buf.close(); + } finally { + doneLatch.countDown(); + } + }); + threads[t].start(); + } + + startLatch.countDown(); + assertTrue("Threads did not finish in time", doneLatch.await(30, TimeUnit.SECONDS)); + + assertEquals( + "Duplicate batch IDs from reset(): expected " + totalIds + " unique IDs but got " + batchIds.size(), + totalIds, + batchIds.size() + ); + } + + @Test + public void testConcurrentStateTransitions() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + AtomicReference error = new AtomicReference<>(); + CountDownLatch userDone = new CountDownLatch(1); + CountDownLatch ioDone = new CountDownLatch(1); + + // Simulate user thread + Thread userThread = new Thread(() -> { + try { + buffer.writeByte((byte) 1); + buffer.incrementRowCount(); + buffer.seal(); + userDone.countDown(); + + // Wait for I/O thread to recycle + buffer.awaitRecycled(); + + // Reset and write again + buffer.reset(); + buffer.writeByte((byte) 2); + } catch (Throwable t) { + error.set(t); + } + }); + + // Simulate I/O thread + Thread ioThread = new Thread(() -> { + try { + userDone.await(); + buffer.markSending(); + + // Simulate sending + Thread.sleep(10); + + buffer.markRecycled(); + ioDone.countDown(); + } catch (Throwable t) { + error.set(t); + } + }); + + userThread.start(); + ioThread.start(); + + userThread.join(1000); + ioThread.join(1000); + + Assert.assertNull(error.get()); + Assert.assertTrue(buffer.isFilling()); + Assert.assertEquals(1, buffer.getBufferPos()); + } + }); + } + + @Test + public void testConstructionWithCustomThresholds() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024, 100, 4096, 1_000_000_000L)) { + Assert.assertEquals(1024, buffer.getBufferCapacity()); + Assert.assertTrue(buffer.isFilling()); + } + }); + } + + @Test + public void testConstructionWithDefaultThresholds() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + Assert.assertEquals(1024, buffer.getBufferCapacity()); + Assert.assertEquals(0, buffer.getBufferPos()); + Assert.assertEquals(0, buffer.getRowCount()); + Assert.assertTrue(buffer.isFilling()); + Assert.assertFalse(buffer.hasData()); + } + }); + } + + @Test(expected = IllegalArgumentException.class) + public void testConstructionWithNegativeCapacity() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer ignored = new MicrobatchBuffer(-1)) { + Assert.fail("Should have thrown"); + } + }); + } + + @Test(expected = IllegalArgumentException.class) + public void testConstructionWithZeroCapacity() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer ignored = new MicrobatchBuffer(0)) { + Assert.fail("Should have thrown"); + } + }); + } + + @Test + public void testEnsureCapacityGrows() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + buffer.ensureCapacity(2000); + Assert.assertTrue(buffer.getBufferCapacity() >= 2000); + } + }); + } + + @Test + public void testEnsureCapacityNoGrowth() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + buffer.ensureCapacity(512); + Assert.assertEquals(1024, buffer.getBufferCapacity()); // No change + } + }); + } + + @Test + public void testFirstRowTimeIsRecorded() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + Assert.assertEquals(0, buffer.getAgeNanos()); + + buffer.incrementRowCount(); + long age1 = buffer.getAgeNanos(); + Assert.assertTrue(age1 >= 0); + + Thread.sleep(10); + + long age2 = buffer.getAgeNanos(); + Assert.assertTrue(age2 > age1); + } + }); + } + + @Test + public void testFullStateLifecycle() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + // FILLING + Assert.assertTrue(buffer.isFilling()); + buffer.writeByte((byte) 1); + buffer.incrementRowCount(); + + // FILLING -> SEALED + buffer.seal(); + Assert.assertTrue(buffer.isSealed()); + + // SEALED -> SENDING + buffer.markSending(); + Assert.assertTrue(buffer.isSending()); + + // SENDING -> RECYCLED + buffer.markRecycled(); + Assert.assertTrue(buffer.isRecycled()); + + // RECYCLED -> FILLING (reset) + buffer.reset(); + Assert.assertTrue(buffer.isFilling()); + Assert.assertFalse(buffer.hasData()); + } + }); + } + + @Test + public void testIncrementRowCount() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + Assert.assertEquals(0, buffer.getRowCount()); + buffer.incrementRowCount(); + Assert.assertEquals(1, buffer.getRowCount()); + buffer.incrementRowCount(); + Assert.assertEquals(2, buffer.getRowCount()); + } + }); + } + + @Test(expected = IllegalStateException.class) + public void testIncrementRowCountWhenSealed() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + buffer.seal(); + buffer.incrementRowCount(); // Should throw + } + }); + } + + @Test + public void testInitialState() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + Assert.assertEquals(MicrobatchBuffer.STATE_FILLING, buffer.getState()); + Assert.assertTrue(buffer.isFilling()); + Assert.assertFalse(buffer.isSealed()); + Assert.assertFalse(buffer.isSending()); + Assert.assertFalse(buffer.isRecycled()); + Assert.assertFalse(buffer.isInUse()); + } + }); + } + + @Test + public void testMarkRecycledTransition() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + buffer.seal(); + buffer.markSending(); + buffer.markRecycled(); + + Assert.assertEquals(MicrobatchBuffer.STATE_RECYCLED, buffer.getState()); + Assert.assertTrue(buffer.isRecycled()); + Assert.assertFalse(buffer.isInUse()); + } + }); + } + + @Test(expected = IllegalStateException.class) + public void testMarkRecycledWhenNotSending() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + buffer.seal(); + buffer.markRecycled(); // Should throw - not sending + } + }); + } + + @Test + public void testMarkSendingTransition() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + buffer.seal(); + buffer.markSending(); + + Assert.assertEquals(MicrobatchBuffer.STATE_SENDING, buffer.getState()); + Assert.assertTrue(buffer.isSending()); + Assert.assertTrue(buffer.isInUse()); + } + }); + } + + @Test(expected = IllegalStateException.class) + public void testMarkSendingWhenNotSealed() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + buffer.markSending(); // Should throw - not sealed + } + }); + } + + @Test + public void testResetFromRecycled() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + buffer.writeByte((byte) 1); + buffer.incrementRowCount(); + long oldBatchId = buffer.getBatchId(); + + buffer.seal(); + buffer.markSending(); + buffer.markRecycled(); + buffer.reset(); + + Assert.assertTrue(buffer.isFilling()); + Assert.assertEquals(0, buffer.getBufferPos()); + Assert.assertEquals(0, buffer.getRowCount()); + Assert.assertNotEquals(oldBatchId, buffer.getBatchId()); + } + }); + } + + @Test(expected = IllegalStateException.class) + public void testResetWhenSealed() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + buffer.seal(); + buffer.reset(); // Should throw + } + }); + } + + @Test(expected = IllegalStateException.class) + public void testResetWhenSending() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + buffer.seal(); + buffer.markSending(); + buffer.reset(); // Should throw + } + }); + } + + @Test + public void testRollbackSealForRetry() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + buffer.writeByte((byte) 1); + buffer.incrementRowCount(); + + buffer.seal(); + Assert.assertTrue(buffer.isSealed()); + + buffer.rollbackSealForRetry(); + Assert.assertTrue(buffer.isFilling()); + + // Verify the same batch remains writable after rollback. + buffer.writeByte((byte) 2); + buffer.incrementRowCount(); + Assert.assertEquals(2, buffer.getBufferPos()); + Assert.assertEquals(2, buffer.getRowCount()); + } + }); + } + + @Test(expected = IllegalStateException.class) + public void testRollbackSealWhenNotSealed() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + buffer.rollbackSealForRetry(); // Should throw - not sealed + } + }); + } + + @Test + public void testSealTransition() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + buffer.writeByte((byte) 1); + buffer.seal(); + + Assert.assertEquals(MicrobatchBuffer.STATE_SEALED, buffer.getState()); + Assert.assertFalse(buffer.isFilling()); + Assert.assertTrue(buffer.isSealed()); + Assert.assertTrue(buffer.isInUse()); + } + }); + } + + @Test(expected = IllegalStateException.class) + public void testSealWhenNotFilling() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + buffer.seal(); + buffer.seal(); // Should throw + } + }); + } + + @Test + public void testSetBufferPos() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + buffer.setBufferPos(100); + Assert.assertEquals(100, buffer.getBufferPos()); + } + }); + } + + @Test(expected = IllegalArgumentException.class) + public void testSetBufferPosNegative() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + buffer.setBufferPos(-1); + } + }); + } + + @Test(expected = IllegalArgumentException.class) + public void testSetBufferPosOutOfBounds() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + buffer.setBufferPos(2000); + } + }); + } + + @Test + public void testShouldFlushAgeLimit() throws Exception { + assertMemoryLeak(() -> { + // 50ms timeout + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024, 0, 0, 50_000_000L)) { + buffer.writeByte((byte) 1); + buffer.incrementRowCount(); + Assert.assertFalse(buffer.shouldFlush()); + + Thread.sleep(60); + + Assert.assertTrue(buffer.shouldFlush()); + Assert.assertTrue(buffer.isAgeLimitExceeded()); + } + }); + } + + @Test + public void testShouldFlushByteLimit() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024, 0, 10, 0)) { + for (int i = 0; i < 9; i++) { + buffer.writeByte((byte) i); + buffer.incrementRowCount(); + Assert.assertFalse(buffer.shouldFlush()); + } + buffer.writeByte((byte) 9); + buffer.incrementRowCount(); + Assert.assertTrue(buffer.shouldFlush()); + Assert.assertTrue(buffer.isByteLimitExceeded()); + } + }); + } + + @Test + public void testShouldFlushEmptyBuffer() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024, 1, 1, 1)) { + Assert.assertFalse(buffer.shouldFlush()); // Empty buffer never flushes + } + }); + } + + @Test + public void testShouldFlushRowLimit() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024, 5, 0, 0)) { + for (int i = 0; i < 4; i++) { + buffer.writeByte((byte) i); + buffer.incrementRowCount(); + Assert.assertFalse(buffer.shouldFlush()); + } + buffer.writeByte((byte) 4); + buffer.incrementRowCount(); + Assert.assertTrue(buffer.shouldFlush()); + Assert.assertTrue(buffer.isRowLimitExceeded()); + } + }); + } + + @Test + public void testShouldFlushWithNoThresholds() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + buffer.writeByte((byte) 1); + buffer.incrementRowCount(); + Assert.assertFalse(buffer.shouldFlush()); // No thresholds set + } + }); + } + + @Test + public void testStateName() { + Assert.assertEquals("FILLING", MicrobatchBuffer.stateName(MicrobatchBuffer.STATE_FILLING)); + Assert.assertEquals("SEALED", MicrobatchBuffer.stateName(MicrobatchBuffer.STATE_SEALED)); + Assert.assertEquals("SENDING", MicrobatchBuffer.stateName(MicrobatchBuffer.STATE_SENDING)); + Assert.assertEquals("RECYCLED", MicrobatchBuffer.stateName(MicrobatchBuffer.STATE_RECYCLED)); + Assert.assertEquals("UNKNOWN(99)", MicrobatchBuffer.stateName(99)); + } + + @Test + public void testToString() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + buffer.writeByte((byte) 1); + buffer.incrementRowCount(); + + String str = buffer.toString(); + Assert.assertTrue(str.contains("MicrobatchBuffer")); + Assert.assertTrue(str.contains("state=FILLING")); + Assert.assertTrue(str.contains("rows=1")); + Assert.assertTrue(str.contains("bytes=1")); + } + }); + } + + @Test + public void testWriteBeyondInitialCapacity() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(16)) { + // Write more than initial capacity + for (int i = 0; i < 100; i++) { + buffer.writeByte((byte) i); + } + Assert.assertEquals(100, buffer.getBufferPos()); + Assert.assertTrue(buffer.getBufferCapacity() >= 100); + + // Verify data integrity after growth + for (int i = 0; i < 100; i++) { + byte read = Unsafe.getUnsafe().getByte(buffer.getBufferPtr() + i); + Assert.assertEquals((byte) i, read); + } + } + }); + } + + @Test + public void testWriteByte() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + buffer.writeByte((byte) 0x42); + Assert.assertEquals(1, buffer.getBufferPos()); + Assert.assertTrue(buffer.hasData()); + + byte read = Unsafe.getUnsafe().getByte(buffer.getBufferPtr()); + Assert.assertEquals((byte) 0x42, read); + } + }); + } + + @Test + public void testWriteFromNativeMemory() throws Exception { + assertMemoryLeak(() -> { + long src = Unsafe.malloc(10, MemoryTag.NATIVE_DEFAULT); + try { + // Fill source with test data + for (int i = 0; i < 10; i++) { + Unsafe.getUnsafe().putByte(src + i, (byte) (i + 100)); + } + + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + buffer.write(src, 10); + Assert.assertEquals(10, buffer.getBufferPos()); + + // Verify data + for (int i = 0; i < 10; i++) { + byte read = Unsafe.getUnsafe().getByte(buffer.getBufferPtr() + i); + Assert.assertEquals((byte) (i + 100), read); + } + } + } finally { + Unsafe.free(src, 10, MemoryTag.NATIVE_DEFAULT); + } + }); + } + + @Test + public void testWriteMultipleBytes() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + for (int i = 0; i < 100; i++) { + buffer.writeByte((byte) i); + } + Assert.assertEquals(100, buffer.getBufferPos()); + + // Verify data + for (int i = 0; i < 100; i++) { + byte read = Unsafe.getUnsafe().getByte(buffer.getBufferPtr() + i); + Assert.assertEquals((byte) i, read); + } + } + }); + } + + @Test(expected = IllegalStateException.class) + public void testWriteWhenSealed() throws Exception { + assertMemoryLeak(() -> { + try (MicrobatchBuffer buffer = new MicrobatchBuffer(1024)) { + buffer.seal(); + buffer.writeByte((byte) 1); // Should throw + } + }); + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/NativeBufferWriterTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/NativeBufferWriterTest.java new file mode 100644 index 0000000..a2cda57 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/NativeBufferWriterTest.java @@ -0,0 +1,525 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.cutlass.qwp.client.NativeBufferWriter; +import io.questdb.client.std.Unsafe; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; +import org.junit.Assert; +import org.junit.Test; + +import static org.junit.Assert.*; + +public class NativeBufferWriterTest { + + @Test + public void testEnsureCapacityGrowsBuffer() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter(16)) { + assertEquals(16, writer.getCapacity()); + writer.ensureCapacity(32); + assertTrue(writer.getCapacity() >= 32); + } + }); + } + + @Test + public void testGrowBuffer() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter(16)) { + // Write more than initial capacity + for (int i = 0; i < 100; i++) { + writer.putLong(i); + } + Assert.assertEquals(800, writer.getPosition()); + // Verify data + for (int i = 0; i < 100; i++) { + Assert.assertEquals(i, Unsafe.getUnsafe().getLong(writer.getBufferPtr() + i * 8)); + } + } + }); + } + + @Test + public void testMultipleWrites() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter()) { + writer.putByte((byte) 'I'); + writer.putByte((byte) 'L'); + writer.putByte((byte) 'P'); + writer.putByte((byte) '4'); + writer.putByte((byte) 1); // Version + writer.putByte((byte) 0); // Flags + writer.putShort((short) 1); // Table count + writer.putInt(0); // Payload length placeholder + + Assert.assertEquals(12, writer.getPosition()); + + // Verify QWP1 header + Assert.assertEquals((byte) 'Q', Unsafe.getUnsafe().getByte(writer.getBufferPtr())); + Assert.assertEquals((byte) 'W', Unsafe.getUnsafe().getByte(writer.getBufferPtr() + 1)); + Assert.assertEquals((byte) 'P', Unsafe.getUnsafe().getByte(writer.getBufferPtr() + 2)); + Assert.assertEquals((byte) '1', Unsafe.getUnsafe().getByte(writer.getBufferPtr() + 3)); + } + }); + } + + @Test + public void testNativeBufferWriterUtf8LengthInvalidSurrogatePair() throws Exception { + assertMemoryLeak(() -> { + // High surrogate followed by non-low-surrogate: '?' (1) + 'X' (1) = 2 + assertEquals(2, NativeBufferWriter.utf8Length("\uD800X")); + // Lone high surrogate at end: '?' (1) + assertEquals(1, NativeBufferWriter.utf8Length("\uD800")); + // Lone low surrogate: '?' (1) + assertEquals(1, NativeBufferWriter.utf8Length("\uDC00")); + // Valid pair still works: 4 bytes + assertEquals(4, NativeBufferWriter.utf8Length("\uD83D\uDE00")); + }); + } + + @Test + public void testPatchInt() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter()) { + writer.putInt(0); // Placeholder at offset 0 + writer.putInt(100); // At offset 4 + writer.patchInt(0, 42); // Patch first int + Assert.assertEquals(42, Unsafe.getUnsafe().getInt(writer.getBufferPtr())); + Assert.assertEquals(100, Unsafe.getUnsafe().getInt(writer.getBufferPtr() + 4)); + } + }); + } + + @Test + public void testPatchIntAtLastValidOffset() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter(16)) { + writer.putLong(0L); // 8 bytes, position = 8 + // Patch at offset 4 covers bytes [4..7], exactly at the boundary + writer.patchInt(4, 0x1234); + assertEquals(0x1234, Unsafe.getUnsafe().getInt(writer.getBufferPtr() + 4)); + } + }); + } + + @Test + public void testPatchIntAtValidOffset() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter(16)) { + writer.putInt(0); // placeholder at offset 0 + writer.putInt(0xBEEF); // data at offset 4 + // Patch the placeholder + writer.patchInt(0, 0xCAFE); + assertEquals(0xCAFE, Unsafe.getUnsafe().getInt(writer.getBufferPtr())); + assertEquals(0xBEEF, Unsafe.getUnsafe().getInt(writer.getBufferPtr() + 4)); + } + }); + } + + @Test + public void testPutBlockOfBytes() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter(); + NativeBufferWriter source = new NativeBufferWriter()) { + // Prepare source data + source.putByte((byte) 1); + source.putByte((byte) 2); + source.putByte((byte) 3); + source.putByte((byte) 4); + + // Copy to writer + writer.putBlockOfBytes(source.getBufferPtr(), 4); + Assert.assertEquals(4, writer.getPosition()); + Assert.assertEquals((byte) 1, Unsafe.getUnsafe().getByte(writer.getBufferPtr())); + Assert.assertEquals((byte) 2, Unsafe.getUnsafe().getByte(writer.getBufferPtr() + 1)); + Assert.assertEquals((byte) 3, Unsafe.getUnsafe().getByte(writer.getBufferPtr() + 2)); + Assert.assertEquals((byte) 4, Unsafe.getUnsafe().getByte(writer.getBufferPtr() + 3)); + } + }); + } + + @Test + public void testPutBlockOfBytesRejectsLenExceedingIntMax() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter(16)) { + try { + writer.putBlockOfBytes(0, (long) Integer.MAX_VALUE + 1); + fail("expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + Assert.assertTrue(e.getMessage().contains("len")); + } + } + }); + } + + @Test + public void testPutUtf8InvalidSurrogatePair() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter(64)) { + // High surrogate \uD800 followed by non-low-surrogate 'X'. + // Should produce '?' for the lone high surrogate, then 'X'. + writer.putUtf8("\uD800X"); + assertEquals(2, writer.getPosition()); + assertEquals((byte) '?', Unsafe.getUnsafe().getByte(writer.getBufferPtr())); + assertEquals((byte) 'X', Unsafe.getUnsafe().getByte(writer.getBufferPtr() + 1)); + } + }); + } + + @Test + public void testPutUtf8LoneHighSurrogateAtEnd() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter(64)) { + writer.putUtf8("\uD800"); + assertEquals(1, writer.getPosition()); + assertEquals((byte) '?', Unsafe.getUnsafe().getByte(writer.getBufferPtr())); + } + }); + } + + @Test + public void testPutUtf8LoneLowSurrogate() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter(64)) { + writer.putUtf8("\uDC00"); + assertEquals(1, writer.getPosition()); + assertEquals((byte) '?', Unsafe.getUnsafe().getByte(writer.getBufferPtr())); + } + }); + } + + @Test + public void testPutUtf8LoneSurrogateMatchesUtf8Length() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter(64)) { + // Verify putUtf8 and utf8Length agree for all lone surrogate cases + String[] cases = {"\uD800", "\uDBFF", "\uDC00", "\uDFFF", "\uD800X", "A\uDC00B"}; + for (String s : cases) { + writer.reset(); + writer.putUtf8(s); + assertEquals("length mismatch for: " + s.codePoints() + .mapToObj(cp -> String.format("U+%04X", cp)) + .reduce((a, b) -> a + " " + b).orElse(""), + NativeBufferWriter.utf8Length(s), writer.getPosition()); + } + } + }); + } + + @Test + public void testQwpBufferWriterUtf8LengthInvalidSurrogatePair() throws Exception { + assertMemoryLeak(() -> { + // High surrogate followed by non-low-surrogate: '?' (1) + 'X' (1) = 2 + assertEquals(2, NativeBufferWriter.utf8Length("\uD800X")); + // Lone high surrogate at end: '?' (1) + assertEquals(1, NativeBufferWriter.utf8Length("\uD800")); + // Lone low surrogate: '?' (1) + assertEquals(1, NativeBufferWriter.utf8Length("\uDC00")); + // Valid pair still works: 4 bytes + assertEquals(4, NativeBufferWriter.utf8Length("\uD83D\uDE00")); + }); + } + + @Test + public void testReset() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter()) { + writer.putInt(12345); + Assert.assertEquals(4, writer.getPosition()); + writer.reset(); + Assert.assertEquals(0, writer.getPosition()); + // Can write again + writer.putByte((byte) 0xFF); + Assert.assertEquals(1, writer.getPosition()); + } + }); + } + + @Test + public void testSkipAdvancesPosition() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter(16)) { + writer.skip(4); + assertEquals(4, writer.getPosition()); + writer.skip(8); + assertEquals(12, writer.getPosition()); + } + }); + } + + @Test + public void testSkipBeyondCapacityGrowsBuffer() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter(16)) { + // skip past the 16-byte buffer — must grow, not corrupt memory + writer.skip(32); + assertEquals(32, writer.getPosition()); + assertTrue(writer.getCapacity() >= 32); + // writing after the skip must also succeed + writer.putInt(0xCAFE); + assertEquals(36, writer.getPosition()); + assertEquals(0xCAFE, Unsafe.getUnsafe().getInt(writer.getBufferPtr() + 32)); + } + }); + } + + @Test + public void testSkipThenPatchInt() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter(8)) { + int patchOffset = writer.getPosition(); + writer.skip(4); // reserve space for a length field + writer.putInt(0xDEAD); + // Patch the reserved space + writer.patchInt(patchOffset, 4); + assertEquals(0x4, Unsafe.getUnsafe().getInt(writer.getBufferPtr() + patchOffset)); + assertEquals(0xDEAD, Unsafe.getUnsafe().getInt(writer.getBufferPtr() + 4)); + } + }); + } + + @Test + public void testUtf8Length() throws Exception { + assertMemoryLeak(() -> { + Assert.assertEquals(0, NativeBufferWriter.utf8Length(null)); + Assert.assertEquals(0, NativeBufferWriter.utf8Length("")); + Assert.assertEquals(5, NativeBufferWriter.utf8Length("hello")); + Assert.assertEquals(2, NativeBufferWriter.utf8Length("ñ")); + Assert.assertEquals(3, NativeBufferWriter.utf8Length("€")); + }); + } + + @Test + public void testWriteByte() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter()) { + writer.putByte((byte) 0x42); + Assert.assertEquals(1, writer.getPosition()); + Assert.assertEquals((byte) 0x42, Unsafe.getUnsafe().getByte(writer.getBufferPtr())); + } + }); + } + + @Test + public void testWriteDouble() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter()) { + writer.putDouble(3.14159265359); + Assert.assertEquals(8, writer.getPosition()); + Assert.assertEquals(3.14159265359, Unsafe.getUnsafe().getDouble(writer.getBufferPtr()), 0.0000000001); + } + }); + } + + @Test + public void testWriteEmptyString() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter()) { + writer.putString(""); + Assert.assertEquals(1, writer.getPosition()); + Assert.assertEquals((byte) 0, Unsafe.getUnsafe().getByte(writer.getBufferPtr())); + } + }); + } + + @Test + public void testWriteFloat() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter()) { + writer.putFloat(3.14f); + Assert.assertEquals(4, writer.getPosition()); + Assert.assertEquals(3.14f, Unsafe.getUnsafe().getFloat(writer.getBufferPtr()), 0.0001f); + } + }); + } + + @Test + public void testWriteInt() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter()) { + writer.putInt(0x12345678); + Assert.assertEquals(4, writer.getPosition()); + // Little-endian + Assert.assertEquals(0x12345678, Unsafe.getUnsafe().getInt(writer.getBufferPtr())); + } + }); + } + + @Test + public void testWriteLong() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter()) { + writer.putLong(0x123456789ABCDEF0L); + Assert.assertEquals(8, writer.getPosition()); + Assert.assertEquals(0x123456789ABCDEF0L, Unsafe.getUnsafe().getLong(writer.getBufferPtr())); + } + }); + } + + @Test + public void testWriteLongBigEndian() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter()) { + writer.putLongBE(0x0102030405060708L); + Assert.assertEquals(8, writer.getPosition()); + // Check big-endian byte order + long ptr = writer.getBufferPtr(); + Assert.assertEquals((byte) 0x01, Unsafe.getUnsafe().getByte(ptr)); + Assert.assertEquals((byte) 0x02, Unsafe.getUnsafe().getByte(ptr + 1)); + Assert.assertEquals((byte) 0x03, Unsafe.getUnsafe().getByte(ptr + 2)); + Assert.assertEquals((byte) 0x04, Unsafe.getUnsafe().getByte(ptr + 3)); + Assert.assertEquals((byte) 0x05, Unsafe.getUnsafe().getByte(ptr + 4)); + Assert.assertEquals((byte) 0x06, Unsafe.getUnsafe().getByte(ptr + 5)); + Assert.assertEquals((byte) 0x07, Unsafe.getUnsafe().getByte(ptr + 6)); + Assert.assertEquals((byte) 0x08, Unsafe.getUnsafe().getByte(ptr + 7)); + } + }); + } + + @Test + public void testWriteNullString() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter()) { + writer.putString(null); + Assert.assertEquals(1, writer.getPosition()); + Assert.assertEquals((byte) 0, Unsafe.getUnsafe().getByte(writer.getBufferPtr())); + } + }); + } + + @Test + public void testWriteShort() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter()) { + writer.putShort((short) 0x1234); + Assert.assertEquals(2, writer.getPosition()); + // Little-endian + Assert.assertEquals((byte) 0x34, Unsafe.getUnsafe().getByte(writer.getBufferPtr())); + Assert.assertEquals((byte) 0x12, Unsafe.getUnsafe().getByte(writer.getBufferPtr() + 1)); + } + }); + } + + @Test + public void testWriteString() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter()) { + writer.putString("hello"); + // Length (1 byte varint) + 5 bytes + Assert.assertEquals(6, writer.getPosition()); + // Check length + Assert.assertEquals((byte) 5, Unsafe.getUnsafe().getByte(writer.getBufferPtr())); + // Check content + Assert.assertEquals((byte) 'h', Unsafe.getUnsafe().getByte(writer.getBufferPtr() + 1)); + Assert.assertEquals((byte) 'e', Unsafe.getUnsafe().getByte(writer.getBufferPtr() + 2)); + Assert.assertEquals((byte) 'l', Unsafe.getUnsafe().getByte(writer.getBufferPtr() + 3)); + Assert.assertEquals((byte) 'l', Unsafe.getUnsafe().getByte(writer.getBufferPtr() + 4)); + Assert.assertEquals((byte) 'o', Unsafe.getUnsafe().getByte(writer.getBufferPtr() + 5)); + } + }); + } + + @Test + public void testWriteUtf8Ascii() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter()) { + writer.putUtf8("ABC"); + Assert.assertEquals(3, writer.getPosition()); + Assert.assertEquals((byte) 'A', Unsafe.getUnsafe().getByte(writer.getBufferPtr())); + Assert.assertEquals((byte) 'B', Unsafe.getUnsafe().getByte(writer.getBufferPtr() + 1)); + Assert.assertEquals((byte) 'C', Unsafe.getUnsafe().getByte(writer.getBufferPtr() + 2)); + } + }); + } + + @Test + public void testWriteUtf8ThreeByte() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter()) { + // € is 3 bytes in UTF-8 + writer.putUtf8("€"); + Assert.assertEquals(3, writer.getPosition()); + Assert.assertEquals((byte) 0xE2, Unsafe.getUnsafe().getByte(writer.getBufferPtr())); + Assert.assertEquals((byte) 0x82, Unsafe.getUnsafe().getByte(writer.getBufferPtr() + 1)); + Assert.assertEquals((byte) 0xAC, Unsafe.getUnsafe().getByte(writer.getBufferPtr() + 2)); + } + }); + } + + @Test + public void testWriteUtf8TwoByte() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter()) { + // ñ is 2 bytes in UTF-8 + writer.putUtf8("ñ"); + Assert.assertEquals(2, writer.getPosition()); + Assert.assertEquals((byte) 0xC3, Unsafe.getUnsafe().getByte(writer.getBufferPtr())); + Assert.assertEquals((byte) 0xB1, Unsafe.getUnsafe().getByte(writer.getBufferPtr() + 1)); + } + }); + } + + @Test + public void testWriteVarintLarge() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter()) { + // Test larger value + writer.putVarint(16384); + Assert.assertEquals(3, writer.getPosition()); + // LEB128: 16384 = 0x80 0x80 0x01 + Assert.assertEquals((byte) 0x80, Unsafe.getUnsafe().getByte(writer.getBufferPtr())); + Assert.assertEquals((byte) 0x80, Unsafe.getUnsafe().getByte(writer.getBufferPtr() + 1)); + Assert.assertEquals((byte) 0x01, Unsafe.getUnsafe().getByte(writer.getBufferPtr() + 2)); + } + }); + } + + @Test + public void testWriteVarintMedium() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter()) { + // Two bytes for 128 + writer.putVarint(128); + Assert.assertEquals(2, writer.getPosition()); + // LEB128: 128 = 0x80 0x01 + Assert.assertEquals((byte) 0x80, Unsafe.getUnsafe().getByte(writer.getBufferPtr())); + Assert.assertEquals((byte) 0x01, Unsafe.getUnsafe().getByte(writer.getBufferPtr() + 1)); + } + }); + } + + @Test + public void testWriteVarintSmall() throws Exception { + assertMemoryLeak(() -> { + try (NativeBufferWriter writer = new NativeBufferWriter()) { + // Single byte for values < 128 + writer.putVarint(127); + Assert.assertEquals(1, writer.getPosition()); + Assert.assertEquals((byte) 127, Unsafe.getUnsafe().getByte(writer.getBufferPtr())); + } + }); + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpDeltaDictRollbackTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpDeltaDictRollbackTest.java new file mode 100644 index 0000000..1fa657e --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpDeltaDictRollbackTest.java @@ -0,0 +1,93 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.cutlass.qwp.client.InFlightWindow; +import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; +import io.questdb.client.test.AbstractTest; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; +import org.junit.Assert; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.time.temporal.ChronoUnit; + +/** + * Verifies that maxSentSymbolId and sentSchemaHashes are not updated + * when the send fails, so the next batch's delta dictionary correctly + * re-includes symbols the server never received. + */ +public class QwpDeltaDictRollbackTest extends AbstractTest { + + @Test + public void testSyncFlushFailureDoesNotAdvanceMaxSentSymbolId() throws Exception { + assertMemoryLeak(() -> { + // Sync mode (window=1), not connected to any server + QwpWebSocketSender sender = QwpWebSocketSender.createForTesting("localhost", 0, 1); + try { + // Bypass ensureConnected() by marking as connected. + // Leave client null so sendBinary() will throw. + setField(sender, "connected", true); + setField(sender, "inFlightWindow", new InFlightWindow(1, InFlightWindow.DEFAULT_TIMEOUT_MS)); + + // Buffer a row with a symbol — this registers symbol id 0 + // in the global dictionary and sets currentBatchMaxSymbolId = 0 + sender.table("t") + .symbol("s", "val1") + .at(1, ChronoUnit.MICROS); + + // maxSentSymbolId should still be -1 (nothing sent yet) + Assert.assertEquals(-1, sender.getMaxSentSymbolId()); + + // flush() -> flushSync() -> encode succeeds -> client.sendBinary() throws NPE + // because client is null (we never actually connected) + try { + sender.flush(); + Assert.fail("Expected NullPointerException from null client"); + } catch (NullPointerException expected) { + // sendBinary() on null client + } + + // The fix: maxSentSymbolId must remain -1 because the send failed. + // Without the fix, it would have been advanced to 0 before the throw, + // causing the next batch's delta dictionary to omit symbol "val1". + Assert.assertEquals( + "maxSentSymbolId must not advance when send fails", + -1, sender.getMaxSentSymbolId() + ); + } finally { + // Mark as not connected so close() doesn't try to flush again + setField(sender, "connected", false); + sender.close(); + } + }); + } + + private static void setField(Object target, String fieldName, Object value) throws Exception { + Field f = target.getClass().getDeclaredField(fieldName); + f.setAccessible(true); + f.set(target, value); + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpSenderTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpSenderTest.java new file mode 100644 index 0000000..0d0d157 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpSenderTest.java @@ -0,0 +1,8006 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; +import io.questdb.client.std.Decimal128; +import io.questdb.client.std.Decimal256; +import io.questdb.client.std.Decimal64; +import io.questdb.client.test.cutlass.line.AbstractLineSenderTest; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +/** + * Integration tests for the QWP (QuestDB Wire Protocol) WebSocket sender. + *

    + * Tests verify that all QWP native types arrive correctly (exact type match) + * and that reasonable type coercions work (e.g., client sends INT but server + * column is LONG). + *

    + * Tests are skipped if no QuestDB instance is running + * ({@code -Dquestdb.running=true}). + */ +public class QwpSenderTest extends AbstractLineSenderTest { + + @BeforeClass + public static void setUpStatic() { + AbstractLineSenderTest.setUpStatic(); + } + + @Test + public void testBoolToString() throws Exception { + String table = "test_qwp_bool_to_string"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "s STRING, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .boolColumn("s", true) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .boolColumn("s", false) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "s\tts\n" + + "true\t1970-01-01T00:00:01.000000000Z\n" + + "false\t1970-01-01T00:00:02.000000000Z\n", + "SELECT s, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testBoolToVarchar() throws Exception { + String table = "test_qwp_bool_to_varchar"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "v VARCHAR, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .boolColumn("v", true) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .boolColumn("v", false) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "v\tts\n" + + "true\t1970-01-01T00:00:01.000000000Z\n" + + "false\t1970-01-01T00:00:02.000000000Z\n", + "SELECT v, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testBoolean() throws Exception { + String table = "test_qwp_boolean"; + useTable(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .boolColumn("b", true) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .boolColumn("b", false) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "b\ttimestamp\n" + + "true\t1970-01-01T00:00:01.000000000Z\n" + + "false\t1970-01-01T00:00:02.000000000Z\n", + "SELECT b, timestamp FROM " + table + " ORDER BY timestamp"); + } + + @Test + public void testBooleanToByteCoercionError() throws Exception { + String table = "test_qwp_boolean_to_byte_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v BYTE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .boolColumn("v", true) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write BOOLEAN") && msg.contains("BYTE") + ); + } + } + + @Test + public void testBooleanToCharCoercionError() throws Exception { + String table = "test_qwp_boolean_to_char_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v CHAR, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .boolColumn("v", true) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write BOOLEAN") && msg.contains("CHAR") + ); + } + } + + @Test + public void testBooleanToDateCoercionError() throws Exception { + String table = "test_qwp_boolean_to_date_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v DATE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .boolColumn("v", true) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write BOOLEAN") && msg.contains("DATE") + ); + } + } + + @Test + public void testBooleanToDecimalCoercionError() throws Exception { + String table = "test_qwp_boolean_to_decimal_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v DECIMAL(18,2), ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .boolColumn("v", true) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write BOOLEAN") && msg.contains("DECIMAL") + ); + } + } + + @Test + public void testBooleanToDoubleCoercionError() throws Exception { + String table = "test_qwp_boolean_to_double_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v DOUBLE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .boolColumn("v", true) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write BOOLEAN") && msg.contains("DOUBLE") + ); + } + } + + @Test + public void testBooleanToFloatCoercionError() throws Exception { + String table = "test_qwp_boolean_to_float_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v FLOAT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .boolColumn("v", true) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write BOOLEAN") && msg.contains("FLOAT") + ); + } + } + + @Test + public void testBooleanToGeoHashCoercionError() throws Exception { + String table = "test_qwp_boolean_to_geohash_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v GEOHASH(5c), ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .boolColumn("v", true) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write BOOLEAN") && msg.contains("GEOHASH") + ); + } + } + + @Test + public void testBooleanToIntCoercionError() throws Exception { + String table = "test_qwp_boolean_to_int_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v INT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .boolColumn("v", true) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write BOOLEAN") && msg.contains("INT") + ); + } + } + + @Test + public void testBooleanToLong256CoercionError() throws Exception { + String table = "test_qwp_boolean_to_long256_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v LONG256, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .boolColumn("v", true) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write BOOLEAN") && msg.contains("LONG256") + ); + } + } + + @Test + public void testBooleanToLongCoercionError() throws Exception { + String table = "test_qwp_boolean_to_long_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v LONG, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .boolColumn("v", true) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write BOOLEAN") && msg.contains("LONG") + ); + } + } + + @Test + public void testBooleanToShortCoercionError() throws Exception { + String table = "test_qwp_boolean_to_short_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v SHORT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .boolColumn("v", true) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write BOOLEAN") && msg.contains("SHORT") + ); + } + } + + @Test + public void testBooleanToSymbolCoercionError() throws Exception { + String table = "test_qwp_boolean_to_symbol_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v SYMBOL, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .boolColumn("v", true) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write BOOLEAN") && msg.contains("SYMBOL") + ); + } + } + + @Test + public void testBooleanToTimestampCoercionError() throws Exception { + String table = "test_qwp_boolean_to_timestamp_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v TIMESTAMP, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .boolColumn("v", true) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write BOOLEAN") && msg.contains("TIMESTAMP") + ); + } + } + + @Test + public void testBooleanToTimestampNsCoercionError() throws Exception { + String table = "test_qwp_boolean_to_timestamp_ns_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v TIMESTAMP_NS, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .boolColumn("v", true) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write BOOLEAN") && msg.contains("TIMESTAMP") + ); + } + } + + @Test + public void testBooleanToUuidCoercionError() throws Exception { + String table = "test_qwp_boolean_to_uuid_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v UUID, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .boolColumn("v", true) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write BOOLEAN") && msg.contains("UUID") + ); + } + } + + @Test + public void testByte() throws Exception { + String table = "test_qwp_byte"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "b BYTE, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .shortColumn("b", (short) -1) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .shortColumn("b", (short) 0) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .shortColumn("b", (short) 127) + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + } + + @Test + public void testByteToBooleanCoercionError() throws Exception { + String table = "test_qwp_byte_to_boolean_error"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "b BOOLEAN, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .byteColumn("b", (byte) 1) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected error mentioning BYTE and BOOLEAN but got: " + msg, + msg.contains("BYTE") && msg.contains("BOOLEAN") + ); + } + } + + @Test + public void testByteToCharCoercionError() throws Exception { + String table = "test_qwp_byte_to_char_error"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "c CHAR, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .byteColumn("c", (byte) 65) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected error mentioning BYTE and CHAR but got: " + msg, + msg.contains("BYTE") && msg.contains("CHAR") + ); + } + } + + @Test + public void testByteToDate() throws Exception { + String table = "test_qwp_byte_to_date"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DATE, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .byteColumn("d", (byte) 100) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .byteColumn("d", (byte) 0) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "1970-01-01T00:00:00.100000000Z\t1970-01-01T00:00:01.000000000Z\n" + + "1970-01-01T00:00:00.000000000Z\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testByteToDecimal() throws Exception { + String table = "test_qwp_byte_to_decimal"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(6, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .byteColumn("d", (byte) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .byteColumn("d", (byte) -100) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "42.00\t1970-01-01T00:00:01.000000000Z\n" + + "-100.00\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testByteToDecimal128() throws Exception { + String table = "test_qwp_byte_to_decimal128"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(38, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .byteColumn("d", (byte) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .byteColumn("d", (byte) -1) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "42.00\t1970-01-01T00:00:01.000000000Z\n" + + "-1.00\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testByteToDecimal16() throws Exception { + String table = "test_qwp_byte_to_decimal16"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(4, 1), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .byteColumn("d", (byte) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .byteColumn("d", (byte) -9) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "42.0\t1970-01-01T00:00:01.000000000Z\n" + + "-9.0\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testByteToDecimal256() throws Exception { + String table = "test_qwp_byte_to_decimal256"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(76, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .byteColumn("d", (byte) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .byteColumn("d", (byte) -1) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "42.00\t1970-01-01T00:00:01.000000000Z\n" + + "-1.00\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testByteToDecimal64() throws Exception { + String table = "test_qwp_byte_to_decimal64"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(18, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .byteColumn("d", (byte) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .byteColumn("d", (byte) -1) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "42.00\t1970-01-01T00:00:01.000000000Z\n" + + "-1.00\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testByteToDecimal8() throws Exception { + String table = "test_qwp_byte_to_decimal8"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(2, 1), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .byteColumn("d", (byte) 5) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .byteColumn("d", (byte) -9) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "5.0\t1970-01-01T00:00:01.000000000Z\n" + + "-9.0\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testByteToDouble() throws Exception { + String table = "test_qwp_byte_to_double"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DOUBLE, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .byteColumn("d", (byte) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .byteColumn("d", (byte) -100) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "42.0\t1970-01-01T00:00:01.000000000Z\n" + + "-100.0\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testByteToFloat() throws Exception { + String table = "test_qwp_byte_to_float"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "f FLOAT, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .byteColumn("f", (byte) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .byteColumn("f", (byte) -100) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "f\tts\n" + + "42.0\t1970-01-01T00:00:01.000000000Z\n" + + "-100.0\t1970-01-01T00:00:02.000000000Z\n", + "SELECT f, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testByteToGeoHashCoercionError() throws Exception { + String table = "test_qwp_byte_to_geohash_error"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "g GEOHASH(4c), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .byteColumn("g", (byte) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error mentioning BYTE but got: " + msg, + msg.contains("type coercion from BYTE to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testByteToInt() throws Exception { + String table = "test_qwp_byte_to_int"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "i INT, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .byteColumn("i", (byte) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .byteColumn("i", Byte.MAX_VALUE) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .byteColumn("i", Byte.MIN_VALUE) + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "i\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "127\t1970-01-01T00:00:02.000000000Z\n" + + "-128\t1970-01-01T00:00:03.000000000Z\n", + "SELECT i, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testByteToLong() throws Exception { + String table = "test_qwp_byte_to_long"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "l LONG, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .byteColumn("l", (byte) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .byteColumn("l", Byte.MAX_VALUE) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .byteColumn("l", Byte.MIN_VALUE) + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "l\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "127\t1970-01-01T00:00:02.000000000Z\n" + + "-128\t1970-01-01T00:00:03.000000000Z\n", + "SELECT l, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testByteToLong256CoercionError() throws Exception { + String table = "test_qwp_byte_to_long256_error"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "v LONG256, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .byteColumn("v", (byte) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from BYTE to LONG256 is not supported") + ); + } + } + + @Test + public void testByteToShort() throws Exception { + String table = "test_qwp_byte_to_short"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "s SHORT, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .byteColumn("s", (byte) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .byteColumn("s", Byte.MIN_VALUE) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .byteColumn("s", Byte.MAX_VALUE) + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "s\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "-128\t1970-01-01T00:00:02.000000000Z\n" + + "127\t1970-01-01T00:00:03.000000000Z\n", + "SELECT s, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testByteToString() throws Exception { + String table = "test_qwp_byte_to_string"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "s STRING, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .byteColumn("s", (byte) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .byteColumn("s", (byte) -100) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .byteColumn("s", (byte) 0) + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "s\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "-100\t1970-01-01T00:00:02.000000000Z\n" + + "0\t1970-01-01T00:00:03.000000000Z\n", + "SELECT s, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testByteToSymbol() throws Exception { + String table = "test_qwp_byte_to_symbol"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "s SYMBOL, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .byteColumn("s", (byte) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .byteColumn("s", (byte) -1) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .byteColumn("s", (byte) 0) + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "s\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "-1\t1970-01-01T00:00:02.000000000Z\n" + + "0\t1970-01-01T00:00:03.000000000Z\n", + "SELECT s, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testByteToTimestamp() throws Exception { + String table = "test_qwp_byte_to_timestamp"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "t TIMESTAMP, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .byteColumn("t", (byte) 100) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .byteColumn("t", (byte) 0) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "t\tts\n" + + "1970-01-01T00:00:00.000100000Z\t1970-01-01T00:00:01.000000000Z\n" + + "1970-01-01T00:00:00.000000000Z\t1970-01-01T00:00:02.000000000Z\n", + "SELECT t, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testByteToUuidCoercionError() throws Exception { + String table = "test_qwp_byte_to_uuid_error"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "u UUID, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .byteColumn("u", (byte) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from BYTE to UUID is not supported") + ); + } + } + + @Test + public void testByteToVarchar() throws Exception { + String table = "test_qwp_byte_to_varchar"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "v VARCHAR, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .byteColumn("v", (byte) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .byteColumn("v", (byte) -100) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .byteColumn("v", Byte.MAX_VALUE) + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "v\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "-100\t1970-01-01T00:00:02.000000000Z\n" + + "127\t1970-01-01T00:00:03.000000000Z\n", + "SELECT v, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testChar() throws Exception { + String table = "test_qwp_char"; + useTable(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .charColumn("c", 'A') + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .charColumn("c", 'ü') // ü + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .charColumn("c", '中') // 中 + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "c\ttimestamp\n" + + "A\t1970-01-01T00:00:01.000000000Z\n" + + "ü\t1970-01-01T00:00:02.000000000Z\n" + + "中\t1970-01-01T00:00:03.000000000Z\n", + "SELECT c, timestamp FROM " + table + " ORDER BY timestamp"); + } + + @Test + public void testCharToBooleanCoercionError() throws Exception { + String table = "test_qwp_char_to_boolean_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v BOOLEAN, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .charColumn("v", 'A') + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write") && msg.contains("BOOLEAN") + ); + } + } + + @Test + public void testCharToByteCoercionError() throws Exception { + String table = "test_qwp_char_to_byte_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v BYTE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .charColumn("v", 'A') + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("not supported") && msg.contains("BYTE") + ); + } + } + + @Test + public void testCharToDateCoercionError() throws Exception { + String table = "test_qwp_char_to_date_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v DATE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .charColumn("v", 'A') + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("not supported") && msg.contains("DATE") + ); + } + } + + @Test + public void testCharToDoubleCoercionError() throws Exception { + String table = "test_qwp_char_to_double_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v DOUBLE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .charColumn("v", 'A') + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("not supported") && msg.contains("DOUBLE") + ); + } + } + + @Test + public void testCharToFloatCoercionError() throws Exception { + String table = "test_qwp_char_to_float_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v FLOAT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .charColumn("v", 'A') + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("not supported") && msg.contains("FLOAT") + ); + } + } + + @Test + public void testCharToGeoHashCoercionError() throws Exception { + String table = "test_qwp_char_to_geohash_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v GEOHASH(5c), ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .charColumn("v", 'A') + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("GEOHASH") + ); + } + } + + @Test + public void testCharToIntCoercionError() throws Exception { + String table = "test_qwp_char_to_int_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v INT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .charColumn("v", 'A') + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("not supported") && msg.contains("INT") + ); + } + } + + @Test + public void testCharToLong256CoercionError() throws Exception { + String table = "test_qwp_char_to_long256_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v LONG256, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .charColumn("v", 'A') + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("not supported") && msg.contains("LONG256") + ); + } + } + + @Test + public void testCharToLongCoercionError() throws Exception { + String table = "test_qwp_char_to_long_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v LONG, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .charColumn("v", 'A') + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("not supported") && msg.contains("LONG") + ); + } + } + + @Test + public void testCharToShortCoercionError() throws Exception { + String table = "test_qwp_char_to_short_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v SHORT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .charColumn("v", 'A') + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("not supported") && msg.contains("SHORT") + ); + } + } + + @Test + public void testCharToString() throws Exception { + String table = "test_qwp_char_to_string"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "s STRING, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .charColumn("s", 'A') + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .charColumn("s", 'Z') + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "s\tts\n" + + "A\t1970-01-01T00:00:01.000000000Z\n" + + "Z\t1970-01-01T00:00:02.000000000Z\n", + "SELECT s, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testCharToSymbolCoercionError() throws Exception { + String table = "test_qwp_char_to_symbol_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v SYMBOL, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .charColumn("v", 'A') + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write") && msg.contains("SYMBOL") + ); + } + } + + @Test + public void testCharToUuidCoercionError() throws Exception { + String table = "test_qwp_char_to_uuid_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v UUID, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .charColumn("v", 'A') + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("not supported") && msg.contains("UUID") + ); + } + } + + @Test + public void testCharToVarchar() throws Exception { + String table = "test_qwp_char_to_varchar"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "v VARCHAR, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .charColumn("v", 'A') + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .charColumn("v", 'Z') + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "v\tts\n" + + "A\t1970-01-01T00:00:01.000000000Z\n" + + "Z\t1970-01-01T00:00:02.000000000Z\n", + "SELECT v, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testDecimal() throws Exception { + String table = "test_qwp_decimal"; + useTable(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .decimalColumn("d", "123.45") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .decimalColumn("d", "-999.99") + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .decimalColumn("d", "0.01") + .at(3_000_000, ChronoUnit.MICROS); + sender.table(table) + .decimalColumn("d", Decimal256.fromLong(42_000, 2)) + .at(4_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 4); + } + + @Test + public void testDecimal128ToDecimal256() throws Exception { + String table = "test_qwp_dec128_to_dec256"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(76, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .decimalColumn("d", Decimal128.fromLong(12345, 2)) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .decimalColumn("d", Decimal128.fromLong(-9999, 2)) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "123.45\t1970-01-01T00:00:01.000000000Z\n" + + "-99.99\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testDecimal128ToDecimal64() throws Exception { + String table = "test_qwp_dec128_to_dec64"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(18, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .decimalColumn("d", Decimal128.fromLong(12345, 2)) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .decimalColumn("d", Decimal128.fromLong(-9999, 2)) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "123.45\t1970-01-01T00:00:01.000000000Z\n" + + "-99.99\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testDecimal256ToDecimal128() throws Exception { + String table = "test_qwp_dec256_to_dec128"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(38, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .decimalColumn("d", Decimal256.fromLong(12345, 2)) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .decimalColumn("d", Decimal256.fromLong(-9999, 2)) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "123.45\t1970-01-01T00:00:01.000000000Z\n" + + "-99.99\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testDecimal256ToDecimal64() throws Exception { + String table = "test_qwp_dec256_to_dec64"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(18, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + // Send DECIMAL256 wire type to DECIMAL64 column + sender.table(table) + .decimalColumn("d", Decimal256.fromLong(12345, 2)) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .decimalColumn("d", Decimal256.fromLong(-9999, 2)) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "123.45\t1970-01-01T00:00:01.000000000Z\n" + + "-99.99\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testDecimal256ToDecimal64OverflowError() throws Exception { + String table = "test_qwp_dec256_to_dec64_overflow"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(18, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + // Create a value that fits in Decimal256 but overflows Decimal64 + // Decimal256 with hi bits set will overflow 64-bit storage + Decimal256 bigValue = Decimal256.fromBigDecimal(new java.math.BigDecimal("99999999999999999999.99")); + sender.table(table) + .decimalColumn("d", bigValue) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected overflow error but got: " + msg, + msg.contains("decimal value overflows") + ); + } + } + + @Test + public void testDecimal256ToDecimal8OverflowError() throws Exception { + String table = "test_qwp_dec256_to_dec8_overflow"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(2, 1), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + // 999.9 with scale=1 → unscaled 9999, which doesn't fit in a byte (-128..127) + sender.table(table) + .decimalColumn("d", Decimal256.fromLong(9999, 1)) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected overflow error but got: " + msg, + msg.contains("decimal value overflows") + ); + } + } + + @Test + public void testDecimal64ToDecimal128() throws Exception { + String table = "test_qwp_dec64_to_dec128"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(38, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + // Send DECIMAL64 wire type to DECIMAL128 column (widening) + sender.table(table) + .decimalColumn("d", Decimal64.fromLong(12345, 2)) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .decimalColumn("d", Decimal64.fromLong(-9999, 2)) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "123.45\t1970-01-01T00:00:01.000000000Z\n" + + "-99.99\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testDecimal64ToDecimal256() throws Exception { + String table = "test_qwp_dec64_to_dec256"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(76, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .decimalColumn("d", Decimal64.fromLong(12345, 2)) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .decimalColumn("d", Decimal64.fromLong(-9999, 2)) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "123.45\t1970-01-01T00:00:01.000000000Z\n" + + "-99.99\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testDecimalRescale() throws Exception { + String table = "test_qwp_decimal_rescale"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(18, 4), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + // Send scale=2 wire data to scale=4 column: server should rescale + sender.table(table) + .decimalColumn("d", Decimal64.fromLong(12345, 2)) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .decimalColumn("d", Decimal64.fromLong(-100, 2)) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "123.4500\t1970-01-01T00:00:01.000000000Z\n" + + "-1.0000\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testDecimalToBooleanCoercionError() throws Exception { + String table = "test_qwp_decimal_to_boolean_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v BOOLEAN, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .decimalColumn("v", Decimal64.fromLong(12345L, 2)) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write DECIMAL64") && msg.contains("BOOLEAN") + ); + } + } + + @Test + public void testDecimalToByteCoercionError() throws Exception { + String table = "test_qwp_decimal_to_byte_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v BYTE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .decimalColumn("v", Decimal64.fromLong(12345L, 2)) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write DECIMAL64") && msg.contains("BYTE") + ); + } + } + + @Test + public void testDecimalToCharCoercionError() throws Exception { + String table = "test_qwp_decimal_to_char_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v CHAR, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .decimalColumn("v", Decimal64.fromLong(12345L, 2)) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write DECIMAL64") && msg.contains("CHAR") + ); + } + } + + @Test + public void testDecimalToDateCoercionError() throws Exception { + String table = "test_qwp_decimal_to_date_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v DATE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .decimalColumn("v", Decimal64.fromLong(12345L, 2)) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write DECIMAL64") && msg.contains("DATE") + ); + } + } + + @Test + public void testDecimalToDoubleCoercionError() throws Exception { + String table = "test_qwp_decimal_to_double_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v DOUBLE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .decimalColumn("v", Decimal64.fromLong(12345L, 2)) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write DECIMAL64") && msg.contains("DOUBLE") + ); + } + } + + @Test + public void testDecimalToFloatCoercionError() throws Exception { + String table = "test_qwp_decimal_to_float_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v FLOAT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .decimalColumn("v", Decimal64.fromLong(12345L, 2)) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write DECIMAL64") && msg.contains("FLOAT") + ); + } + } + + @Test + public void testDecimalToGeoHashCoercionError() throws Exception { + String table = "test_qwp_decimal_to_geohash_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v GEOHASH(5c), ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .decimalColumn("v", Decimal64.fromLong(12345L, 2)) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write DECIMAL64") && msg.contains("GEOHASH") + ); + } + } + + @Test + public void testDecimalToIntCoercionError() throws Exception { + String table = "test_qwp_decimal_to_int_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v INT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .decimalColumn("v", Decimal64.fromLong(12345L, 2)) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write DECIMAL64") && msg.contains("INT") + ); + } + } + + @Test + public void testDecimalToLong256CoercionError() throws Exception { + String table = "test_qwp_decimal_to_long256_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v LONG256, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .decimalColumn("v", Decimal64.fromLong(12345L, 2)) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write DECIMAL64") && msg.contains("LONG256") + ); + } + } + + @Test + public void testDecimalToLongCoercionError() throws Exception { + String table = "test_qwp_decimal_to_long_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v LONG, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .decimalColumn("v", Decimal64.fromLong(12345L, 2)) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write DECIMAL64") && msg.contains("LONG") + ); + } + } + + @Test + public void testDecimalToShortCoercionError() throws Exception { + String table = "test_qwp_decimal_to_short_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v SHORT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .decimalColumn("v", Decimal64.fromLong(12345L, 2)) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write DECIMAL64") && msg.contains("SHORT") + ); + } + } + + @Test + public void testDecimalToString() throws Exception { + String table = "test_qwp_decimal_to_string"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "s STRING, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .decimalColumn("s", Decimal64.fromLong(12345, 2)) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .decimalColumn("s", Decimal64.fromLong(-9999, 2)) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "s\tts\n" + + "123.45\t1970-01-01T00:00:01.000000000Z\n" + + "-99.99\t1970-01-01T00:00:02.000000000Z\n", + "SELECT s, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testDecimalToSymbolCoercionError() throws Exception { + String table = "test_qwp_decimal_to_symbol_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v SYMBOL, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .decimalColumn("v", Decimal64.fromLong(12345L, 2)) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write DECIMAL64") && msg.contains("SYMBOL") + ); + } + } + + @Test + public void testDecimalToTimestampCoercionError() throws Exception { + String table = "test_qwp_decimal_to_timestamp_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v TIMESTAMP, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .decimalColumn("v", Decimal64.fromLong(12345L, 2)) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write DECIMAL64") && msg.contains("TIMESTAMP") + ); + } + } + + @Test + public void testDecimalToTimestampNsCoercionError() throws Exception { + String table = "test_qwp_decimal_to_timestamp_ns_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v TIMESTAMP_NS, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .decimalColumn("v", Decimal64.fromLong(12345L, 2)) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write DECIMAL64") && msg.contains("TIMESTAMP") + ); + } + } + + @Test + public void testDecimalToUuidCoercionError() throws Exception { + String table = "test_qwp_decimal_to_uuid_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v UUID, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .decimalColumn("v", Decimal64.fromLong(12345L, 2)) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write DECIMAL64") && msg.contains("UUID") + ); + } + } + + @Test + public void testDecimalToVarchar() throws Exception { + String table = "test_qwp_decimal_to_varchar"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "v VARCHAR, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .decimalColumn("v", Decimal64.fromLong(12345, 2)) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .decimalColumn("v", Decimal64.fromLong(-9999, 2)) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "v\tts\n" + + "123.45\t1970-01-01T00:00:01.000000000Z\n" + + "-99.99\t1970-01-01T00:00:02.000000000Z\n", + "SELECT v, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testDouble() throws Exception { + String table = "test_qwp_double"; + useTable(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .doubleColumn("d", 42.5) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .doubleColumn("d", -1.0E10) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .doubleColumn("d", Double.MAX_VALUE) + .at(3_000_000, ChronoUnit.MICROS); + sender.table(table) + .doubleColumn("d", Double.MIN_VALUE) + .at(4_000_000, ChronoUnit.MICROS); + // NaN and Inf should be stored as null + sender.table(table) + .doubleColumn("d", Double.NaN) + .at(5_000_000, ChronoUnit.MICROS); + sender.table(table) + .doubleColumn("d", Double.POSITIVE_INFINITY) + .at(6_000_000, ChronoUnit.MICROS); + sender.table(table) + .doubleColumn("d", Double.NEGATIVE_INFINITY) + .at(7_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 7); + assertSqlEventually( + "d\ttimestamp\n" + + "42.5\t1970-01-01T00:00:01.000000000Z\n" + + "-1.0E10\t1970-01-01T00:00:02.000000000Z\n" + + "1.7976931348623157E308\t1970-01-01T00:00:03.000000000Z\n" + + "4.9E-324\t1970-01-01T00:00:04.000000000Z\n" + + "null\t1970-01-01T00:00:05.000000000Z\n" + + "null\t1970-01-01T00:00:06.000000000Z\n" + + "null\t1970-01-01T00:00:07.000000000Z\n", + "SELECT d, timestamp FROM " + table + " ORDER BY timestamp"); + } + + @Test + public void testDoubleArray() throws Exception { + String table = "test_qwp_double_array"; + useTable(table); + + double[] arr1d = createDoubleArray(5); + double[][] arr2d = createDoubleArray(2, 3); + double[][][] arr3d = createDoubleArray(1, 2, 3); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .doubleArray("a1", arr1d) + .doubleArray("a2", arr2d) + .doubleArray("a3", arr3d) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 1); + } + + @Test + public void testDoubleArrayToIntCoercionError() throws Exception { + String table = "test_qwp_doublearray_to_int_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v INT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .doubleArray("v", new double[]{1.0, 2.0}) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write DOUBLE_ARRAY") && msg.contains("INT") + ); + } + } + + @Test + public void testDoubleArrayToStringCoercionError() throws Exception { + String table = "test_qwp_doublearray_to_string_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v STRING, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .doubleArray("v", new double[]{1.0, 2.0}) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write DOUBLE_ARRAY") && msg.contains("STRING") + ); + } + } + + @Test + public void testDoubleArrayToSymbolCoercionError() throws Exception { + String table = "test_qwp_doublearray_to_symbol_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v SYMBOL, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .doubleArray("v", new double[]{1.0, 2.0}) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write DOUBLE_ARRAY") && msg.contains("SYMBOL") + ); + } + } + + @Test + public void testDoubleArrayToTimestampCoercionError() throws Exception { + String table = "test_qwp_doublearray_to_timestamp_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v TIMESTAMP, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .doubleArray("v", new double[]{1.0, 2.0}) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write DOUBLE_ARRAY") && msg.contains("TIMESTAMP") + ); + } + } + + @Test + public void testDoubleToBooleanCoercionError() throws Exception { + String table = "test_qwp_double_to_boolean_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v BOOLEAN, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .doubleColumn("v", 3.14) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write DOUBLE") && msg.contains("BOOLEAN") + ); + } + } + + @Test + public void testDoubleToByte() throws Exception { + String table = "test_qwp_double_to_byte"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "b BYTE, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .doubleColumn("b", 42.0) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .doubleColumn("b", -100.0) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "b\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "-100\t1970-01-01T00:00:02.000000000Z\n", + "SELECT b, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testDoubleToByteOverflowError() throws Exception { + String table = "test_qwp_double_to_byte_ovf"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "b BYTE, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .doubleColumn("b", 200.0) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected overflow error but got: " + msg, + msg.contains("integer value 200 out of range for BYTE") + ); + } + } + + @Test + public void testDoubleToBytePrecisionLossError() throws Exception { + String table = "test_qwp_double_to_byte_prec"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "b BYTE, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .doubleColumn("b", 42.5) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected precision loss error but got: " + msg, + msg.contains("loses precision") && msg.contains("42.5") + ); + } + } + + @Test + public void testDoubleToCharCoercionError() throws Exception { + String table = "test_qwp_double_to_char_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v CHAR, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .doubleColumn("v", 3.14) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write DOUBLE") && msg.contains("CHAR") + ); + } + } + + @Test + public void testDoubleToDateCoercionError() throws Exception { + String table = "test_qwp_double_to_date_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v DATE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .doubleColumn("v", 3.14) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from DOUBLE to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testDoubleToDecimal() throws Exception { + String table = "test_qwp_double_to_decimal"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(10, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .doubleColumn("d", 123.45) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .doubleColumn("d", -42.10) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "123.45\t1970-01-01T00:00:01.000000000Z\n" + + "-42.10\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testDoubleToDecimalPrecisionLossError() throws Exception { + String table = "test_qwp_double_to_decimal_prec"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(10, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .doubleColumn("d", 123.456) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected precision loss error but got: " + msg, + msg.contains("cannot be converted to") && msg.contains("123.456") && msg.contains("scale=2") + ); + } + } + + @Test + public void testDoubleToFloat() throws Exception { + String table = "test_qwp_double_to_float"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "f FLOAT, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .doubleColumn("f", 1.5) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .doubleColumn("f", -42.25) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + } + + @Test + public void testDoubleToGeoHashCoercionError() throws Exception { + String table = "test_qwp_double_to_geohash_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v GEOHASH(5c), ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .doubleColumn("v", 3.14) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from DOUBLE to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testDoubleToInt() throws Exception { + String table = "test_qwp_double_to_int"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "i INT, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .doubleColumn("i", 100_000.0) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .doubleColumn("i", -42.0) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "i\tts\n" + + "100000\t1970-01-01T00:00:01.000000000Z\n" + + "-42\t1970-01-01T00:00:02.000000000Z\n", + "SELECT i, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testDoubleToIntPrecisionLossError() throws Exception { + String table = "test_qwp_double_to_int_prec"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "i INT, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .doubleColumn("i", 3.14) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected precision loss error but got: " + msg, + msg.contains("loses precision") && msg.contains("3.14") + ); + } + } + + @Test + public void testDoubleToLong() throws Exception { + String table = "test_qwp_double_to_long"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "l LONG, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .doubleColumn("l", 1_000_000.0) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .doubleColumn("l", -42.0) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "l\tts\n" + + "1000000\t1970-01-01T00:00:01.000000000Z\n" + + "-42\t1970-01-01T00:00:02.000000000Z\n", + "SELECT l, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testDoubleToLong256CoercionError() throws Exception { + String table = "test_qwp_double_to_long256_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v LONG256, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .doubleColumn("v", 3.14) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from DOUBLE to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testDoubleToShort() throws Exception { + String table = "test_qwp_double_to_short"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "v SHORT, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .doubleColumn("v", 100.0) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .doubleColumn("v", -200.0) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "v\tts\n" + + "100\t1970-01-01T00:00:01.000000000Z\n" + + "-200\t1970-01-01T00:00:02.000000000Z\n", + "SELECT v, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testDoubleToString() throws Exception { + String table = "test_qwp_double_to_string"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "s STRING, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .doubleColumn("s", 3.14) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .doubleColumn("s", -42.0) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "s\tts\n" + + "3.14\t1970-01-01T00:00:01.000000000Z\n" + + "-42.0\t1970-01-01T00:00:02.000000000Z\n", + "SELECT s, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testDoubleToSymbol() throws Exception { + String table = "test_qwp_double_to_symbol"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "sym SYMBOL, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .doubleColumn("sym", 3.14) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 1); + assertSqlEventually( + "sym\tts\n" + + "3.14\t1970-01-01T00:00:01.000000000Z\n", + "SELECT sym, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testDoubleToUuidCoercionError() throws Exception { + String table = "test_qwp_double_to_uuid_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v UUID, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .doubleColumn("v", 3.14) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from DOUBLE to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testDoubleToVarchar() throws Exception { + String table = "test_qwp_double_to_varchar"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "v VARCHAR, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .doubleColumn("v", 3.14) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .doubleColumn("v", -42.0) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "v\tts\n" + + "3.14\t1970-01-01T00:00:01.000000000Z\n" + + "-42.0\t1970-01-01T00:00:02.000000000Z\n", + "SELECT v, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testFloat() throws Exception { + String table = "test_qwp_float"; + useTable(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .floatColumn("f", 1.5f) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .floatColumn("f", -42.25f) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .floatColumn("f", 0.0f) + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + } + + @Test + public void testFloatToBooleanCoercionError() throws Exception { + String table = "test_qwp_float_to_boolean_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v BOOLEAN, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .floatColumn("v", 1.5f) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write FLOAT") && msg.contains("BOOLEAN") + ); + } + } + + @Test + public void testFloatToByte() throws Exception { + String table = "test_qwp_float_to_byte"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "v BYTE, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .floatColumn("v", 7.0f) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .floatColumn("v", -100.0f) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "v\tts\n" + + "7\t1970-01-01T00:00:01.000000000Z\n" + + "-100\t1970-01-01T00:00:02.000000000Z\n", + "SELECT v, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testFloatToCharCoercionError() throws Exception { + String table = "test_qwp_float_to_char_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v CHAR, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .floatColumn("v", 1.5f) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write FLOAT") && msg.contains("CHAR") + ); + } + } + + @Test + public void testFloatToDateCoercionError() throws Exception { + String table = "test_qwp_float_to_date_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v DATE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .floatColumn("v", 1.5f) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from FLOAT to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testFloatToDecimal() throws Exception { + String table = "test_qwp_float_to_decimal"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(10, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .floatColumn("d", 1.5f) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .floatColumn("d", -42.25f) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "1.50\t1970-01-01T00:00:01.000000000Z\n" + + "-42.25\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testFloatToDecimalPrecisionLossError() throws Exception { + String table = "test_qwp_float_to_decimal_prec"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(10, 1), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .floatColumn("d", 1.25f) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected precision loss error but got: " + msg, + msg.contains("cannot be converted to") && msg.contains("scale=1") + ); + } + } + + @Test + public void testFloatToDouble() throws Exception { + String table = "test_qwp_float_to_double"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DOUBLE, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .floatColumn("d", 1.5f) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .floatColumn("d", -42.25f) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "1.5\t1970-01-01T00:00:01.000000000Z\n" + + "-42.25\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testFloatToGeoHashCoercionError() throws Exception { + String table = "test_qwp_float_to_geohash_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v GEOHASH(5c), ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .floatColumn("v", 1.5f) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from FLOAT to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testFloatToInt() throws Exception { + String table = "test_qwp_float_to_int"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "i INT, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .floatColumn("i", 42.0f) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .floatColumn("i", -100.0f) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "i\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "-100\t1970-01-01T00:00:02.000000000Z\n", + "SELECT i, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testFloatToIntPrecisionLossError() throws Exception { + String table = "test_qwp_float_to_int_prec"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "i INT, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .floatColumn("i", 3.14f) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected precision loss error but got: " + msg, + msg.contains("loses precision") + ); + } + } + + @Test + public void testFloatToLong() throws Exception { + String table = "test_qwp_float_to_long"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "l LONG, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .floatColumn("l", 1000.0f) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 1); + assertSqlEventually( + "l\tts\n" + + "1000\t1970-01-01T00:00:01.000000000Z\n", + "SELECT l, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testFloatToLong256CoercionError() throws Exception { + String table = "test_qwp_float_to_long256_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v LONG256, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .floatColumn("v", 1.5f) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from FLOAT to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testFloatToShort() throws Exception { + String table = "test_qwp_float_to_short"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "v SHORT, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .floatColumn("v", 42.0f) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .floatColumn("v", -1000.0f) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "v\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "-1000\t1970-01-01T00:00:02.000000000Z\n", + "SELECT v, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testFloatToString() throws Exception { + String table = "test_qwp_float_to_string"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "s STRING, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .floatColumn("s", 1.5f) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 1); + assertSqlEventually( + "s\tts\n" + + "1.5\t1970-01-01T00:00:01.000000000Z\n", + "SELECT s, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testFloatToSymbol() throws Exception { + String table = "test_qwp_float_to_symbol"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "sym SYMBOL, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .floatColumn("sym", 1.5f) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 1); + assertSqlEventually( + "sym\tts\n" + + "1.5\t1970-01-01T00:00:01.000000000Z\n", + "SELECT sym, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testFloatToUuidCoercionError() throws Exception { + String table = "test_qwp_float_to_uuid_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v UUID, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .floatColumn("v", 1.5f) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from FLOAT to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testFloatToVarchar() throws Exception { + String table = "test_qwp_float_to_varchar"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "v VARCHAR, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .floatColumn("v", 1.5f) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 1); + assertSqlEventually( + "v\tts\n" + + "1.5\t1970-01-01T00:00:01.000000000Z\n", + "SELECT v, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testInt() throws Exception { + String table = "test_qwp_int"; + useTable(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + // Integer.MIN_VALUE is the null sentinel for INT + sender.table(table) + .intColumn("i", Integer.MIN_VALUE) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("i", 0) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("i", Integer.MAX_VALUE) + .at(3_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("i", -42) + .at(4_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 4); + assertSqlEventually( + "i\ttimestamp\n" + + "null\t1970-01-01T00:00:01.000000000Z\n" + + "0\t1970-01-01T00:00:02.000000000Z\n" + + "2147483647\t1970-01-01T00:00:03.000000000Z\n" + + "-42\t1970-01-01T00:00:04.000000000Z\n", + "SELECT i, timestamp FROM " + table + " ORDER BY timestamp"); + } + + @Test + public void testIntToBooleanCoercionError() throws Exception { + String table = "test_qwp_int_to_boolean_error"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "b BOOLEAN, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .intColumn("b", 1) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected error mentioning INT and BOOLEAN but got: " + msg, + msg.contains("INT") && msg.contains("BOOLEAN") + ); + } + } + + @Test + public void testIntToByte() throws Exception { + String table = "test_qwp_int_to_byte"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "b BYTE, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .intColumn("b", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("b", -128) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("b", 127) + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "b\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "-128\t1970-01-01T00:00:02.000000000Z\n" + + "127\t1970-01-01T00:00:03.000000000Z\n", + "SELECT b, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testIntToByteOverflowError() throws Exception { + String table = "test_qwp_int_to_byte_overflow"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "b BYTE, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .intColumn("b", 128) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected overflow error but got: " + msg, + msg.contains("integer value 128 out of range for BYTE") + ); + } + } + + @Test + public void testIntToCharCoercionError() throws Exception { + String table = "test_qwp_int_to_char_error"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "c CHAR, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .intColumn("c", 65) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected error mentioning INT and CHAR but got: " + msg, + msg.contains("INT") && msg.contains("CHAR") + ); + } + } + + @Test + public void testIntToDate() throws Exception { + String table = "test_qwp_int_to_date"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DATE, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + // 86_400_000 millis = 1 day + sender.table(table) + .intColumn("d", 86_400_000) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("d", 0) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "1970-01-02T00:00:00.000000000Z\t1970-01-01T00:00:01.000000000Z\n" + + "1970-01-01T00:00:00.000000000Z\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testIntToDecimal() throws Exception { + String table = "test_qwp_int_to_decimal"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(6, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .intColumn("d", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("d", -100) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "42.00\t1970-01-01T00:00:01.000000000Z\n" + + "-100.00\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testIntToDecimal128() throws Exception { + String table = "test_qwp_int_to_decimal128"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(38, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .intColumn("d", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("d", -100) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("d", 0) + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "d\tts\n" + + "42.00\t1970-01-01T00:00:01.000000000Z\n" + + "-100.00\t1970-01-01T00:00:02.000000000Z\n" + + "0.00\t1970-01-01T00:00:03.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testIntToDecimal16() throws Exception { + String table = "test_qwp_int_to_decimal16"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(4, 1), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .intColumn("d", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("d", -100) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("d", 0) + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "d\tts\n" + + "42.0\t1970-01-01T00:00:01.000000000Z\n" + + "-100.0\t1970-01-01T00:00:02.000000000Z\n" + + "0.0\t1970-01-01T00:00:03.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testIntToDecimal256() throws Exception { + String table = "test_qwp_int_to_decimal256"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(76, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .intColumn("d", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("d", -100) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("d", 0) + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "d\tts\n" + + "42.00\t1970-01-01T00:00:01.000000000Z\n" + + "-100.00\t1970-01-01T00:00:02.000000000Z\n" + + "0.00\t1970-01-01T00:00:03.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testIntToDecimal64() throws Exception { + String table = "test_qwp_int_to_decimal64"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(18, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .intColumn("d", Integer.MAX_VALUE) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("d", -100) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("d", 0) + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "d\tts\n" + + "2147483647.00\t1970-01-01T00:00:01.000000000Z\n" + + "-100.00\t1970-01-01T00:00:02.000000000Z\n" + + "0.00\t1970-01-01T00:00:03.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testIntToDecimal8() throws Exception { + String table = "test_qwp_int_to_decimal8"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(2, 1), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .intColumn("d", 5) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("d", -9) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("d", 0) + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "d\tts\n" + + "5.0\t1970-01-01T00:00:01.000000000Z\n" + + "-9.0\t1970-01-01T00:00:02.000000000Z\n" + + "0.0\t1970-01-01T00:00:03.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testIntToDouble() throws Exception { + String table = "test_qwp_int_to_double"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DOUBLE, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .intColumn("d", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("d", -100) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "42.0\t1970-01-01T00:00:01.000000000Z\n" + + "-100.0\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testIntToFloat() throws Exception { + String table = "test_qwp_int_to_float"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "f FLOAT, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .intColumn("f", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("f", -100) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("f", 0) + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "f\tts\n" + + "42.0\t1970-01-01T00:00:01.000000000Z\n" + + "-100.0\t1970-01-01T00:00:02.000000000Z\n" + + "0.0\t1970-01-01T00:00:03.000000000Z\n", + "SELECT f, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testIntToGeoHashCoercionError() throws Exception { + String table = "test_qwp_int_to_geohash_error"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "g GEOHASH(4c), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .intColumn("g", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error mentioning INT but got: " + msg, + msg.contains("type coercion from INT to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testIntToLong() throws Exception { + String table = "test_qwp_int_to_long"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "l LONG, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .intColumn("l", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("l", Integer.MAX_VALUE) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("l", -1) + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "l\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "2147483647\t1970-01-01T00:00:02.000000000Z\n" + + "-1\t1970-01-01T00:00:03.000000000Z\n", + "SELECT l, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testIntToLong256CoercionError() throws Exception { + String table = "test_qwp_int_to_long256_error"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "v LONG256, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .intColumn("v", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from INT to LONG256 is not supported") + ); + } + } + + @Test + public void testIntToShort() throws Exception { + String table = "test_qwp_int_to_short"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "s SHORT, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .intColumn("s", 1000) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("s", -32768) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("s", 32767) + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "s\tts\n" + + "1000\t1970-01-01T00:00:01.000000000Z\n" + + "-32768\t1970-01-01T00:00:02.000000000Z\n" + + "32767\t1970-01-01T00:00:03.000000000Z\n", + "SELECT s, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testIntToShortOverflowError() throws Exception { + String table = "test_qwp_int_to_short_overflow"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "s SHORT, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .intColumn("s", 32768) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected overflow error but got: " + msg, + msg.contains("integer value 32768 out of range for SHORT") + ); + } + } + + @Test + public void testIntToString() throws Exception { + String table = "test_qwp_int_to_string"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "s STRING, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .intColumn("s", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("s", -100) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("s", 0) + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "s\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "-100\t1970-01-01T00:00:02.000000000Z\n" + + "0\t1970-01-01T00:00:03.000000000Z\n", + "SELECT s, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testIntToSymbol() throws Exception { + String table = "test_qwp_int_to_symbol"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "s SYMBOL, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .intColumn("s", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("s", -1) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("s", 0) + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "s\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "-1\t1970-01-01T00:00:02.000000000Z\n" + + "0\t1970-01-01T00:00:03.000000000Z\n", + "SELECT s, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testIntToTimestamp() throws Exception { + String table = "test_qwp_int_to_timestamp"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "t TIMESTAMP, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + // 1_000_000 micros = 1 second + sender.table(table) + .intColumn("t", 1_000_000) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("t", 0) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "t\tts\n" + + "1970-01-01T00:00:01.000000000Z\t1970-01-01T00:00:01.000000000Z\n" + + "1970-01-01T00:00:00.000000000Z\t1970-01-01T00:00:02.000000000Z\n", + "SELECT t, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testIntToUuidCoercionError() throws Exception { + String table = "test_qwp_int_to_uuid_error"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "u UUID, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .intColumn("u", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from INT to UUID is not supported") + ); + } + } + + @Test + public void testIntToVarchar() throws Exception { + String table = "test_qwp_int_to_varchar"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "v VARCHAR, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .intColumn("v", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("v", -100) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .intColumn("v", Integer.MAX_VALUE) + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "v\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "-100\t1970-01-01T00:00:02.000000000Z\n" + + "2147483647\t1970-01-01T00:00:03.000000000Z\n", + "SELECT v, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testLong() throws Exception { + String table = "test_qwp_long"; + useTable(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + // Long.MIN_VALUE is the null sentinel for LONG + sender.table(table) + .longColumn("l", Long.MIN_VALUE) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .longColumn("l", 0) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .longColumn("l", Long.MAX_VALUE) + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "l\ttimestamp\n" + + "null\t1970-01-01T00:00:01.000000000Z\n" + + "0\t1970-01-01T00:00:02.000000000Z\n" + + "9223372036854775807\t1970-01-01T00:00:03.000000000Z\n", + "SELECT l, timestamp FROM " + table + " ORDER BY timestamp"); + } + + @Test + public void testLong256() throws Exception { + String table = "test_qwp_long256"; + useTable(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + // All zeros + sender.table(table) + .long256Column("v", 0, 0, 0, 0) + .at(1_000_000, ChronoUnit.MICROS); + // Mixed values + sender.table(table) + .long256Column("v", 1, 2, 3, 4) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + } + + @Test + public void testLong256ToBooleanCoercionError() throws Exception { + String table = "test_qwp_long256_to_boolean_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v BOOLEAN, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .long256Column("v", 1L, 0L, 0L, 0L) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write LONG256") && msg.contains("BOOLEAN") + ); + } + } + + @Test + public void testLong256ToByteCoercionError() throws Exception { + String table = "test_qwp_long256_to_byte_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v BYTE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .long256Column("v", 1L, 0L, 0L, 0L) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from LONG256 to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testLong256ToCharCoercionError() throws Exception { + String table = "test_qwp_long256_to_char_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v CHAR, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .long256Column("v", 1L, 0L, 0L, 0L) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write LONG256") && msg.contains("CHAR") + ); + } + } + + @Test + public void testLong256ToDateCoercionError() throws Exception { + String table = "test_qwp_long256_to_date_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v DATE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .long256Column("v", 1L, 0L, 0L, 0L) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from LONG256 to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testLong256ToDoubleCoercionError() throws Exception { + String table = "test_qwp_long256_to_double_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v DOUBLE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .long256Column("v", 1L, 0L, 0L, 0L) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from LONG256 to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testLong256ToFloatCoercionError() throws Exception { + String table = "test_qwp_long256_to_float_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v FLOAT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .long256Column("v", 1L, 0L, 0L, 0L) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from LONG256 to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testLong256ToGeoHashCoercionError() throws Exception { + String table = "test_qwp_long256_to_geohash_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v GEOHASH(5c), ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .long256Column("v", 1L, 0L, 0L, 0L) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from LONG256 to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testLong256ToIntCoercionError() throws Exception { + String table = "test_qwp_long256_to_int_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v INT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .long256Column("v", 1L, 0L, 0L, 0L) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from LONG256 to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testLong256ToLongCoercionError() throws Exception { + String table = "test_qwp_long256_to_long_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v LONG, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .long256Column("v", 1L, 0L, 0L, 0L) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from LONG256 to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testLong256ToShortCoercionError() throws Exception { + String table = "test_qwp_long256_to_short_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v SHORT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .long256Column("v", 1L, 0L, 0L, 0L) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from LONG256 to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testLong256ToString() throws Exception { + String table = "test_qwp_long256_to_string"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "s STRING, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .long256Column("s", 1, 2, 3, 4) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 1); + assertSqlEventually( + "s\tts\n" + + "0x04000000000000000300000000000000020000000000000001\t1970-01-01T00:00:01.000000000Z\n", + "SELECT s, ts FROM " + table); + } + + @Test + public void testLong256ToSymbolCoercionError() throws Exception { + String table = "test_qwp_long256_to_symbol_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v SYMBOL, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .long256Column("v", 1L, 0L, 0L, 0L) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write LONG256") && msg.contains("SYMBOL") + ); + } + } + + @Test + public void testLong256ToUuidCoercionError() throws Exception { + String table = "test_qwp_long256_to_uuid_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v UUID, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .long256Column("v", 1L, 0L, 0L, 0L) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from LONG256 to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testLong256ToVarchar() throws Exception { + String table = "test_qwp_long256_to_varchar"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "v VARCHAR, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .long256Column("v", 1, 2, 3, 4) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 1); + assertSqlEventually( + "v\tts\n" + + "0x04000000000000000300000000000000020000000000000001\t1970-01-01T00:00:01.000000000Z\n", + "SELECT v, ts FROM " + table); + } + + @Test + public void testLongToBooleanCoercionError() throws Exception { + String table = "test_qwp_long_to_boolean_error"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "b BOOLEAN, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .longColumn("b", 1) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected error mentioning LONG and BOOLEAN but got: " + msg, + msg.contains("LONG") && msg.contains("BOOLEAN") + ); + } + } + + @Test + public void testLongToByte() throws Exception { + String table = "test_qwp_long_to_byte"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "b BYTE, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .longColumn("b", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .longColumn("b", -128) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .longColumn("b", 127) + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "b\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "-128\t1970-01-01T00:00:02.000000000Z\n" + + "127\t1970-01-01T00:00:03.000000000Z\n", + "SELECT b, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testLongToByteOverflowError() throws Exception { + String table = "test_qwp_long_to_byte_overflow"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "b BYTE, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .longColumn("b", 128) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected overflow error but got: " + msg, + msg.contains("integer value 128 out of range for BYTE") + ); + } + } + + @Test + public void testLongToCharCoercionError() throws Exception { + String table = "test_qwp_long_to_char_error"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "c CHAR, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .longColumn("c", 65) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected error mentioning LONG and CHAR but got: " + msg, + msg.contains("LONG") && msg.contains("CHAR") + ); + } + } + + @Test + public void testLongToDate() throws Exception { + String table = "test_qwp_long_to_date"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DATE, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .longColumn("d", 86_400_000L) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .longColumn("d", 0L) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "1970-01-02T00:00:00.000000000Z\t1970-01-01T00:00:01.000000000Z\n" + + "1970-01-01T00:00:00.000000000Z\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testLongToDecimal() throws Exception { + String table = "test_qwp_long_to_decimal"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(10, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .longColumn("d", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .longColumn("d", -100) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "42.00\t1970-01-01T00:00:01.000000000Z\n" + + "-100.00\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testLongToDecimal128() throws Exception { + String table = "test_qwp_long_to_decimal128"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(38, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .longColumn("d", 1_000_000_000L) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .longColumn("d", -1_000_000_000L) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "1000000000.00\t1970-01-01T00:00:01.000000000Z\n" + + "-1000000000.00\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testLongToDecimal16() throws Exception { + String table = "test_qwp_long_to_decimal16"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(4, 1), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .longColumn("d", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .longColumn("d", -100) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "42.0\t1970-01-01T00:00:01.000000000Z\n" + + "-100.0\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testLongToDecimal256() throws Exception { + String table = "test_qwp_long_to_decimal256"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(76, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .longColumn("d", Long.MAX_VALUE) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .longColumn("d", -1_000_000_000_000L) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "9223372036854775807.00\t1970-01-01T00:00:01.000000000Z\n" + + "-1000000000000.00\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testLongToDecimal32() throws Exception { + String table = "test_qwp_long_to_decimal32"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(6, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .longColumn("d", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .longColumn("d", -100) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "42.00\t1970-01-01T00:00:01.000000000Z\n" + + "-100.00\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testLongToDecimal8() throws Exception { + String table = "test_qwp_long_to_decimal8"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(2, 1), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .longColumn("d", 5) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .longColumn("d", -9) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "5.0\t1970-01-01T00:00:01.000000000Z\n" + + "-9.0\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testLongToDouble() throws Exception { + String table = "test_qwp_long_to_double"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DOUBLE, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .longColumn("d", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .longColumn("d", -100) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "42.0\t1970-01-01T00:00:01.000000000Z\n" + + "-100.0\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testLongToFloat() throws Exception { + String table = "test_qwp_long_to_float"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "f FLOAT, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .longColumn("f", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .longColumn("f", -100) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "f\tts\n" + + "42.0\t1970-01-01T00:00:01.000000000Z\n" + + "-100.0\t1970-01-01T00:00:02.000000000Z\n", + "SELECT f, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testLongToGeoHashCoercionError() throws Exception { + String table = "test_qwp_long_to_geohash_error"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "g GEOHASH(4c), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .longColumn("g", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error mentioning LONG but got: " + msg, + msg.contains("type coercion from LONG to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testLongToInt() throws Exception { + String table = "test_qwp_long_to_int"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "i INT, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + // Value in INT range should succeed + sender.table(table) + .longColumn("i", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .longColumn("i", -1) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "i\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "-1\t1970-01-01T00:00:02.000000000Z\n", + "SELECT i, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testLongToIntOverflowError() throws Exception { + String table = "test_qwp_long_to_int_overflow"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "i INT, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .longColumn("i", (long) Integer.MAX_VALUE + 1) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected overflow error but got: " + msg, + msg.contains("integer value 2147483648 out of range for INT") + ); + } + } + + @Test + public void testLongToLong256CoercionError() throws Exception { + String table = "test_qwp_long_to_long256_error"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "v LONG256, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .longColumn("v", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from LONG to LONG256 is not supported") + ); + } + } + + @Test + public void testLongToShort() throws Exception { + String table = "test_qwp_long_to_short"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "s SHORT, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + // Value in SHORT range should succeed + sender.table(table) + .longColumn("s", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .longColumn("s", -1) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + } + + @Test + public void testLongToShortOverflowError() throws Exception { + String table = "test_qwp_long_to_short_overflow"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "s SHORT, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .longColumn("s", 32768) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected overflow error but got: " + msg, + msg.contains("integer value 32768 out of range for SHORT") + ); + } + } + + @Test + public void testLongToString() throws Exception { + String table = "test_qwp_long_to_string"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "s STRING, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .longColumn("s", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .longColumn("s", Long.MAX_VALUE) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "s\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "9223372036854775807\t1970-01-01T00:00:02.000000000Z\n", + "SELECT s, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testLongToSymbol() throws Exception { + String table = "test_qwp_long_to_symbol"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "s SYMBOL, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .longColumn("s", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .longColumn("s", -1) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "s\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "-1\t1970-01-01T00:00:02.000000000Z\n", + "SELECT s, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testLongToTimestamp() throws Exception { + String table = "test_qwp_long_to_timestamp"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "t TIMESTAMP, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .longColumn("t", 1_000_000L) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .longColumn("t", 0L) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "t\tts\n" + + "1970-01-01T00:00:01.000000000Z\t1970-01-01T00:00:01.000000000Z\n" + + "1970-01-01T00:00:00.000000000Z\t1970-01-01T00:00:02.000000000Z\n", + "SELECT t, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testLongToUuidCoercionError() throws Exception { + String table = "test_qwp_long_to_uuid_error"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "u UUID, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .longColumn("u", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from LONG to UUID is not supported") + ); + } + } + + @Test + public void testLongToVarchar() throws Exception { + String table = "test_qwp_long_to_varchar"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "v VARCHAR, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .longColumn("v", 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .longColumn("v", Long.MAX_VALUE) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "v\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "9223372036854775807\t1970-01-01T00:00:02.000000000Z\n", + "SELECT v, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testMultipleRowsAndBatching() throws Exception { + String table = "test_qwp_multiple_rows"; + useTable(table); + + int rowCount = 1000; + try (QwpWebSocketSender sender = createQwpSender()) { + for (int i = 0; i < rowCount; i++) { + sender.table(table) + .symbol("sym", "s" + (i % 10)) + .longColumn("val", i) + .doubleColumn("dbl", i * 1.5) + .at((long) (i + 1) * 1_000_000, ChronoUnit.MICROS); + } + sender.flush(); + } + + assertTableSizeEventually(table, rowCount); + } + + @Test + public void testNullStringToBoolean() throws Exception { + String table = "test_qwp_null_string_to_boolean"; + useTable(table); + execute("CREATE TABLE " + table + " (b BOOLEAN, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("b", "true") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("b", null) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + assertTableSizeEventually(table, 2); + assertSqlEventually( + "b\tts\n" + + "true\t1970-01-01T00:00:01.000000000Z\n" + + "false\t1970-01-01T00:00:02.000000000Z\n", + "SELECT b, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testNullStringToByte() throws Exception { + String table = "test_qwp_null_string_to_byte"; + useTable(table); + execute("CREATE TABLE " + table + " (b BYTE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("b", "42") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("b", null) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + assertTableSizeEventually(table, 2); + assertSqlEventually( + "b\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "0\t1970-01-01T00:00:02.000000000Z\n", + "SELECT b, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testNullStringToChar() throws Exception { + String table = "test_qwp_null_string_to_char"; + useTable(table); + execute("CREATE TABLE " + table + " (c CHAR, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("c", "A") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("c", null) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + assertTableSizeEventually(table, 2); + assertSqlEventually( + "c\tts\n" + + "A\t1970-01-01T00:00:01.000000000Z\n" + + "null\t1970-01-01T00:00:02.000000000Z\n", + "SELECT c, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testNullStringToDate() throws Exception { + String table = "test_qwp_null_string_to_date"; + useTable(table); + execute("CREATE TABLE " + table + " (d DATE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("d", "2022-02-25T00:00:00.000Z") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("d", null) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "2022-02-25T00:00:00.000000000Z\t1970-01-01T00:00:01.000000000Z\n" + + "null\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testNullStringToDecimal() throws Exception { + String table = "test_qwp_null_string_to_decimal"; + useTable(table); + execute("CREATE TABLE " + table + " (d DECIMAL(18,2), ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("d", "123.45") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("d", null) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "123.45\t1970-01-01T00:00:01.000000000Z\n" + + "null\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testNullStringToFloat() throws Exception { + String table = "test_qwp_null_string_to_float"; + useTable(table); + execute("CREATE TABLE " + table + " (f FLOAT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("f", "3.14") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("f", null) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + assertTableSizeEventually(table, 2); + assertSqlEventually( + "f\tts\n" + + "3.14\t1970-01-01T00:00:01.000000000Z\n" + + "null\t1970-01-01T00:00:02.000000000Z\n", + "SELECT f, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testNullStringToGeoHash() throws Exception { + String table = "test_qwp_null_string_to_geohash"; + useTable(table); + execute("CREATE TABLE " + table + " (g GEOHASH(5c), ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("g", "s09wh") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("g", null) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + assertTableSizeEventually(table, 2); + assertSqlEventually( + "g\tts\n" + + "s09wh\t1970-01-01T00:00:01.000000000Z\n" + + "null\t1970-01-01T00:00:02.000000000Z\n", + "SELECT g, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testNullStringToLong256() throws Exception { + String table = "test_qwp_null_string_to_long256"; + useTable(table); + execute("CREATE TABLE " + table + " (l LONG256, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("l", "0x01") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("l", null) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + assertTableSizeEventually(table, 2); + assertSqlEventually( + "l\tts\n" + + "0x01\t1970-01-01T00:00:01.000000000Z\n" + + "null\t1970-01-01T00:00:02.000000000Z\n", + "SELECT l, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testNullStringToNumeric() throws Exception { + String table = "test_qwp_null_string_to_numeric"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "i INT, " + + "l LONG, " + + "d DOUBLE, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("i", "42") + .stringColumn("l", "100") + .stringColumn("d", "3.14") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("i", null) + .stringColumn("l", null) + .stringColumn("d", null) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + assertTableSizeEventually(table, 2); + assertSqlEventually( + "i\tl\td\tts\n" + + "42\t100\t3.14\t1970-01-01T00:00:01.000000000Z\n" + + "null\tnull\tnull\t1970-01-01T00:00:02.000000000Z\n", + "SELECT i, l, d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testNullStringToShort() throws Exception { + String table = "test_qwp_null_string_to_short"; + useTable(table); + execute("CREATE TABLE " + table + " (s SHORT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("s", "42") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("s", null) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + assertTableSizeEventually(table, 2); + assertSqlEventually( + "s\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "0\t1970-01-01T00:00:02.000000000Z\n", + "SELECT s, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testNullStringToSymbol() throws Exception { + String table = "test_qwp_null_string_to_symbol"; + useTable(table); + execute("CREATE TABLE " + table + " (s SYMBOL, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("s", "alpha") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("s", null) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + assertTableSizeEventually(table, 2); + assertSqlEventually( + "s\tts\n" + + "alpha\t1970-01-01T00:00:01.000000000Z\n" + + "null\t1970-01-01T00:00:02.000000000Z\n", + "SELECT s, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testNullStringToTimestamp() throws Exception { + String table = "test_qwp_null_string_to_timestamp"; + useTable(table); + execute("CREATE TABLE " + table + " (t TIMESTAMP, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("t", "2022-02-25T00:00:00.000000Z") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("t", null) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + assertTableSizeEventually(table, 2); + assertSqlEventually( + "t\tts\n" + + "2022-02-25T00:00:00.000000000Z\t1970-01-01T00:00:01.000000000Z\n" + + "null\t1970-01-01T00:00:02.000000000Z\n", + "SELECT t, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testNullStringToTimestampNs() throws Exception { + String table = "test_qwp_null_string_to_timestamp_ns"; + useTable(table); + execute("CREATE TABLE " + table + " (t TIMESTAMP_NS, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("t", "2022-02-25T00:00:00.000000Z") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("t", null) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + assertTableSizeEventually(table, 2); + assertSqlEventually( + "t\tts\n" + + "2022-02-25T00:00:00.000000000Z\t1970-01-01T00:00:01.000000000Z\n" + + "null\t1970-01-01T00:00:02.000000000Z\n", + "SELECT t, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testNullStringToUuid() throws Exception { + String table = "test_qwp_null_string_to_uuid"; + useTable(table); + execute("CREATE TABLE " + table + " (u UUID, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("u", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("u", null) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + assertTableSizeEventually(table, 2); + assertSqlEventually( + "u\tts\n" + + "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11\t1970-01-01T00:00:01.000000000Z\n" + + "null\t1970-01-01T00:00:02.000000000Z\n", + "SELECT u, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testNullStringToVarchar() throws Exception { + String table = "test_qwp_null_string_to_varchar"; + useTable(table); + execute("CREATE TABLE " + table + " (v VARCHAR, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("v", "hello") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("v", null) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + assertTableSizeEventually(table, 2); + assertSqlEventually( + "v\tts\n" + + "hello\t1970-01-01T00:00:01.000000000Z\n" + + "null\t1970-01-01T00:00:02.000000000Z\n", + "SELECT v, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testNullSymbolToString() throws Exception { + String table = "test_qwp_null_symbol_to_string"; + useTable(table); + execute("CREATE TABLE " + table + " (s STRING, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .symbol("s", "hello") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .symbol("s", null) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + assertTableSizeEventually(table, 2); + assertSqlEventually( + "s\tts\n" + + "hello\t1970-01-01T00:00:01.000000000Z\n" + + "null\t1970-01-01T00:00:02.000000000Z\n", + "SELECT s, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testNullSymbolToSymbol() throws Exception { + String table = "test_qwp_null_symbol_to_symbol"; + useTable(table); + execute("CREATE TABLE " + table + " (s SYMBOL, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .symbol("s", "alpha") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .symbol("s", null) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + assertTableSizeEventually(table, 2); + assertSqlEventually( + "s\tts\n" + + "alpha\t1970-01-01T00:00:01.000000000Z\n" + + "null\t1970-01-01T00:00:02.000000000Z\n", + "SELECT s, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testNullSymbolToVarchar() throws Exception { + String table = "test_qwp_null_symbol_to_varchar"; + useTable(table); + execute("CREATE TABLE " + table + " (v VARCHAR, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .symbol("v", "hello") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .symbol("v", null) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + assertTableSizeEventually(table, 2); + assertSqlEventually( + "v\tts\n" + + "hello\t1970-01-01T00:00:01.000000000Z\n" + + "null\t1970-01-01T00:00:02.000000000Z\n", + "SELECT v, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testShort() throws Exception { + String table = "test_qwp_short"; + useTable(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + // Short.MIN_VALUE is the null sentinel for SHORT + sender.table(table) + .shortColumn("s", Short.MIN_VALUE) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .shortColumn("s", (short) 0) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .shortColumn("s", Short.MAX_VALUE) + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + } + + @Test + public void testShortToBooleanCoercionError() throws Exception { + String table = "test_qwp_short_to_boolean_error"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "b BOOLEAN, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .shortColumn("b", (short) 1) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected error mentioning SHORT and BOOLEAN but got: " + msg, + msg.contains("SHORT") && msg.contains("BOOLEAN") + ); + } + } + + @Test + public void testShortToByte() throws Exception { + String table = "test_qwp_short_to_byte"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "b BYTE, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .shortColumn("b", (short) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .shortColumn("b", (short) -128) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .shortColumn("b", (short) 127) + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "b\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "-128\t1970-01-01T00:00:02.000000000Z\n" + + "127\t1970-01-01T00:00:03.000000000Z\n", + "SELECT b, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testShortToByteOverflowError() throws Exception { + String table = "test_qwp_short_to_byte_overflow"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "b BYTE, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .shortColumn("b", (short) 128) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected overflow error but got: " + msg, + msg.contains("integer value 128 out of range for BYTE") + ); + } + } + + @Test + public void testShortToCharCoercionError() throws Exception { + String table = "test_qwp_short_to_char_error"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "c CHAR, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .shortColumn("c", (short) 65) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected error mentioning SHORT and CHAR but got: " + msg, + msg.contains("SHORT") && msg.contains("CHAR") + ); + } + } + + @Test + public void testShortToDate() throws Exception { + String table = "test_qwp_short_to_date"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DATE, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + // 1000 millis = 1 second + sender.table(table) + .shortColumn("d", (short) 1000) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .shortColumn("d", (short) 0) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "1970-01-01T00:00:01.000000000Z\t1970-01-01T00:00:01.000000000Z\n" + + "1970-01-01T00:00:00.000000000Z\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testShortToDecimal128() throws Exception { + String table = "test_qwp_short_to_decimal128"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(38, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .shortColumn("d", Short.MAX_VALUE) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .shortColumn("d", Short.MIN_VALUE) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "32767.00\t1970-01-01T00:00:01.000000000Z\n" + + "-32768.00\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testShortToDecimal16() throws Exception { + String table = "test_qwp_short_to_decimal16"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(4, 1), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .shortColumn("d", (short) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .shortColumn("d", (short) -100) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "42.0\t1970-01-01T00:00:01.000000000Z\n" + + "-100.0\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testShortToDecimal256() throws Exception { + String table = "test_qwp_short_to_decimal256"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(76, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .shortColumn("d", (short) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .shortColumn("d", (short) -100) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "42.00\t1970-01-01T00:00:01.000000000Z\n" + + "-100.00\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testShortToDecimal32() throws Exception { + String table = "test_qwp_short_to_decimal32"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(6, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .shortColumn("d", (short) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .shortColumn("d", (short) -100) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "42.00\t1970-01-01T00:00:01.000000000Z\n" + + "-100.00\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testShortToDecimal64() throws Exception { + String table = "test_qwp_short_to_decimal64"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(18, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .shortColumn("d", (short) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .shortColumn("d", (short) -100) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "42.00\t1970-01-01T00:00:01.000000000Z\n" + + "-100.00\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testShortToDecimal8() throws Exception { + String table = "test_qwp_short_to_decimal8"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(2, 1), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .shortColumn("d", (short) 5) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .shortColumn("d", (short) -9) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "5.0\t1970-01-01T00:00:01.000000000Z\n" + + "-9.0\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testShortToDouble() throws Exception { + String table = "test_qwp_short_to_double"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DOUBLE, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .shortColumn("d", (short) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .shortColumn("d", (short) -100) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "42.0\t1970-01-01T00:00:01.000000000Z\n" + + "-100.0\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testShortToFloat() throws Exception { + String table = "test_qwp_short_to_float"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "f FLOAT, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .shortColumn("f", (short) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .shortColumn("f", (short) -100) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "f\tts\n" + + "42.0\t1970-01-01T00:00:01.000000000Z\n" + + "-100.0\t1970-01-01T00:00:02.000000000Z\n", + "SELECT f, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testShortToGeoHashCoercionError() throws Exception { + String table = "test_qwp_short_to_geohash_error"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "g GEOHASH(4c), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .shortColumn("g", (short) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error mentioning SHORT but got: " + msg, + msg.contains("type coercion from SHORT to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testShortToInt() throws Exception { + String table = "test_qwp_short_to_int"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "i INT, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .shortColumn("i", (short) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .shortColumn("i", Short.MAX_VALUE) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "i\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "32767\t1970-01-01T00:00:02.000000000Z\n", + "SELECT i, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testShortToLong() throws Exception { + String table = "test_qwp_short_to_long"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "l LONG, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .shortColumn("l", (short) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .shortColumn("l", Short.MAX_VALUE) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "l\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "32767\t1970-01-01T00:00:02.000000000Z\n", + "SELECT l, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testShortToLong256CoercionError() throws Exception { + String table = "test_qwp_short_to_long256_error"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "v LONG256, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .shortColumn("v", (short) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from SHORT to LONG256 is not supported") + ); + } + } + + @Test + public void testShortToString() throws Exception { + String table = "test_qwp_short_to_string"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "s STRING, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .shortColumn("s", (short) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .shortColumn("s", (short) -100) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .shortColumn("s", (short) 0) + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "s\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "-100\t1970-01-01T00:00:02.000000000Z\n" + + "0\t1970-01-01T00:00:03.000000000Z\n", + "SELECT s, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testShortToSymbol() throws Exception { + String table = "test_qwp_short_to_symbol"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "s SYMBOL, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .shortColumn("s", (short) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .shortColumn("s", (short) -1) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .shortColumn("s", (short) 0) + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "s\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "-1\t1970-01-01T00:00:02.000000000Z\n" + + "0\t1970-01-01T00:00:03.000000000Z\n", + "SELECT s, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testShortToTimestamp() throws Exception { + String table = "test_qwp_short_to_timestamp"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "t TIMESTAMP, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .shortColumn("t", (short) 1000) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .shortColumn("t", (short) 0) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "t\tts\n" + + "1970-01-01T00:00:00.001000000Z\t1970-01-01T00:00:01.000000000Z\n" + + "1970-01-01T00:00:00.000000000Z\t1970-01-01T00:00:02.000000000Z\n", + "SELECT t, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testShortToUuidCoercionError() throws Exception { + String table = "test_qwp_short_to_uuid_error"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "u UUID, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .shortColumn("u", (short) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from SHORT to UUID is not supported") + ); + } + } + + @Test + public void testShortToVarchar() throws Exception { + String table = "test_qwp_short_to_varchar"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "v VARCHAR, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .shortColumn("v", (short) 42) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .shortColumn("v", (short) -100) + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .shortColumn("v", Short.MAX_VALUE) + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "v\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "-100\t1970-01-01T00:00:02.000000000Z\n" + + "32767\t1970-01-01T00:00:03.000000000Z\n", + "SELECT v, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testString() throws Exception { + String table = "test_qwp_string"; + useTable(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("s", "hello world") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("s", "non-ascii äöü") + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("s", "") + .at(3_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("s", null) + .at(4_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 4); + assertSqlEventually( + "s\ttimestamp\n" + + "hello world\t1970-01-01T00:00:01.000000000Z\n" + + "non-ascii äöü\t1970-01-01T00:00:02.000000000Z\n" + + "\t1970-01-01T00:00:03.000000000Z\n" + + "null\t1970-01-01T00:00:04.000000000Z\n", + "SELECT s, timestamp FROM " + table + " ORDER BY timestamp"); + } + + @Test + public void testStringToBoolean() throws Exception { + String table = "test_qwp_string_to_boolean"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "b BOOLEAN, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("b", "true") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("b", "false") + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("b", "1") + .at(3_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("b", "0") + .at(4_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("b", "TRUE") + .at(5_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 5); + assertSqlEventually( + "b\tts\n" + + "true\t1970-01-01T00:00:01.000000000Z\n" + + "false\t1970-01-01T00:00:02.000000000Z\n" + + "true\t1970-01-01T00:00:03.000000000Z\n" + + "false\t1970-01-01T00:00:04.000000000Z\n" + + "true\t1970-01-01T00:00:05.000000000Z\n", + "SELECT b, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testStringToBooleanParseError() throws Exception { + String table = "test_qwp_string_to_boolean_err"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "b BOOLEAN, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("b", "yes") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected parse error but got: " + msg, + msg.contains("cannot parse boolean from string") + ); + } + } + + @Test + public void testStringToByte() throws Exception { + String table = "test_qwp_string_to_byte"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "b BYTE, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("b", "42") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("b", "-128") + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("b", "127") + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "b\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "-128\t1970-01-01T00:00:02.000000000Z\n" + + "127\t1970-01-01T00:00:03.000000000Z\n", + "SELECT b, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testStringToByteParseError() throws Exception { + String table = "test_qwp_string_to_byte_err"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "b BYTE, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("b", "abc") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected parse error but got: " + msg, + msg.contains("cannot parse BYTE from string") + ); + } + } + + @Test + public void testStringToChar() throws Exception { + String table = "test_qwp_string_to_char"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "c CHAR, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("c", "A") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("c", "Hello") + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "c\tts\n" + + "A\t1970-01-01T00:00:01.000000000Z\n" + + "H\t1970-01-01T00:00:02.000000000Z\n", + "SELECT c, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testStringToDate() throws Exception { + String table = "test_qwp_string_to_date"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DATE, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("d", "2022-02-25T00:00:00.000Z") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 1); + assertSqlEventually( + "d\tts\n" + + "2022-02-25T00:00:00.000000000Z\t1970-01-01T00:00:01.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testStringToDateParseError() throws Exception { + String table = "test_qwp_string_to_date_parse_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v DATE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("v", "not_a_date") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected parse error but got: " + msg, + msg.contains("cannot parse DATE from string") && msg.contains("not_a_date") + ); + } + } + + @Test + public void testStringToDecimal128() throws Exception { + String table = "test_qwp_string_to_dec128"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(38, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("d", "123.45") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("d", "-99.99") + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "123.45\t1970-01-01T00:00:01.000000000Z\n" + + "-99.99\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testStringToDecimal16() throws Exception { + String table = "test_qwp_string_to_dec16"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(4, 1), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("d", "12.5") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("d", "-99.9") + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "12.5\t1970-01-01T00:00:01.000000000Z\n" + + "-99.9\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testStringToDecimal256() throws Exception { + String table = "test_qwp_string_to_dec256"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(76, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("d", "123.45") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("d", "-99.99") + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "123.45\t1970-01-01T00:00:01.000000000Z\n" + + "-99.99\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testStringToDecimal32() throws Exception { + String table = "test_qwp_string_to_dec32"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(6, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("d", "1234.56") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("d", "-999.99") + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "1234.56\t1970-01-01T00:00:01.000000000Z\n" + + "-999.99\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testStringToDecimal64() throws Exception { + String table = "test_qwp_string_to_dec64"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(18, 2), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("d", "123.45") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("d", "-99.99") + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "123.45\t1970-01-01T00:00:01.000000000Z\n" + + "-99.99\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testStringToDecimal8() throws Exception { + String table = "test_qwp_string_to_dec8"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DECIMAL(2, 1), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("d", "1.5") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("d", "-9.9") + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "1.5\t1970-01-01T00:00:01.000000000Z\n" + + "-9.9\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testStringToDouble() throws Exception { + String table = "test_qwp_string_to_double"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "d DOUBLE, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("d", "3.14") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("d", "-2.718") + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "d\tts\n" + + "3.14\t1970-01-01T00:00:01.000000000Z\n" + + "-2.718\t1970-01-01T00:00:02.000000000Z\n", + "SELECT d, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testStringToDoubleParseError() throws Exception { + String table = "test_qwp_string_to_double_parse_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v DOUBLE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("v", "not_a_number") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected parse error but got: " + msg, + msg.contains("cannot parse DOUBLE from string") && msg.contains("not_a_number") + ); + } + } + + @Test + public void testStringToFloat() throws Exception { + String table = "test_qwp_string_to_float"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "f FLOAT, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("f", "3.14") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("f", "-2.5") + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "f\tts\n" + + "3.14\t1970-01-01T00:00:01.000000000Z\n" + + "-2.5\t1970-01-01T00:00:02.000000000Z\n", + "SELECT f, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testStringToFloatParseError() throws Exception { + String table = "test_qwp_string_to_float_parse_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v FLOAT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("v", "not_a_number") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected parse error but got: " + msg, + msg.contains("cannot parse FLOAT from string") && msg.contains("not_a_number") + ); + } + } + + @Test + public void testStringToGeoHash() throws Exception { + String table = "test_qwp_string_to_geohash"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "g GEOHASH(5c), " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("g", "s24se") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("g", "u33dc") + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "g\tts\n" + + "s24se\t1970-01-01T00:00:01.000000000Z\n" + + "u33dc\t1970-01-01T00:00:02.000000000Z\n", + "SELECT g, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testStringToGeoHashParseError() throws Exception { + String table = "test_qwp_string_to_geohash_parse_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v GEOHASH(5c), ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("v", "!!!") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected parse error but got: " + msg, + msg.contains("cannot parse geohash from string") && msg.contains("!!!") + ); + } + } + + @Test + public void testStringToInt() throws Exception { + String table = "test_qwp_string_to_int"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "i INT, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("i", "42") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("i", "-100") + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("i", "0") + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "i\tts\n" + + "42\t1970-01-01T00:00:01.000000000Z\n" + + "-100\t1970-01-01T00:00:02.000000000Z\n" + + "0\t1970-01-01T00:00:03.000000000Z\n", + "SELECT i, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testStringToIntParseError() throws Exception { + String table = "test_qwp_string_to_int_parse_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v INT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("v", "not_a_number") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected parse error but got: " + msg, + msg.contains("cannot parse INT from string") && msg.contains("not_a_number") + ); + } + } + + @Test + public void testStringToLong() throws Exception { + String table = "test_qwp_string_to_long"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "l LONG, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("l", "1000000000000") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("l", "-1") + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "l\tts\n" + + "1000000000000\t1970-01-01T00:00:01.000000000Z\n" + + "-1\t1970-01-01T00:00:02.000000000Z\n", + "SELECT l, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testStringToLong256() throws Exception { + String table = "test_qwp_string_to_long256"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "l LONG256, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("l", "0x01") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 1); + assertSqlEventually( + "l\tts\n" + + "0x01\t1970-01-01T00:00:01.000000000Z\n", + "SELECT l, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testStringToLong256ParseError() throws Exception { + String table = "test_qwp_string_to_long256_parse_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v LONG256, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("v", "not_a_long256") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected parse error but got: " + msg, + msg.contains("cannot parse long256 from string") && msg.contains("not_a_long256") + ); + } + } + + @Test + public void testStringToLongParseError() throws Exception { + String table = "test_qwp_string_to_long_parse_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v LONG, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("v", "not_a_number") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected parse error but got: " + msg, + msg.contains("cannot parse LONG from string") && msg.contains("not_a_number") + ); + } + } + + @Test + public void testStringToShort() throws Exception { + String table = "test_qwp_string_to_short"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "s SHORT, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("s", "1000") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("s", "-32768") + .at(2_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("s", "32767") + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "s\tts\n" + + "1000\t1970-01-01T00:00:01.000000000Z\n" + + "-32768\t1970-01-01T00:00:02.000000000Z\n" + + "32767\t1970-01-01T00:00:03.000000000Z\n", + "SELECT s, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testStringToShortParseError() throws Exception { + String table = "test_qwp_string_to_short_parse_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v SHORT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("v", "not_a_number") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected parse error but got: " + msg, + msg.contains("cannot parse SHORT from string") && msg.contains("not_a_number") + ); + } + } + + @Test + public void testStringToSymbol() throws Exception { + String table = "test_qwp_string_to_symbol"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "s SYMBOL, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("s", "hello") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("s", "world") + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "s\tts\n" + + "hello\t1970-01-01T00:00:01.000000000Z\n" + + "world\t1970-01-01T00:00:02.000000000Z\n", + "SELECT s, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testStringToTimestamp() throws Exception { + String table = "test_qwp_string_to_timestamp"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "t TIMESTAMP, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("t", "2022-02-25T00:00:00.000000Z") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 1); + assertSqlEventually( + "t\tts\n" + + "2022-02-25T00:00:00.000000000Z\t1970-01-01T00:00:01.000000000Z\n", + "SELECT t, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testStringToTimestampNs() throws Exception { + String table = "test_qwp_string_to_timestamp_ns"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "ts_col TIMESTAMP_NS, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("ts_col", "2022-02-25T00:00:00.000000Z") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 1); + assertSqlEventually( + "ts_col\tts\n" + + "2022-02-25T00:00:00.000000000Z\t1970-01-01T00:00:01.000000000Z\n", + "SELECT ts_col, ts FROM " + table); + } + + @Test + public void testStringToTimestampParseError() throws Exception { + String table = "test_qwp_string_to_timestamp_parse_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v TIMESTAMP, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("v", "not_a_timestamp") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected parse error but got: " + msg, + msg.contains("cannot parse timestamp from string") && msg.contains("not_a_timestamp") + ); + } + } + + @Test + public void testStringToUuid() throws Exception { + String table = "test_qwp_string_to_uuid"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "u UUID, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("u", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 1); + assertSqlEventually( + "u\tts\n" + + "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11\t1970-01-01T00:00:01.000000000Z\n", + "SELECT u, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testStringToUuidParseError() throws Exception { + String table = "test_qwp_string_to_uuid_parse_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v UUID, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("v", "not-a-uuid") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected parse error but got: " + msg, + msg.contains("cannot parse UUID from string") && msg.contains("not-a-uuid") + ); + } + } + + @Test + public void testStringToVarchar() throws Exception { + String table = "test_qwp_string_to_varchar"; + useTable(table); + execute("CREATE TABLE " + table + " (v VARCHAR, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .stringColumn("v", "hello") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .stringColumn("v", "world") + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + assertTableSizeEventually(table, 2); + assertSqlEventually( + "v\tts\n" + + "hello\t1970-01-01T00:00:01.000000000Z\n" + + "world\t1970-01-01T00:00:02.000000000Z\n", + "SELECT v, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testSymbol() throws Exception { + String table = "test_qwp_symbol"; + useTable(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .symbol("s", "alpha") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .symbol("s", "beta") + .at(2_000_000, ChronoUnit.MICROS); + // repeated value reuses dictionary entry + sender.table(table) + .symbol("s", "alpha") + .at(3_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 3); + assertSqlEventually( + "s\ttimestamp\n" + + "alpha\t1970-01-01T00:00:01.000000000Z\n" + + "beta\t1970-01-01T00:00:02.000000000Z\n" + + "alpha\t1970-01-01T00:00:03.000000000Z\n", + "SELECT s, timestamp FROM " + table + " ORDER BY timestamp"); + } + + @Test + public void testSymbolToBooleanCoercionError() throws Exception { + String table = "test_qwp_symbol_to_boolean_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v BOOLEAN, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .symbol("v", "hello") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write SYMBOL") && msg.contains("BOOLEAN") + ); + } + } + + @Test + public void testSymbolToByteCoercionError() throws Exception { + String table = "test_qwp_symbol_to_byte_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v BYTE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .symbol("v", "hello") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write SYMBOL") && msg.contains("BYTE") + ); + } + } + + @Test + public void testSymbolToCharCoercionError() throws Exception { + String table = "test_qwp_symbol_to_char_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v CHAR, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .symbol("v", "hello") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write SYMBOL") && msg.contains("CHAR") + ); + } + } + + @Test + public void testSymbolToDateCoercionError() throws Exception { + String table = "test_qwp_symbol_to_date_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v DATE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .symbol("v", "hello") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write SYMBOL") && msg.contains("DATE") + ); + } + } + + @Test + public void testSymbolToDecimalCoercionError() throws Exception { + String table = "test_qwp_symbol_to_decimal_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v DECIMAL(18,2), ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .symbol("v", "hello") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write SYMBOL") && msg.contains("DECIMAL") + ); + } + } + + @Test + public void testSymbolToDoubleCoercionError() throws Exception { + String table = "test_qwp_symbol_to_double_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v DOUBLE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .symbol("v", "hello") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write SYMBOL") && msg.contains("DOUBLE") + ); + } + } + + @Test + public void testSymbolToFloatCoercionError() throws Exception { + String table = "test_qwp_symbol_to_float_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v FLOAT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .symbol("v", "hello") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write SYMBOL") && msg.contains("FLOAT") + ); + } + } + + @Test + public void testSymbolToGeoHashCoercionError() throws Exception { + String table = "test_qwp_symbol_to_geohash_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v GEOHASH(5c), ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .symbol("v", "hello") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write SYMBOL") && msg.contains("GEOHASH") + ); + } + } + + @Test + public void testSymbolToIntCoercionError() throws Exception { + String table = "test_qwp_symbol_to_int_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v INT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .symbol("v", "hello") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write SYMBOL") && msg.contains("INT") + ); + } + } + + @Test + public void testSymbolToLong256CoercionError() throws Exception { + String table = "test_qwp_symbol_to_long256_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v LONG256, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .symbol("v", "hello") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write SYMBOL") && msg.contains("LONG256") + ); + } + } + + @Test + public void testSymbolToLongCoercionError() throws Exception { + String table = "test_qwp_symbol_to_long_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v LONG, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .symbol("v", "hello") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write SYMBOL") && msg.contains("LONG") + ); + } + } + + @Test + public void testSymbolToShortCoercionError() throws Exception { + String table = "test_qwp_symbol_to_short_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v SHORT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .symbol("v", "hello") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write SYMBOL") && msg.contains("SHORT") + ); + } + } + + @Test + public void testSymbolToString() throws Exception { + String table = "test_qwp_symbol_to_string"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "s STRING, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .symbol("s", "hello") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .symbol("s", "world") + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "s\tts\n" + + "hello\t1970-01-01T00:00:01.000000000Z\n" + + "world\t1970-01-01T00:00:02.000000000Z\n", + "SELECT s, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testSymbolToTimestampCoercionError() throws Exception { + String table = "test_qwp_symbol_to_timestamp_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v TIMESTAMP, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .symbol("v", "hello") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write SYMBOL") && msg.contains("TIMESTAMP") + ); + } + } + + @Test + public void testSymbolToTimestampNsCoercionError() throws Exception { + String table = "test_qwp_symbol_to_timestamp_ns_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v TIMESTAMP_NS, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .symbol("v", "hello") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write SYMBOL") && msg.contains("TIMESTAMP") + ); + } + } + + @Test + public void testSymbolToUuidCoercionError() throws Exception { + String table = "test_qwp_symbol_to_uuid_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v UUID, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .symbol("v", "hello") + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write SYMBOL") && msg.contains("UUID") + ); + } + } + + @Test + public void testSymbolToVarchar() throws Exception { + String table = "test_qwp_symbol_to_varchar"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "v VARCHAR, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .symbol("v", "hello") + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .symbol("v", "world") + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "v\tts\n" + + "hello\t1970-01-01T00:00:01.000000000Z\n" + + "world\t1970-01-01T00:00:02.000000000Z\n", + "SELECT v, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testTimestampMicros() throws Exception { + String table = "test_qwp_timestamp_micros"; + useTable(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + long tsMicros = 1_645_747_200_000_000L; // 2022-02-25T00:00:00Z in micros + sender.table(table) + .timestampColumn("ts_col", tsMicros, ChronoUnit.MICROS) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 1); + assertSqlEventually( + "ts_col\ttimestamp\n" + + "2022-02-25T00:00:00.000000000Z\t1970-01-01T00:00:01.000000000Z\n", + "SELECT ts_col, timestamp FROM " + table); + } + + @Test + public void testTimestampMicrosToNanos() throws Exception { + String table = "test_qwp_timestamp_micros_to_nanos"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "ts_col TIMESTAMP_NS, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + long tsMicros = 1_645_747_200_111_111L; // 2022-02-25T00:00:00Z + sender.table(table) + .timestampColumn("ts_col", tsMicros, ChronoUnit.MICROS) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 1); + // Microseconds scaled to nanoseconds + assertSqlEventually( + "ts_col\tts\n" + + "2022-02-25T00:00:00.111111000Z\t1970-01-01T00:00:01.000000000Z\n", + "SELECT ts_col, ts FROM " + table); + } + + @Test + public void testTimestampNanos() throws Exception { + String table = "test_qwp_timestamp_nanos"; + useTable(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + long tsNanos = 1_645_747_200_000_000_000L; // 2022-02-25T00:00:00Z in nanos + sender.table(table) + .timestampColumn("ts_col", tsNanos, ChronoUnit.NANOS) + .at(tsNanos, ChronoUnit.NANOS); + sender.flush(); + } + + assertTableSizeEventually(table, 1); + } + + @Test + public void testTimestampNanosToMicros() throws Exception { + String table = "test_qwp_timestamp_nanos_to_micros"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "ts_col TIMESTAMP, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + long tsNanos = 1_645_747_200_123_456_789L; + sender.table(table) + .timestampColumn("ts_col", tsNanos, ChronoUnit.NANOS) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 1); + // Nanoseconds truncated to microseconds + assertSqlEventually( + "ts_col\tts\n" + + "2022-02-25T00:00:00.123456000Z\t1970-01-01T00:00:01.000000000Z\n", + "SELECT ts_col, ts FROM " + table); + } + + @Test + public void testTimestampToBooleanCoercionError() throws Exception { + String table = "test_qwp_timestamp_to_boolean_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v BOOLEAN, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .timestampColumn("v", 1_645_747_200_000_000L, ChronoUnit.MICROS) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write TIMESTAMP") && msg.contains("BOOLEAN") + ); + } + } + + @Test + public void testTimestampToByteCoercionError() throws Exception { + String table = "test_qwp_timestamp_to_byte_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v BYTE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .timestampColumn("v", 1_645_747_200_000_000L, ChronoUnit.MICROS) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write TIMESTAMP") && msg.contains("BYTE") + ); + } + } + + @Test + public void testTimestampToCharCoercionError() throws Exception { + String table = "test_qwp_timestamp_to_char_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v CHAR, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .timestampColumn("v", 1_645_747_200_000_000L, ChronoUnit.MICROS) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write TIMESTAMP") && msg.contains("CHAR") + ); + } + } + + @Test + public void testTimestampToDateCoercionError() throws Exception { + String table = "test_qwp_timestamp_to_date_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v DATE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .timestampColumn("v", 1_645_747_200_000_000L, ChronoUnit.MICROS) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write TIMESTAMP") && msg.contains("DATE") + ); + } + } + + @Test + public void testTimestampToDecimalCoercionError() throws Exception { + String table = "test_qwp_timestamp_to_decimal_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v DECIMAL(18,2), ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .timestampColumn("v", 1_645_747_200_000_000L, ChronoUnit.MICROS) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write TIMESTAMP") && msg.contains("DECIMAL") + ); + } + } + + @Test + public void testTimestampToDoubleCoercionError() throws Exception { + String table = "test_qwp_timestamp_to_double_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v DOUBLE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .timestampColumn("v", 1_645_747_200_000_000L, ChronoUnit.MICROS) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write TIMESTAMP") && msg.contains("DOUBLE") + ); + } + } + + @Test + public void testTimestampToFloatCoercionError() throws Exception { + String table = "test_qwp_timestamp_to_float_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v FLOAT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .timestampColumn("v", 1_645_747_200_000_000L, ChronoUnit.MICROS) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write TIMESTAMP") && msg.contains("FLOAT") + ); + } + } + + @Test + public void testTimestampToGeoHashCoercionError() throws Exception { + String table = "test_qwp_timestamp_to_geohash_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v GEOHASH(5c), ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .timestampColumn("v", 1_645_747_200_000_000L, ChronoUnit.MICROS) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write TIMESTAMP") && msg.contains("GEOHASH") + ); + } + } + + @Test + public void testTimestampToIntCoercionError() throws Exception { + String table = "test_qwp_timestamp_to_int_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v INT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .timestampColumn("v", 1_645_747_200_000_000L, ChronoUnit.MICROS) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write TIMESTAMP") && msg.contains("INT") + ); + } + } + + @Test + public void testTimestampToLong256CoercionError() throws Exception { + String table = "test_qwp_timestamp_to_long256_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v LONG256, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .timestampColumn("v", 1_645_747_200_000_000L, ChronoUnit.MICROS) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write TIMESTAMP") && msg.contains("LONG256") + ); + } + } + + @Test + public void testTimestampToLongCoercionError() throws Exception { + String table = "test_qwp_timestamp_to_long_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v LONG, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .timestampColumn("v", 1_645_747_200_000_000L, ChronoUnit.MICROS) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write TIMESTAMP") && msg.contains("LONG") + ); + } + } + + @Test + public void testTimestampToShortCoercionError() throws Exception { + String table = "test_qwp_timestamp_to_short_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v SHORT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .timestampColumn("v", 1_645_747_200_000_000L, ChronoUnit.MICROS) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write TIMESTAMP") && msg.contains("SHORT") + ); + } + } + + @Test + public void testTimestampToString() throws Exception { + String table = "test_qwp_timestamp_to_string"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "s STRING, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + long tsMicros = 1_645_747_200_000_000L; // 2022-02-25T00:00:00Z in micros + sender.table(table) + .timestampColumn("s", tsMicros, ChronoUnit.MICROS) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 1); + assertSqlEventually( + "s\tts\n" + + "2022-02-25T00:00:00.000Z\t1970-01-01T00:00:01.000000000Z\n", + "SELECT s, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testTimestampToSymbolCoercionError() throws Exception { + String table = "test_qwp_timestamp_to_symbol_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v SYMBOL, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .timestampColumn("v", 1_645_747_200_000_000L, ChronoUnit.MICROS) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write TIMESTAMP") && msg.contains("SYMBOL") + ); + } + } + + @Test + public void testTimestampToUuidCoercionError() throws Exception { + String table = "test_qwp_timestamp_to_uuid_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v UUID, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .timestampColumn("v", 1_645_747_200_000_000L, ChronoUnit.MICROS) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write TIMESTAMP") && msg.contains("UUID") + ); + } + } + + @Test + public void testTimestampToVarchar() throws Exception { + String table = "test_qwp_timestamp_to_varchar"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "v VARCHAR, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + long tsMicros = 1_645_747_200_000_000L; // 2022-02-25T00:00:00Z in micros + sender.table(table) + .timestampColumn("v", tsMicros, ChronoUnit.MICROS) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 1); + assertSqlEventually( + "v\tts\n" + + "2022-02-25T00:00:00.000Z\t1970-01-01T00:00:01.000000000Z\n", + "SELECT v, ts FROM " + table + " ORDER BY ts"); + } + + @Test + public void testUuid() throws Exception { + String table = "test_qwp_uuid"; + useTable(table); + + UUID uuid1 = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + UUID uuid2 = UUID.fromString("11111111-2222-3333-4444-555555555555"); + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .uuidColumn("u", uuid1.getLeastSignificantBits(), uuid1.getMostSignificantBits()) + .at(1_000_000, ChronoUnit.MICROS); + sender.table(table) + .uuidColumn("u", uuid2.getLeastSignificantBits(), uuid2.getMostSignificantBits()) + .at(2_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 2); + assertSqlEventually( + "u\ttimestamp\n" + + "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11\t1970-01-01T00:00:01.000000000Z\n" + + "11111111-2222-3333-4444-555555555555\t1970-01-01T00:00:02.000000000Z\n", + "SELECT u, timestamp FROM " + table + " ORDER BY timestamp"); + } + + @Test + public void testUuidToBooleanCoercionError() throws Exception { + String table = "test_qwp_uuid_to_boolean_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v BOOLEAN, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + UUID uuid = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + sender.table(table) + .uuidColumn("v", uuid.getLeastSignificantBits(), uuid.getMostSignificantBits()) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write UUID") && msg.contains("BOOLEAN") + ); + } + } + + @Test + public void testUuidToByteCoercionError() throws Exception { + String table = "test_qwp_uuid_to_byte_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v BYTE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + UUID uuid = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + sender.table(table) + .uuidColumn("v", uuid.getLeastSignificantBits(), uuid.getMostSignificantBits()) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from UUID to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testUuidToCharCoercionError() throws Exception { + String table = "test_qwp_uuid_to_char_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v CHAR, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + UUID uuid = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + sender.table(table) + .uuidColumn("v", uuid.getLeastSignificantBits(), uuid.getMostSignificantBits()) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write UUID") && msg.contains("CHAR") + ); + } + } + + @Test + public void testUuidToDateCoercionError() throws Exception { + String table = "test_qwp_uuid_to_date_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v DATE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + UUID uuid = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + sender.table(table) + .uuidColumn("v", uuid.getLeastSignificantBits(), uuid.getMostSignificantBits()) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from UUID to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testUuidToDoubleCoercionError() throws Exception { + String table = "test_qwp_uuid_to_double_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v DOUBLE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + UUID uuid = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + sender.table(table) + .uuidColumn("v", uuid.getLeastSignificantBits(), uuid.getMostSignificantBits()) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from UUID to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testUuidToFloatCoercionError() throws Exception { + String table = "test_qwp_uuid_to_float_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v FLOAT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + UUID uuid = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + sender.table(table) + .uuidColumn("v", uuid.getLeastSignificantBits(), uuid.getMostSignificantBits()) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from UUID to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testUuidToGeoHashCoercionError() throws Exception { + String table = "test_qwp_uuid_to_geohash_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v GEOHASH(5c), ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + UUID uuid = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + sender.table(table) + .uuidColumn("v", uuid.getLeastSignificantBits(), uuid.getMostSignificantBits()) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from UUID to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testUuidToIntCoercionError() throws Exception { + String table = "test_qwp_uuid_to_int_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v INT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + UUID uuid = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + sender.table(table) + .uuidColumn("v", uuid.getLeastSignificantBits(), uuid.getMostSignificantBits()) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from UUID to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testUuidToLong256CoercionError() throws Exception { + String table = "test_qwp_uuid_to_long256_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v LONG256, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + UUID uuid = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + sender.table(table) + .uuidColumn("v", uuid.getLeastSignificantBits(), uuid.getMostSignificantBits()) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from UUID to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testUuidToLongCoercionError() throws Exception { + String table = "test_qwp_uuid_to_long_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v LONG, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + UUID uuid = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + sender.table(table) + .uuidColumn("v", uuid.getLeastSignificantBits(), uuid.getMostSignificantBits()) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from UUID to") && msg.contains("is not supported") + ); + } + } + + @Test + public void testUuidToShortCoercionError() throws Exception { + String table = "test_qwp_uuid_to_short_error"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "s SHORT, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + try (QwpWebSocketSender sender = createQwpSender()) { + UUID uuid = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + sender.table(table) + .uuidColumn("s", uuid.getLeastSignificantBits(), uuid.getMostSignificantBits()) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("type coercion from UUID to SHORT is not supported") + ); + } + } + + @Test + public void testUuidToString() throws Exception { + String table = "test_qwp_uuid_to_string"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "s STRING, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + UUID uuid = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .uuidColumn("s", uuid.getLeastSignificantBits(), uuid.getMostSignificantBits()) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 1); + assertSqlEventually( + "s\tts\n" + + "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11\t1970-01-01T00:00:01.000000000Z\n", + "SELECT s, ts FROM " + table); + } + + @Test + public void testUuidToSymbolCoercionError() throws Exception { + String table = "test_qwp_uuid_to_symbol_error"; + useTable(table); + execute("CREATE TABLE " + table + " (v SYMBOL, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + try (QwpWebSocketSender sender = createQwpSender()) { + UUID uuid = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + sender.table(table) + .uuidColumn("v", uuid.getLeastSignificantBits(), uuid.getMostSignificantBits()) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + String msg = e.getMessage(); + Assert.assertTrue( + "Expected coercion error but got: " + msg, + msg.contains("cannot write UUID") && msg.contains("SYMBOL") + ); + } + } + + @Test + public void testUuidToVarchar() throws Exception { + String table = "test_qwp_uuid_to_varchar"; + useTable(table); + execute("CREATE TABLE " + table + " (" + + "v VARCHAR, " + + "ts TIMESTAMP" + + ") TIMESTAMP(ts) PARTITION BY DAY"); + assertTableExistsEventually(table); + + UUID uuid = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .uuidColumn("v", uuid.getLeastSignificantBits(), uuid.getMostSignificantBits()) + .at(1_000_000, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 1); + assertSqlEventually( + "v\tts\n" + + "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11\t1970-01-01T00:00:01.000000000Z\n", + "SELECT v, ts FROM " + table); + } + + @Test + public void testWriteAllTypesInOneRow() throws Exception { + String table = "test_qwp_all_types"; + useTable(table); + + UUID uuid = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + double[] arr1d = {1.0, 2.0, 3.0}; + long tsMicros = 1_645_747_200_000_000L; // 2022-02-25T00:00:00Z + + try (QwpWebSocketSender sender = createQwpSender()) { + sender.table(table) + .symbol("sym", "test_symbol") + .boolColumn("bool_col", true) + .shortColumn("short_col", (short) 42) + .intColumn("int_col", 100_000) + .longColumn("long_col", 1_000_000_000L) + .floatColumn("float_col", 2.5f) + .doubleColumn("double_col", 3.14) + .stringColumn("string_col", "hello") + .charColumn("char_col", 'Z') + .timestampColumn("ts_col", tsMicros, ChronoUnit.MICROS) + .uuidColumn("uuid_col", uuid.getLeastSignificantBits(), uuid.getMostSignificantBits()) + .long256Column("long256_col", 1, 0, 0, 0) + .doubleArray("arr_col", arr1d) + .decimalColumn("decimal_col", "99.99") + .at(tsMicros, ChronoUnit.MICROS); + sender.flush(); + } + + assertTableSizeEventually(table, 1); + } + + private QwpWebSocketSender createQwpSender() { + return QwpWebSocketSender.connect(getQuestDbHost(), getHttpPort()); + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketEncoderTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketEncoderTest.java new file mode 100644 index 0000000..d5909c3 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketEncoderTest.java @@ -0,0 +1,1362 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.cutlass.qwp.client.GlobalSymbolDictionary; +import io.questdb.client.cutlass.qwp.client.QwpBufferWriter; +import io.questdb.client.cutlass.qwp.client.QwpWebSocketEncoder; +import io.questdb.client.cutlass.qwp.protocol.QwpTableBuffer; +import io.questdb.client.std.Unsafe; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; +import org.junit.Assert; +import org.junit.Test; + +import static io.questdb.client.cutlass.qwp.protocol.QwpConstants.*; + +/** + * Unit tests for QwpWebSocketEncoder. + */ +public class QwpWebSocketEncoderTest { + + @Test + public void testBufferResetAndReuse() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test")) { + + // First batch + for (int i = 0; i < 100; i++) { + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("x", TYPE_LONG, false); + col.addLong(i); + buffer.nextRow(); + } + int size1 = encoder.encode(buffer, false); + + // Reset and second batch + buffer.reset(); + for (int i = 0; i < 50; i++) { + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("x", TYPE_LONG, false); + col.addLong(i * 2); + buffer.nextRow(); + } + int size2 = encoder.encode(buffer, false); + + Assert.assertTrue(size1 > size2); // More rows = larger + Assert.assertEquals(50, buffer.getRowCount()); + } + }); + } + + @Test + public void testEncode2DDoubleArray() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("matrix", TYPE_DOUBLE_ARRAY, true); + col.addDoubleArray(new double[][]{{1.0, 2.0}, {3.0, 4.0}}); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + } + }); + } + + @Test + public void testEncode2DLongArray() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("matrix", TYPE_LONG_ARRAY, true); + col.addLongArray(new long[][]{{1L, 2L}, {3L, 4L}}); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + } + }); + } + + @Test + public void testEncode3DDoubleArray() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("tensor", TYPE_DOUBLE_ARRAY, true); + col.addDoubleArray(new double[][][]{ + {{1.0, 2.0}, {3.0, 4.0}}, + {{5.0, 6.0}, {7.0, 8.0}} + }); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + } + }); + } + + @Test + public void testEncodeAllBasicTypesInOneRow() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("all_types")) { + + buffer.getOrCreateColumn("b", TYPE_BOOLEAN, false).addBoolean(true); + buffer.getOrCreateColumn("by", TYPE_BYTE, false).addByte((byte) 42); + buffer.getOrCreateColumn("sh", TYPE_SHORT, false).addShort((short) 1000); + buffer.getOrCreateColumn("i", TYPE_INT, false).addInt(100000); + buffer.getOrCreateColumn("l", TYPE_LONG, false).addLong(1000000000L); + buffer.getOrCreateColumn("f", TYPE_FLOAT, false).addFloat(3.14f); + buffer.getOrCreateColumn("d", TYPE_DOUBLE, false).addDouble(3.14159265); + buffer.getOrCreateColumn("s", TYPE_STRING, true).addString("test"); + buffer.getOrCreateColumn("sym", TYPE_SYMBOL, false).addSymbol("AAPL"); + buffer.getOrCreateColumn("", TYPE_TIMESTAMP, true).addLong(1000000L); + + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + Assert.assertEquals(1, buffer.getRowCount()); + } + }); + } + + @Test + public void testEncodeAllBooleanValues() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("flag", TYPE_BOOLEAN, false); + for (int i = 0; i < 100; i++) { + col.addBoolean(i % 2 == 0); + buffer.nextRow(); + } + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + Assert.assertEquals(100, buffer.getRowCount()); + } + }); + } + + @Test + public void testEncodeDecimal128() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("amount", TYPE_DECIMAL128, false); + col.addDecimal128(io.questdb.client.std.Decimal128.fromLong(123456789012345L, 4)); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + } + }); + } + + @Test + public void testEncodeDecimal256() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("bignum", TYPE_DECIMAL256, false); + col.addDecimal256(io.questdb.client.std.Decimal256.fromLong(Long.MAX_VALUE, 6)); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + } + }); + } + + @Test + public void testEncodeDecimal64() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("price", TYPE_DECIMAL64, false); + col.addDecimal64(io.questdb.client.std.Decimal64.fromLong(12345L, 2)); // 123.45 + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + } + }); + } + + @Test + public void testEncodeDoubleArray() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("values", TYPE_DOUBLE_ARRAY, true); + col.addDoubleArray(new double[]{1.0, 2.0, 3.0}); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + } + }); + } + + @Test + public void testEncodeEmptyString() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("name", TYPE_STRING, true); + col.addString(""); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + } + }); + } + + @Test + public void testEncodeEmptyTableName() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("")) { + // Edge case: empty table name (probably invalid but let's verify encoding works) + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("x", TYPE_LONG, false); + col.addLong(1L); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 0); + } + }); + } + + @Test + public void testEncodeLargeArray() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + // Large 1D array + double[] largeArray = new double[1000]; + for (int i = 0; i < 1000; i++) { + largeArray[i] = i * 1.5; + } + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("values", TYPE_DOUBLE_ARRAY, true); + col.addDoubleArray(largeArray); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 8000); // At least 8 bytes per double + } + }); + } + + @Test + public void testEncodeLargeRowCount() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("metrics")) { + + for (int i = 0; i < 10_000; i++) { + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("x", TYPE_LONG, false); + col.addLong(i); + buffer.nextRow(); + } + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + Assert.assertEquals(10_000, buffer.getRowCount()); + } + }); + } + + @Test + public void testEncodeLongArray() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("values", TYPE_LONG_ARRAY, true); + col.addLongArray(new long[]{1L, 2L, 3L}); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + } + }); + } + + // ==================== SYMBOL COLUMN TESTS ==================== + + @Test + public void testEncodeLongString() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + String sb = "a".repeat(10_000); + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("data", TYPE_STRING, true); + col.addString(sb); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 10_000); + } + }); + } + + @Test + public void testEncodeMaxMinLong() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("x", TYPE_LONG, false); + col.addLong(Long.MAX_VALUE); + buffer.nextRow(); + + col.addLong(Long.MIN_VALUE); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + Assert.assertEquals(2, buffer.getRowCount()); + } + }); + } + + @Test + public void testEncodeMixedColumnTypes() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("events")) { + + // Add columns of different types + QwpTableBuffer.ColumnBuffer symbolCol = buffer.getOrCreateColumn("host", TYPE_SYMBOL, false); + symbolCol.addSymbol("server1"); + + QwpTableBuffer.ColumnBuffer longCol = buffer.getOrCreateColumn("count", TYPE_LONG, false); + longCol.addLong(42); + + QwpTableBuffer.ColumnBuffer doubleCol = buffer.getOrCreateColumn("value", TYPE_DOUBLE, false); + doubleCol.addDouble(3.14); + + QwpTableBuffer.ColumnBuffer boolCol = buffer.getOrCreateColumn("active", TYPE_BOOLEAN, false); + boolCol.addBoolean(true); + + QwpTableBuffer.ColumnBuffer stringCol = buffer.getOrCreateColumn("message", TYPE_STRING, true); + stringCol.addString("hello world"); + + QwpTableBuffer.ColumnBuffer tsCol = buffer.getOrCreateColumn("", TYPE_TIMESTAMP, true); + tsCol.addLong(1000000L); + + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + } + }); + } + + @Test + public void testEncodeMixedColumnsMultipleRows() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("events")) { + + for (int i = 0; i < 50; i++) { + QwpTableBuffer.ColumnBuffer symbolCol = buffer.getOrCreateColumn("host", TYPE_SYMBOL, false); + symbolCol.addSymbol("server" + (i % 5)); + + QwpTableBuffer.ColumnBuffer longCol = buffer.getOrCreateColumn("count", TYPE_LONG, false); + longCol.addLong(i * 10); + + QwpTableBuffer.ColumnBuffer doubleCol = buffer.getOrCreateColumn("value", TYPE_DOUBLE, false); + doubleCol.addDouble(i * 1.5); + + QwpTableBuffer.ColumnBuffer tsCol = buffer.getOrCreateColumn("", TYPE_TIMESTAMP, true); + tsCol.addLong(1000000L + i); + + buffer.nextRow(); + } + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + Assert.assertEquals(50, buffer.getRowCount()); + } + }); + } + + // ==================== UUID COLUMN TESTS ==================== + + @Test + public void testEncodeMultipleColumns() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("weather")) { + + // Add multiple columns + QwpTableBuffer.ColumnBuffer tempCol = buffer.getOrCreateColumn("temperature", TYPE_DOUBLE, false); + tempCol.addDouble(23.5); + + QwpTableBuffer.ColumnBuffer humCol = buffer.getOrCreateColumn("humidity", TYPE_LONG, false); + humCol.addLong(65); + + QwpTableBuffer.ColumnBuffer tsCol = buffer.getOrCreateColumn("", TYPE_TIMESTAMP, true); + tsCol.addLong(1000000L); + + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + + // Verify header + QwpBufferWriter buf = encoder.getBuffer(); + long ptr = buf.getBufferPtr(); + Assert.assertEquals((byte) 'I', Unsafe.getUnsafe().getByte(ptr)); + Assert.assertEquals((byte) 'L', Unsafe.getUnsafe().getByte(ptr + 1)); + Assert.assertEquals((byte) 'P', Unsafe.getUnsafe().getByte(ptr + 2)); + Assert.assertEquals((byte) '4', Unsafe.getUnsafe().getByte(ptr + 3)); + } + }); + } + + @Test + public void testEncodeMultipleDecimal64() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("price", TYPE_DECIMAL64, false); + col.addDecimal64(io.questdb.client.std.Decimal64.fromLong(12345L, 2)); // 123.45 + buffer.nextRow(); + + col.addDecimal64(io.questdb.client.std.Decimal64.fromLong(67890L, 2)); // 678.90 + buffer.nextRow(); + + col.addDecimal64(io.questdb.client.std.Decimal64.fromLong(11111L, 2)); // 111.11 + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + Assert.assertEquals(3, buffer.getRowCount()); + } + }); + } + + // ==================== DECIMAL COLUMN TESTS ==================== + + @Test + public void testEncodeMultipleRows() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("metrics")) { + + for (int i = 0; i < 100; i++) { + QwpTableBuffer.ColumnBuffer valCol = buffer.getOrCreateColumn("value", TYPE_LONG, false); + valCol.addLong(i); + + QwpTableBuffer.ColumnBuffer tsCol = buffer.getOrCreateColumn("", TYPE_TIMESTAMP, true); + tsCol.addLong(1000000L + i); + + buffer.nextRow(); + } + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + Assert.assertEquals(100, buffer.getRowCount()); + } + }); + } + + @Test + public void testEncodeMultipleSymbolsSameDictionary() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("host", TYPE_SYMBOL, false); + col.addSymbol("server1"); + buffer.nextRow(); + + col.addSymbol("server1"); // Same symbol + buffer.nextRow(); + + col.addSymbol("server2"); // Different symbol + buffer.nextRow(); + + col.addSymbol("server1"); // Back to first + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + Assert.assertEquals(4, buffer.getRowCount()); + } + }); + } + + @Test + public void testEncodeMultipleUuids() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("id", TYPE_UUID, false); + for (int i = 0; i < 10; i++) { + col.addUuid(i * 1000L, i * 2000L); + buffer.nextRow(); + } + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + Assert.assertEquals(10, buffer.getRowCount()); + } + }); + } + + @Test + public void testEncodeNaNDouble() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("x", TYPE_DOUBLE, false); + col.addDouble(Double.NaN); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + } + }); + } + + // ==================== ARRAY COLUMN TESTS ==================== + + @Test + public void testEncodeNegativeLong() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("x", TYPE_LONG, false); + col.addLong(-123456789L); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + } + }); + } + + @Test + public void testEncodeNullableColumnWithNull() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test")) { + + // Nullable column with null + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("name", TYPE_STRING, true); + col.addString(null); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + } + }); + } + + @Test + public void testEncodeNullableColumnWithValue() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test")) { + + // Nullable column with a value + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("name", TYPE_STRING, true); + col.addString("hello"); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + } + }); + } + + @Test + public void testEncodeNullableSymbolWithNull() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("host", TYPE_SYMBOL, true); + col.addSymbol("server1"); + buffer.nextRow(); + + col.addSymbol(null); // Null symbol + buffer.nextRow(); + + col.addSymbol("server2"); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + Assert.assertEquals(3, buffer.getRowCount()); + } + }); + } + + // ==================== MULTIPLE ROWS TESTS ==================== + + @Test + public void testEncodeSingleRowWithBoolean() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("active", TYPE_BOOLEAN, false); + col.addBoolean(true); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + } + }); + } + + @Test + public void testEncodeSingleRowWithDouble() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("temperature", TYPE_DOUBLE, false); + col.addDouble(23.5); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + } + }); + } + + // ==================== MIXED COLUMN TYPES ==================== + + @Test + public void testEncodeSingleRowWithLong() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + // Add a long column + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("value", TYPE_LONG, false); + col.addLong(12345L); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); // At least header size + + QwpBufferWriter buf = encoder.getBuffer(); + long ptr = buf.getBufferPtr(); + + // Verify header magic + Assert.assertEquals((byte) 'I', Unsafe.getUnsafe().getByte(ptr)); + Assert.assertEquals((byte) 'L', Unsafe.getUnsafe().getByte(ptr + 1)); + Assert.assertEquals((byte) 'P', Unsafe.getUnsafe().getByte(ptr + 2)); + Assert.assertEquals((byte) '4', Unsafe.getUnsafe().getByte(ptr + 3)); + + // Version + Assert.assertEquals(VERSION_1, Unsafe.getUnsafe().getByte(ptr + 4)); + + // Table count (little-endian short) + Assert.assertEquals((short) 1, Unsafe.getUnsafe().getShort(ptr + 6)); + } + }); + } + + @Test + public void testEncodeSingleRowWithString() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("name", TYPE_STRING, true); + col.addString("hello"); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + } + }); + } + + // ==================== EDGE CASES ==================== + + @Test + public void testEncodeSingleRowWithTimestamp() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + // Add a timestamp column (designated timestamp uses empty name) + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("", TYPE_TIMESTAMP, true); + col.addLong(1000000L); // Micros + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + } + }); + } + + @Test + public void testEncodeSingleSymbol() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("host", TYPE_SYMBOL, false); + col.addSymbol("server1"); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + } + }); + } + + @Test + public void testEncodeSpecialDoubles() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("x", TYPE_DOUBLE, false); + col.addDouble(Double.MAX_VALUE); + buffer.nextRow(); + + col.addDouble(Double.MIN_VALUE); + buffer.nextRow(); + + col.addDouble(Double.POSITIVE_INFINITY); + buffer.nextRow(); + + col.addDouble(Double.NEGATIVE_INFINITY); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + Assert.assertEquals(4, buffer.getRowCount()); + } + }); + } + + @Test + public void testEncodeSymbolWithManyDistinctValues() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("host", TYPE_SYMBOL, false); + for (int i = 0; i < 100; i++) { + col.addSymbol("server" + i); + buffer.nextRow(); + } + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + Assert.assertEquals(100, buffer.getRowCount()); + } + }); + } + + @Test + public void testEncodeUnicodeString() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("name", TYPE_STRING, true); + col.addString("Hello 世界 🌍"); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + } + }); + } + + @Test + public void testEncodeUuid() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("id", TYPE_UUID, false); + col.addUuid(0x123456789ABCDEF0L, 0xFEDCBA9876543210L); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + } + }); + } + + @Test + public void testEncodeWithDeltaDict_freshConnection_sendsAllSymbols() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + GlobalSymbolDictionary globalDict = new GlobalSymbolDictionary(); + + // Add symbol column with global IDs + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("ticker", TYPE_SYMBOL, false); + + // Simulate adding symbols via global dictionary + int id1 = globalDict.getOrAddSymbol("AAPL"); // ID 0 + int id2 = globalDict.getOrAddSymbol("GOOG"); // ID 1 + col.addSymbolWithGlobalId("AAPL", id1); + buffer.nextRow(); + col.addSymbolWithGlobalId("GOOG", id2); + buffer.nextRow(); + + // Fresh connection: confirmedMaxId = -1, so delta should include all symbols (0, 1) + int confirmedMaxId = -1; + int batchMaxId = 1; + + int size = encoder.encodeWithDeltaDict(buffer, globalDict, confirmedMaxId, batchMaxId, false); + Assert.assertTrue(size > 12); + + QwpBufferWriter buf = encoder.getBuffer(); + long ptr = buf.getBufferPtr(); + + // Verify header flag has FLAG_DELTA_SYMBOL_DICT set + byte flags = Unsafe.getUnsafe().getByte(ptr + HEADER_OFFSET_FLAGS); + Assert.assertTrue("Delta flag should be set", (flags & FLAG_DELTA_SYMBOL_DICT) != 0); + } + }); + } + + @Test + public void testEncodeWithDeltaDict_noNewSymbols_sendsEmptyDelta() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + GlobalSymbolDictionary globalDict = new GlobalSymbolDictionary(); + + // Pre-populate dictionary with all symbols + int id0 = globalDict.getOrAddSymbol("AAPL"); // ID 0 + int id1 = globalDict.getOrAddSymbol("GOOG"); // ID 1 + + // Use only existing symbols + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("ticker", TYPE_SYMBOL, false); + col.addSymbolWithGlobalId("AAPL", id0); + buffer.nextRow(); + col.addSymbolWithGlobalId("GOOG", id1); + buffer.nextRow(); + + // Server has confirmed all symbols (0-1), batchMaxId is 1 + int confirmedMaxId = 1; + int batchMaxId = 1; + + int size = encoder.encodeWithDeltaDict(buffer, globalDict, confirmedMaxId, batchMaxId, false); + Assert.assertTrue(size > 12); + + QwpBufferWriter buf = encoder.getBuffer(); + long ptr = buf.getBufferPtr(); + + // Verify delta flag is set + byte flags = Unsafe.getUnsafe().getByte(ptr + HEADER_OFFSET_FLAGS); + Assert.assertTrue("Delta flag should be set", (flags & FLAG_DELTA_SYMBOL_DICT) != 0); + + // Read delta section after header + long pos = ptr + HEADER_SIZE; + + // Read deltaStart varint (should be 2 = confirmedMaxId + 1) + int deltaStart = Unsafe.getUnsafe().getByte(pos) & 0x7F; + Assert.assertEquals(2, deltaStart); + pos++; + + // Read deltaCount varint (should be 0) + int deltaCount = Unsafe.getUnsafe().getByte(pos) & 0x7F; + Assert.assertEquals(0, deltaCount); + } + }); + } + + @Test + public void testEncodeWithDeltaDict_withConfirmed_sendsOnlyNew() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + GlobalSymbolDictionary globalDict = new GlobalSymbolDictionary(); + + // Pre-populate dictionary (simulating symbols already sent) + globalDict.getOrAddSymbol("AAPL"); // ID 0 + globalDict.getOrAddSymbol("GOOG"); // ID 1 + + // Now add new symbols + int id2 = globalDict.getOrAddSymbol("MSFT"); // ID 2 + int id3 = globalDict.getOrAddSymbol("TSLA"); // ID 3 + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("ticker", TYPE_SYMBOL, false); + col.addSymbolWithGlobalId("MSFT", id2); + buffer.nextRow(); + col.addSymbolWithGlobalId("TSLA", id3); + buffer.nextRow(); + + // Server has confirmed IDs 0-1, so delta should only include 2-3 + int confirmedMaxId = 1; + int batchMaxId = 3; + + int size = encoder.encodeWithDeltaDict(buffer, globalDict, confirmedMaxId, batchMaxId, false); + Assert.assertTrue(size > 12); + + QwpBufferWriter buf = encoder.getBuffer(); + long ptr = buf.getBufferPtr(); + + // Verify delta flag is set + byte flags = Unsafe.getUnsafe().getByte(ptr + HEADER_OFFSET_FLAGS); + Assert.assertTrue("Delta flag should be set", (flags & FLAG_DELTA_SYMBOL_DICT) != 0); + + // Read delta section after header + long pos = ptr + HEADER_SIZE; + + // Read deltaStart varint (should be 2 = confirmedMaxId + 1) + int deltaStart = Unsafe.getUnsafe().getByte(pos) & 0x7F; + Assert.assertEquals(2, deltaStart); + } + }); + } + + // ==================== SCHEMA REFERENCE TESTS ==================== + + @Test + public void testEncodeWithSchemaRef() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("x", TYPE_LONG, false); + col.addLong(42L); + buffer.nextRow(); + + int size = encoder.encode(buffer, true); // Use schema reference + Assert.assertTrue(size > 12); + } + }); + } + + // ==================== BUFFER REUSE TESTS ==================== + + @Test + public void testEncodeZeroLong() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("x", TYPE_LONG, false); + col.addLong(0L); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + } + }); + } + + @Test + public void testEncoderReusability() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer1 = new QwpTableBuffer("table1"); + QwpTableBuffer buffer2 = new QwpTableBuffer("table2")) { + // Encode first message + QwpTableBuffer.ColumnBuffer col1 = buffer1.getOrCreateColumn("x", TYPE_LONG, false); + col1.addLong(1L); + buffer1.nextRow(); + int size1 = encoder.encode(buffer1, false); + + // Encode second message (encoder should reset internally) + QwpTableBuffer.ColumnBuffer col2 = buffer2.getOrCreateColumn("y", TYPE_DOUBLE, false); + col2.addDouble(2.0); + buffer2.nextRow(); + int size2 = encoder.encode(buffer2, false); + + // Both should succeed + Assert.assertTrue(size1 > 12); + Assert.assertTrue(size2 > 12); + } + }); + } + + // ==================== ALL BASIC TYPES IN ONE ROW ==================== + + @Test + public void testGlobalSymbolDictionaryBasics() throws Exception { + assertMemoryLeak(() -> { + GlobalSymbolDictionary dict = new GlobalSymbolDictionary(); + + // Test sequential IDs + Assert.assertEquals(0, dict.getOrAddSymbol("AAPL")); + Assert.assertEquals(1, dict.getOrAddSymbol("GOOG")); + Assert.assertEquals(2, dict.getOrAddSymbol("MSFT")); + + // Test deduplication + Assert.assertEquals(0, dict.getOrAddSymbol("AAPL")); + Assert.assertEquals(1, dict.getOrAddSymbol("GOOG")); + + // Test retrieval + Assert.assertEquals("AAPL", dict.getSymbol(0)); + Assert.assertEquals("GOOG", dict.getSymbol(1)); + Assert.assertEquals("MSFT", dict.getSymbol(2)); + + // Test size + Assert.assertEquals(3, dict.size()); + }); + } + + // ==================== Delta Symbol Dictionary Tests ==================== + + @Test + public void testGorillaEncoding_compressionRatio() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("metrics")) { + encoder.setGorillaEnabled(true); + + // Add many timestamps with constant delta - best case for Gorilla + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("ts", TYPE_TIMESTAMP, true); + for (int i = 0; i < 1000; i++) { + col.addLong(1000000000L + i * 1000L); + buffer.nextRow(); + } + + int sizeWithGorilla = encoder.encode(buffer, false); + + // Calculate theoretical minimum size for Gorilla: + // - Header: 12 bytes + // - Table header, column schema, etc. + // - First timestamp: 8 bytes + // - Second timestamp: 8 bytes + // - Remaining 998 timestamps: 998 bits (1 bit each for DoD=0) = ~125 bytes + + // Calculate size without Gorilla (1000 * 8 = 8000 bytes just for timestamps) + encoder.setGorillaEnabled(false); + buffer.reset(); + col = buffer.getOrCreateColumn("ts", TYPE_TIMESTAMP, true); + for (int i = 0; i < 1000; i++) { + col.addLong(1000000000L + i * 1000L); + buffer.nextRow(); + } + + int sizeWithoutGorilla = encoder.encode(buffer, false); + + // For constant delta, Gorilla should achieve significant compression + double compressionRatio = (double) sizeWithGorilla / sizeWithoutGorilla; + Assert.assertTrue("Compression ratio should be < 0.2 for constant delta", + compressionRatio < 0.2); + } + }); + } + + @Test + public void testGorillaEncoding_multipleTimestampColumns() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test")) { + encoder.setGorillaEnabled(true); + + // Add multiple timestamp columns + for (int i = 0; i < 50; i++) { + QwpTableBuffer.ColumnBuffer ts1Col = buffer.getOrCreateColumn("ts1", TYPE_TIMESTAMP, true); + ts1Col.addLong(1000000000L + i * 1000L); + + QwpTableBuffer.ColumnBuffer ts2Col = buffer.getOrCreateColumn("ts2", TYPE_TIMESTAMP, true); + ts2Col.addLong(2000000000L + i * 2000L); + + buffer.nextRow(); + } + + int sizeWithGorilla = encoder.encode(buffer, false); + + // Compare with uncompressed + encoder.setGorillaEnabled(false); + buffer.reset(); + for (int i = 0; i < 50; i++) { + QwpTableBuffer.ColumnBuffer ts1Col = buffer.getOrCreateColumn("ts1", TYPE_TIMESTAMP, true); + ts1Col.addLong(1000000000L + i * 1000L); + + QwpTableBuffer.ColumnBuffer ts2Col = buffer.getOrCreateColumn("ts2", TYPE_TIMESTAMP, true); + ts2Col.addLong(2000000000L + i * 2000L); + + buffer.nextRow(); + } + + int sizeWithoutGorilla = encoder.encode(buffer, false); + + Assert.assertTrue("Gorilla should compress multiple timestamp columns", + sizeWithGorilla < sizeWithoutGorilla); + } + }); + } + + @Test + public void testGorillaEncoding_multipleTimestamps_usesGorillaEncoding() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test")) { + encoder.setGorillaEnabled(true); + + // Add multiple timestamps with constant delta (best compression) + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("", TYPE_TIMESTAMP, true); + for (int i = 0; i < 100; i++) { + col.addLong(1000000000L + i * 1000L); + buffer.nextRow(); + } + + int sizeWithGorilla = encoder.encode(buffer, false); + + // Now encode without Gorilla + encoder.setGorillaEnabled(false); + buffer.reset(); + col = buffer.getOrCreateColumn("", TYPE_TIMESTAMP, true); + for (int i = 0; i < 100; i++) { + col.addLong(1000000000L + i * 1000L); + buffer.nextRow(); + } + + int sizeWithoutGorilla = encoder.encode(buffer, false); + + // Gorilla should produce smaller output for constant-delta timestamps + Assert.assertTrue("Gorilla encoding should be smaller", + sizeWithGorilla < sizeWithoutGorilla); + } + }); + } + + @Test + public void testGorillaEncoding_nanosTimestamps() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test")) { + encoder.setGorillaEnabled(true); + + // Use TYPE_TIMESTAMP_NANOS + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("ts", TYPE_TIMESTAMP_NANOS, true); + for (int i = 0; i < 100; i++) { + col.addLong(1000000000000000000L + i * 1000000L); // Nanos with millisecond intervals + buffer.nextRow(); + } + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + + // Verify header has Gorilla flag + QwpBufferWriter buf = encoder.getBuffer(); + byte flags = Unsafe.getUnsafe().getByte(buf.getBufferPtr() + HEADER_OFFSET_FLAGS); + Assert.assertEquals(FLAG_GORILLA, (byte) (flags & FLAG_GORILLA)); + } + }); + } + + @Test + public void testGorillaEncoding_singleTimestamp_usesUncompressed() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test")) { + encoder.setGorillaEnabled(true); + + // Single timestamp - should use uncompressed + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("", TYPE_TIMESTAMP, true); + col.addLong(1000000L); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + } + }); + } + + @Test + public void testGorillaEncoding_twoTimestamps_usesUncompressed() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test")) { + encoder.setGorillaEnabled(true); + + // Only 2 timestamps - should use uncompressed (Gorilla needs 3+) + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("", TYPE_TIMESTAMP, true); + col.addLong(1000000L); + buffer.nextRow(); + col.addLong(2000000L); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + + // Verify header has Gorilla flag set + QwpBufferWriter buf = encoder.getBuffer(); + byte flags = Unsafe.getUnsafe().getByte(buf.getBufferPtr() + HEADER_OFFSET_FLAGS); + Assert.assertEquals(FLAG_GORILLA, (byte) (flags & FLAG_GORILLA)); + } + }); + } + + @Test + public void testGorillaEncoding_varyingDelta() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test")) { + encoder.setGorillaEnabled(true); + + // Varying deltas that exercise different buckets + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("ts", TYPE_TIMESTAMP, true); + long[] timestamps = { + 1000000000L, + 1000001000L, // delta=1000 + 1000002000L, // DoD=0 + 1000003050L, // DoD=50 + 1000004200L, // DoD=100 + 1000006200L, // DoD=850 + }; + + for (long ts : timestamps) { + col.addLong(ts); + buffer.nextRow(); + } + + int size = encoder.encode(buffer, false); + Assert.assertTrue(size > 12); + + // Verify header has Gorilla flag + QwpBufferWriter buf = encoder.getBuffer(); + byte flags = Unsafe.getUnsafe().getByte(buf.getBufferPtr() + HEADER_OFFSET_FLAGS); + Assert.assertEquals(FLAG_GORILLA, (byte) (flags & FLAG_GORILLA)); + } + }); + } + + @Test + public void testGorillaFlagDisabled() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test")) { + encoder.setGorillaEnabled(false); + Assert.assertFalse(encoder.isGorillaEnabled()); + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("ts", TYPE_TIMESTAMP, true); + col.addLong(1000000L); + buffer.nextRow(); + + encoder.encode(buffer, false); + + // Check flags byte doesn't have Gorilla bit set + QwpBufferWriter buf = encoder.getBuffer(); + byte flags = Unsafe.getUnsafe().getByte(buf.getBufferPtr() + 5); + Assert.assertEquals(0, flags & FLAG_GORILLA); + } + }); + } + + @Test + public void testGorillaFlagEnabled() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test")) { + encoder.setGorillaEnabled(true); + Assert.assertTrue(encoder.isGorillaEnabled()); + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("ts", TYPE_TIMESTAMP, true); + col.addLong(1000000L); + buffer.nextRow(); + + encoder.encode(buffer, false); + + // Check flags byte has Gorilla bit set + QwpBufferWriter buf = encoder.getBuffer(); + byte flags = Unsafe.getUnsafe().getByte(buf.getBufferPtr() + 5); + Assert.assertEquals(FLAG_GORILLA, (byte) (flags & FLAG_GORILLA)); + } + }); + } + + @Test + public void testPayloadLengthPatched() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test_table")) { + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("x", TYPE_LONG, false); + col.addLong(42L); + buffer.nextRow(); + + int size = encoder.encode(buffer, false); + + // Payload length is at offset 8 (4 magic + 1 version + 1 flags + 2 tablecount) + QwpBufferWriter buf = encoder.getBuffer(); + int payloadLength = Unsafe.getUnsafe().getInt(buf.getBufferPtr() + 8); + + // Payload length should be total size minus header (12 bytes) + Assert.assertEquals(size - 12, payloadLength); + } + }); + } + + @Test + public void testReset() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder(); + QwpTableBuffer buffer = new QwpTableBuffer("test")) { + + QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("x", TYPE_LONG, false); + col.addLong(1L); + buffer.nextRow(); + + int size1 = encoder.encode(buffer, false); + + // Reset and encode again + buffer.reset(); + col = buffer.getOrCreateColumn("x", TYPE_LONG, false); + col.addLong(2L); + buffer.nextRow(); + + int size2 = encoder.encode(buffer, false); + + // Sizes should be similar (same schema) + Assert.assertEquals(size1, size2); + } + }); + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderStateTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderStateTest.java new file mode 100644 index 0000000..60a07e0 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderStateTest.java @@ -0,0 +1,177 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.cutlass.qwp.client.InFlightWindow; +import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; +import io.questdb.client.cutlass.qwp.protocol.QwpTableBuffer; +import io.questdb.client.test.AbstractTest; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; +import org.junit.Assert; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.time.temporal.ChronoUnit; + +/** + * Verifies {@link QwpWebSocketSender} internal state management: + *

      + *
    • {@code reset()} discards all pending state, not just the current table buffer.
    • + *
    • Cached timestamp column references are invalidated during flush operations, + * preventing stale writes through freed {@code ColumnBuffer} instances.
    • + *
    + */ +public class QwpWebSocketSenderStateTest extends AbstractTest { + + @Test + public void testCachedTimestampColumnInvalidatedDuringFlush() throws Exception { + assertMemoryLeak(() -> { + QwpWebSocketSender sender = QwpWebSocketSender.createForTesting( + "localhost", 0, 1, 10_000_000, 0, 1 + ); + try { + setField(sender, "connected", true); + + // Row 1: caches cachedTimestampColumn, then auto-flush + // triggers and fails (no real connection). + try { + sender.table("t") + .longColumn("x", 1) + .at(1, ChronoUnit.MICROS); + } catch (Exception ignored) { + } + + // Clear the table buffer so a stale cached reference now + // points to a freed ColumnBuffer. + QwpTableBuffer tb = sender.getTableBuffer("t"); + tb.clear(); + + // Row 2: with the fix, atMicros() creates a fresh column + // and the row is buffered. Without, addLong() NPEs before + // sendRow()/nextRow() and the row is never counted. + try { + sender.table("t") + .longColumn("x", 2) + .at(2, ChronoUnit.MICROS); + } catch (Exception ignored) { + } + + Assert.assertEquals("row must be buffered when cache is properly invalidated", + 1, tb.getRowCount()); + } finally { + setField(sender, "connected", false); + sender.close(); + } + }); + } + + @Test + public void testCachedTimestampNanosColumnInvalidatedDuringFlush() throws Exception { + assertMemoryLeak(() -> { + QwpWebSocketSender sender = QwpWebSocketSender.createForTesting( + "localhost", 0, 1, 10_000_000, 0, 1 + ); + try { + setField(sender, "connected", true); + + try { + sender.table("t") + .longColumn("x", 1) + .at(1, ChronoUnit.NANOS); + } catch (Exception ignored) { + } + + QwpTableBuffer tb = sender.getTableBuffer("t"); + tb.clear(); + + try { + sender.table("t") + .longColumn("x", 2) + .at(2, ChronoUnit.NANOS); + } catch (Exception ignored) { + } + + Assert.assertEquals("row must be buffered when cache is properly invalidated", + 1, tb.getRowCount()); + } finally { + setField(sender, "connected", false); + sender.close(); + } + }); + } + + @Test + public void testResetClearsAllTableBuffersAndPendingRowCount() throws Exception { + assertMemoryLeak(() -> { + // Use high autoFlushRows to prevent auto-flush during the test + QwpWebSocketSender sender = QwpWebSocketSender.createForTesting( + "localhost", 0, 10_000, 10_000_000, 0, 1 + ); + try { + // Bypass ensureConnected() — mark as connected, leave client null + setField(sender, "connected", true); + setField(sender, "inFlightWindow", new InFlightWindow(1, InFlightWindow.DEFAULT_TIMEOUT_MS)); + + // Buffer rows into two different tables via the fluent API + sender.table("t1") + .longColumn("x", 1) + .at(1, ChronoUnit.MICROS); + sender.table("t2") + .longColumn("y", 2) + .at(2, ChronoUnit.MICROS); + + // Verify data is buffered + QwpTableBuffer t1 = sender.getTableBuffer("t1"); + QwpTableBuffer t2 = sender.getTableBuffer("t2"); + Assert.assertEquals("t1 should have 1 row before reset", 1, t1.getRowCount()); + Assert.assertEquals("t2 should have 1 row before reset", 1, t2.getRowCount()); + Assert.assertEquals("pendingRowCount should be 2 before reset", 2, sender.getPendingRowCount()); + + // Select t1 as the current table + sender.table("t1"); + + // Call reset — per the Sender contract this should discard + // ALL pending state, not just the current table + sender.reset(); + + // Both table buffers should be cleared + Assert.assertEquals("t1 row count should be 0 after reset", 0, t1.getRowCount()); + Assert.assertEquals("t2 row count should be 0 after reset", 0, t2.getRowCount()); + + // Pending row count should be zeroed + Assert.assertEquals("pendingRowCount should be 0 after reset", 0, sender.getPendingRowCount()); + } finally { + setField(sender, "connected", false); + sender.close(); + } + }); + } + + private static void setField(Object target, String fieldName, Object value) throws Exception { + Field f = target.getClass().getDeclaredField(fieldName); + f.setAccessible(true); + f.set(target, value); + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderTest.java new file mode 100644 index 0000000..2fccdc5 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderTest.java @@ -0,0 +1,535 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.DefaultHttpClientConfiguration; +import io.questdb.client.cutlass.http.client.WebSocketClient; +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.cutlass.qwp.client.MicrobatchBuffer; +import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; +import io.questdb.client.cutlass.qwp.client.WebSocketSendQueue; +import io.questdb.client.cutlass.qwp.protocol.QwpTableBuffer; +import io.questdb.client.network.PlainSocketFactory; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; +import org.junit.Assert; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +/** + * Unit tests for QwpWebSocketSender. + * These tests focus on state management and API validation without requiring a live server. + */ +public class QwpWebSocketSenderTest { + + @Test + public void testAtAfterCloseThrows() throws Exception { + assertMemoryLeak(() -> { + QwpWebSocketSender sender = createUnconnectedSender(); + sender.close(); + + try { + sender.at(1000L, ChronoUnit.MICROS); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage().contains("closed")); + } + }); + } + + @Test + public void testAtInstantAfterCloseThrows() throws Exception { + assertMemoryLeak(() -> { + QwpWebSocketSender sender = createUnconnectedSender(); + sender.close(); + + try { + sender.at(Instant.now()); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage().contains("closed")); + } + }); + } + + @Test + public void testAtNowAfterCloseThrows() throws Exception { + assertMemoryLeak(() -> { + QwpWebSocketSender sender = createUnconnectedSender(); + sender.close(); + + try { + sender.atNow(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage().contains("closed")); + } + }); + } + + @Test + public void testBoolColumnAfterCloseThrows() throws Exception { + assertMemoryLeak(() -> { + QwpWebSocketSender sender = createUnconnectedSender(); + sender.close(); + + try { + sender.boolColumn("x", true); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage().contains("closed")); + } + }); + } + + @Test + public void testBufferViewNotSupported() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketSender sender = createUnconnectedSender()) { + sender.bufferView(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage().contains("not supported")); + } + }); + } + + @Test + public void testCancelRowAfterCloseThrows() throws Exception { + assertMemoryLeak(() -> { + QwpWebSocketSender sender = createUnconnectedSender(); + sender.close(); + + try { + sender.cancelRow(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage().contains("closed")); + } + }); + } + + @Test + public void testCancelRowDiscardsPartialRow() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketSender sender = createUnconnectedSender()) { + sender.table("test"); + sender.longColumn("x", 1); + sender.boolColumn("y", true); + + // Row is not yet committed (no at/atNow call), cancel it + sender.cancelRow(); + + // Buffer should have no committed rows + QwpTableBuffer buf = sender.getTableBuffer("test"); + Assert.assertEquals(0, buf.getRowCount()); + } + }); + } + + @Test + public void testCancelRowNoOpWithoutTable() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketSender sender = createUnconnectedSender()) { + // cancelRow without table() should be a no-op (no NPE) + sender.cancelRow(); + } + }); + } + + @Test + public void testCloseIdemponent() throws Exception { + assertMemoryLeak(() -> { + QwpWebSocketSender sender = createUnconnectedSender(); + sender.close(); + sender.close(); // Should not throw + }); + } + + @Test + public void testConnectToClosedPort() throws Exception { + assertMemoryLeak(() -> { + try { + QwpWebSocketSender.connect("127.0.0.1", 1); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage().contains("Failed to connect")); + } + }); + } + + @Test + public void testDoubleArrayAfterCloseThrows() throws Exception { + assertMemoryLeak(() -> { + QwpWebSocketSender sender = createUnconnectedSender(); + sender.close(); + + try { + sender.doubleArray("x", new double[]{1.0, 2.0}); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage().contains("closed")); + } + }); + } + + @Test + public void testDoubleColumnAfterCloseThrows() throws Exception { + assertMemoryLeak(() -> { + QwpWebSocketSender sender = createUnconnectedSender(); + sender.close(); + + try { + sender.doubleColumn("x", 1.0); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage().contains("closed")); + } + }); + } + + @Test + public void testGorillaEnabledByDefault() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketSender sender = createUnconnectedSender()) { + Assert.assertTrue(sender.isGorillaEnabled()); + } + }); + } + + @Test + public void testLongArrayAfterCloseThrows() throws Exception { + assertMemoryLeak(() -> { + QwpWebSocketSender sender = createUnconnectedSender(); + sender.close(); + + try { + sender.longArray("x", new long[]{1L, 2L}); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage().contains("closed")); + } + }); + } + + @Test + public void testLongColumnAfterCloseThrows() throws Exception { + assertMemoryLeak(() -> { + QwpWebSocketSender sender = createUnconnectedSender(); + sender.close(); + + try { + sender.longColumn("x", 1); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage().contains("closed")); + } + }); + } + + @Test + public void testNullArrayReturnsThis() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketSender sender = createUnconnectedSender()) { + // Null arrays should be no-ops and return sender + Assert.assertSame(sender, sender.doubleArray("x", (double[]) null)); + Assert.assertSame(sender, sender.longArray("x", (long[]) null)); + } + }); + } + + @Test + public void testOperationsAfterCloseThrow() throws Exception { + assertMemoryLeak(() -> { + QwpWebSocketSender sender = createUnconnectedSender(); + sender.close(); + + try { + sender.table("test"); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage().contains("closed")); + } + }); + } + + @Test + public void testResetAfterCloseThrows() throws Exception { + assertMemoryLeak(() -> { + QwpWebSocketSender sender = createUnconnectedSender(); + sender.close(); + + try { + sender.reset(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage().contains("closed")); + } + }); + } + + @Test + public void testSealAndSwapRollsBackOnEnqueueFailure() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketSender sender = createUnconnectedAsyncSender(); ThrowingOnceWebSocketSendQueue queue = new ThrowingOnceWebSocketSendQueue()) { + setSendQueue(sender, queue); + + MicrobatchBuffer originalActive = getActiveBuffer(sender); + originalActive.writeByte((byte) 7); + originalActive.incrementRowCount(); + + try { + invokeSealAndSwapBuffer(sender); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage().contains("Synthetic enqueue failure")); + } + + // Failed enqueue must not strand the sealed buffer. + Assert.assertSame(originalActive, getActiveBuffer(sender)); + Assert.assertTrue(originalActive.isFilling()); + Assert.assertTrue(originalActive.hasData()); + Assert.assertEquals(1, originalActive.getRowCount()); + + // Retry should be possible on the same sender instance. + invokeSealAndSwapBuffer(sender); + Assert.assertNotSame(originalActive, getActiveBuffer(sender)); + } + }); + } + + @Test + public void testSetGorillaEnabled() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketSender sender = createUnconnectedSender()) { + sender.setGorillaEnabled(false); + Assert.assertFalse(sender.isGorillaEnabled()); + sender.setGorillaEnabled(true); + Assert.assertTrue(sender.isGorillaEnabled()); + } + }); + } + + @Test + public void testStringColumnAfterCloseThrows() throws Exception { + assertMemoryLeak(() -> { + QwpWebSocketSender sender = createUnconnectedSender(); + sender.close(); + + try { + sender.stringColumn("x", "test"); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage().contains("closed")); + } + }); + } + + @Test + public void testSymbolAfterCloseThrows() throws Exception { + assertMemoryLeak(() -> { + QwpWebSocketSender sender = createUnconnectedSender(); + sender.close(); + + try { + sender.symbol("x", "test"); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage().contains("closed")); + } + }); + } + + @Test + public void testTableBeforeAtNowRequired() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketSender sender = createUnconnectedSender()) { + sender.atNow(); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage().contains("table()")); + } + }); + } + + @Test + public void testTableBeforeAtRequired() throws Exception { + assertMemoryLeak(() -> { + try (QwpWebSocketSender sender = createUnconnectedSender()) { + sender.at(1000L, ChronoUnit.MICROS); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage().contains("table()")); + } + }); + } + + @Test + public void testTableBeforeColumnsRequired() throws Exception { + assertMemoryLeak(() -> { + // Create sender without connecting (we'll catch the error earlier) + try (QwpWebSocketSender sender = createUnconnectedSender()) { + sender.longColumn("x", 1); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage().contains("table()")); + } + }); + } + + @Test + public void testTimestampColumnAfterCloseThrows() throws Exception { + assertMemoryLeak(() -> { + QwpWebSocketSender sender = createUnconnectedSender(); + sender.close(); + + try { + sender.timestampColumn("x", 1000L, ChronoUnit.MICROS); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage().contains("closed")); + } + }); + } + + @Test + public void testTimestampColumnInstantAfterCloseThrows() throws Exception { + assertMemoryLeak(() -> { + QwpWebSocketSender sender = createUnconnectedSender(); + sender.close(); + + try { + sender.timestampColumn("x", Instant.now()); + Assert.fail("Expected LineSenderException"); + } catch (LineSenderException e) { + Assert.assertTrue(e.getMessage().contains("closed")); + } + }); + } + + private static MicrobatchBuffer getActiveBuffer(QwpWebSocketSender sender) throws Exception { + Field field = QwpWebSocketSender.class.getDeclaredField("activeBuffer"); + field.setAccessible(true); + return (MicrobatchBuffer) field.get(sender); + } + + private static void invokeSealAndSwapBuffer(QwpWebSocketSender sender) throws Exception { + Method method = QwpWebSocketSender.class.getDeclaredMethod("sealAndSwapBuffer"); + method.setAccessible(true); + try { + method.invoke(sender); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof Exception) { + throw (Exception) cause; + } + if (cause instanceof Error) { + throw (Error) cause; + } + throw new RuntimeException(cause); + } + } + + private static void setSendQueue(QwpWebSocketSender sender, WebSocketSendQueue queue) throws Exception { + Field field = QwpWebSocketSender.class.getDeclaredField("sendQueue"); + field.setAccessible(true); + field.set(sender, queue); + } + + /** + * Creates an async sender without connecting. + */ + private QwpWebSocketSender createUnconnectedAsyncSender() { + return QwpWebSocketSender.createForTesting("localhost", 9000, + 500, 0, 0L, // autoFlushRows, autoFlushBytes, autoFlushIntervalNanos + 8); // inFlightWindowSize + } + + /** + * Creates an async sender with custom flow control settings without connecting. + */ + private QwpWebSocketSender createUnconnectedAsyncSenderWithFlowControl( + int autoFlushRows, int autoFlushBytes, long autoFlushIntervalNanos, + int inFlightWindowSize) { + return QwpWebSocketSender.createForTesting("localhost", 9000, + autoFlushRows, autoFlushBytes, autoFlushIntervalNanos, + inFlightWindowSize); + } + + /** + * Creates a sender without connecting. + * For unit tests that don't need actual connectivity. + */ + private QwpWebSocketSender createUnconnectedSender() { + return QwpWebSocketSender.createForTesting("localhost", 9000, 1); // window=1 for sync + } + + private static class NoOpWebSocketClient extends WebSocketClient { + private NoOpWebSocketClient() { + super(DefaultHttpClientConfiguration.INSTANCE, PlainSocketFactory.INSTANCE); + } + + @Override + public boolean isConnected() { + return false; + } + + @Override + public void sendBinary(long dataPtr, int length) { + // no-op + } + + @Override + protected void ioWait(int timeout, int op) { + // no-op + } + + @Override + protected void setupIoWait() { + // no-op + } + } + + private static class ThrowingOnceWebSocketSendQueue extends WebSocketSendQueue { + private boolean failOnce = true; + + private ThrowingOnceWebSocketSendQueue() { + super(new NoOpWebSocketClient(), null, 50, 50); + } + + @Override + public boolean enqueue(MicrobatchBuffer buffer) { + if (failOnce) { + failOnce = false; + throw new LineSenderException("Synthetic enqueue failure"); + } + return true; + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/WebSocketSendQueueTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/WebSocketSendQueueTest.java new file mode 100644 index 0000000..db97192 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/WebSocketSendQueueTest.java @@ -0,0 +1,364 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.DefaultHttpClientConfiguration; +import io.questdb.client.cutlass.http.client.WebSocketClient; +import io.questdb.client.cutlass.http.client.WebSocketFrameHandler; +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.cutlass.qwp.client.InFlightWindow; +import io.questdb.client.cutlass.qwp.client.MicrobatchBuffer; +import io.questdb.client.cutlass.qwp.client.WebSocketSendQueue; +import io.questdb.client.network.PlainSocketFactory; +import io.questdb.client.std.MemoryTag; +import io.questdb.client.std.Unsafe; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; +import org.junit.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.*; + +public class WebSocketSendQueueTest { + + @Test + public void testEnqueueTimeoutWhenPendingSlotOccupied() throws Exception { + assertMemoryLeak(() -> { + InFlightWindow window = new InFlightWindow(1, 1_000); + FakeWebSocketClient client = new FakeWebSocketClient(); + MicrobatchBuffer batch0 = sealedBuffer((byte) 1); + MicrobatchBuffer batch1 = sealedBuffer((byte) 2); + WebSocketSendQueue queue = null; + + try { + // Keep window full so I/O thread cannot drain pending slot. + window.addInFlight(0); + queue = new WebSocketSendQueue(client, window, 100, 500); + queue.enqueue(batch0); + + try { + queue.enqueue(batch1); + fail("Expected enqueue timeout"); + } catch (LineSenderException e) { + assertTrue(e.getMessage().contains("Enqueue timeout")); + } + } finally { + window.acknowledgeUpTo(Long.MAX_VALUE); + closeQuietly(queue); + batch0.close(); + batch1.close(); + client.close(); + } + }); + } + + @Test + public void testEnqueueWaitsUntilSlotAvailable() throws Exception { + assertMemoryLeak(() -> { + InFlightWindow window = new InFlightWindow(1, 1_000); + FakeWebSocketClient client = new FakeWebSocketClient(); + MicrobatchBuffer batch0 = sealedBuffer((byte) 1); + MicrobatchBuffer batch1 = sealedBuffer((byte) 2); + WebSocketSendQueue queue = null; + + try { + window.addInFlight(0); + queue = new WebSocketSendQueue(client, window, 2_000, 500); + final WebSocketSendQueue finalQueue = queue; + queue.enqueue(batch0); + + CountDownLatch started = new CountDownLatch(1); + CountDownLatch finished = new CountDownLatch(1); + AtomicReference errorRef = new AtomicReference<>(); + + Thread t = new Thread(() -> { + started.countDown(); + try { + finalQueue.enqueue(batch1); + } catch (Throwable t1) { + errorRef.set(t1); + } finally { + finished.countDown(); + } + }); + t.start(); + + assertTrue(started.await(1, TimeUnit.SECONDS)); + awaitThreadBlocked(t); + assertEquals("Second enqueue should still be waiting", 1, finished.getCount()); + + // Free space so I/O thread can poll pending slot. + window.acknowledgeUpTo(0); + + assertTrue("Second enqueue should complete", finished.await(2, TimeUnit.SECONDS)); + assertNull(errorRef.get()); + } finally { + window.acknowledgeUpTo(Long.MAX_VALUE); + closeQuietly(queue); + batch0.close(); + batch1.close(); + client.close(); + } + }); + } + + @Test + public void testFlushFailsOnInvalidAckPayload() throws Exception { + assertMemoryLeak(() -> { + InFlightWindow window = new InFlightWindow(8, 5_000); + FakeWebSocketClient client = new FakeWebSocketClient(); + WebSocketSendQueue queue = null; + CountDownLatch payloadDelivered = new CountDownLatch(1); + AtomicBoolean fired = new AtomicBoolean(false); + + try { + window.addInFlight(0); + client.setTryReceiveBehavior(handler -> { + if (fired.compareAndSet(false, true)) { + emitBinary(handler, new byte[]{1, 2, 3}); + payloadDelivered.countDown(); + return true; + } + return false; + }); + + queue = new WebSocketSendQueue(client, window, 1_000, 500); + assertTrue("Expected invalid payload callback", payloadDelivered.await(2, TimeUnit.SECONDS)); + + try { + queue.flush(); + fail("Expected flush failure on invalid payload"); + } catch (LineSenderException e) { + assertTrue(e.getMessage().contains("Invalid ACK response payload")); + } + } finally { + closeQuietly(queue); + client.close(); + } + }); + } + + @Test + public void testFlushFailsOnReceiveIoError() throws Exception { + assertMemoryLeak(() -> { + InFlightWindow window = new InFlightWindow(8, 5_000); + FakeWebSocketClient client = new FakeWebSocketClient(); + WebSocketSendQueue queue = null; + CountDownLatch receiveAttempted = new CountDownLatch(1); + + try { + window.addInFlight(0); + client.setTryReceiveBehavior(handler -> { + receiveAttempted.countDown(); + throw new RuntimeException("recv-fail"); + }); + + queue = new WebSocketSendQueue(client, window, 1_000, 500); + assertTrue("Expected receive attempt", receiveAttempted.await(2, TimeUnit.SECONDS)); + long deadline = System.currentTimeMillis() + 2_000; + while (queue.getLastError() == null && System.currentTimeMillis() < deadline) { + Thread.sleep(5); + } + assertNotNull("Expected queue error after receive failure", queue.getLastError()); + + try { + queue.flush(); + fail("Expected flush failure after receive error"); + } catch (LineSenderException e) { + assertTrue(e.getMessage().contains("Error receiving response")); + } + } finally { + closeQuietly(queue); + client.close(); + } + }); + } + + @Test + public void testFlushFailsOnSendIoError() throws Exception { + assertMemoryLeak(() -> { + FakeWebSocketClient client = new FakeWebSocketClient(); + MicrobatchBuffer batch = sealedBuffer((byte) 42); + WebSocketSendQueue queue = null; + + try { + client.setSendBehavior((dataPtr, length) -> { + throw new RuntimeException("send-fail"); + }); + queue = new WebSocketSendQueue(client, null, 1_000, 500); + queue.enqueue(batch); + + try { + queue.flush(); + fail("Expected flush failure after send error"); + } catch (LineSenderException e) { + assertTrue( + e.getMessage().contains("Error sending batch") + || e.getMessage().contains("Error in send queue I/O thread") + ); + } + } finally { + closeQuietly(queue); + batch.close(); + client.close(); + } + }); + } + + @Test + public void testFlushFailsWhenServerClosesConnection() throws Exception { + assertMemoryLeak(() -> { + InFlightWindow window = new InFlightWindow(8, 5_000); + FakeWebSocketClient client = new FakeWebSocketClient(); + WebSocketSendQueue queue = null; + CountDownLatch closeDelivered = new CountDownLatch(1); + AtomicBoolean fired = new AtomicBoolean(false); + + try { + window.addInFlight(0); + client.setTryReceiveBehavior(handler -> { + if (fired.compareAndSet(false, true)) { + handler.onClose(1006, "boom"); + closeDelivered.countDown(); + return true; + } + return false; + }); + + queue = new WebSocketSendQueue(client, window, 1_000, 500); + assertTrue("Expected close callback", closeDelivered.await(2, TimeUnit.SECONDS)); + + try { + queue.flush(); + fail("Expected flush failure after close"); + } catch (LineSenderException e) { + assertTrue(e.getMessage().contains("closed")); + } + } finally { + closeQuietly(queue); + client.close(); + } + }); + } + + private static void awaitThreadBlocked(Thread thread) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (System.nanoTime() < deadline) { + Thread.State state = thread.getState(); + if (state == Thread.State.WAITING || state == Thread.State.TIMED_WAITING) { + return; + } + Thread.sleep(1); + } + fail("Thread did not reach blocked state within 5s, state: " + thread.getState()); + } + + private static void closeQuietly(WebSocketSendQueue queue) { + if (queue != null) { + queue.close(); + } + } + + private static void emitBinary(WebSocketFrameHandler handler, byte[] payload) { + long ptr = Unsafe.malloc(payload.length, MemoryTag.NATIVE_DEFAULT); + try { + for (int i = 0; i < payload.length; i++) { + Unsafe.getUnsafe().putByte(ptr + i, payload[i]); + } + handler.onBinaryMessage(ptr, payload.length); + } finally { + Unsafe.free(ptr, payload.length, MemoryTag.NATIVE_DEFAULT); + } + } + + private static MicrobatchBuffer sealedBuffer(byte value) { + MicrobatchBuffer buffer = new MicrobatchBuffer(64); + buffer.writeByte(value); + buffer.incrementRowCount(); + buffer.seal(); + return buffer; + } + + private interface SendBehavior { + void send(long dataPtr, int length); + } + + private interface TryReceiveBehavior { + boolean tryReceive(WebSocketFrameHandler handler); + } + + private static class FakeWebSocketClient extends WebSocketClient { + private volatile TryReceiveBehavior behavior = handler -> false; + private volatile boolean connected = true; + private volatile SendBehavior sendBehavior = (dataPtr, length) -> { + }; + + private FakeWebSocketClient() { + super(DefaultHttpClientConfiguration.INSTANCE, PlainSocketFactory.INSTANCE); + } + + @Override + public void close() { + connected = false; + super.close(); + } + + @Override + public boolean isConnected() { + return connected; + } + + @Override + public void sendBinary(long dataPtr, int length) { + sendBehavior.send(dataPtr, length); + } + + public void setSendBehavior(SendBehavior sendBehavior) { + this.sendBehavior = sendBehavior; + } + + public void setTryReceiveBehavior(TryReceiveBehavior behavior) { + this.behavior = behavior; + } + + @Override + public boolean tryReceiveFrame(WebSocketFrameHandler handler) { + return behavior.tryReceive(handler); + } + + @Override + protected void ioWait(int timeout, int op) { + // no-op + } + + @Override + protected void setupIoWait() { + // no-op + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/protocol/OffHeapAppendMemoryTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/protocol/OffHeapAppendMemoryTest.java new file mode 100644 index 0000000..8726bcf --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/protocol/OffHeapAppendMemoryTest.java @@ -0,0 +1,405 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.protocol; + +import io.questdb.client.cutlass.qwp.protocol.OffHeapAppendMemory; +import io.questdb.client.std.MemoryTag; +import io.questdb.client.std.Unsafe; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class OffHeapAppendMemoryTest { + + @Test + public void testCloseFreesMemory() throws Exception { + assertMemoryLeak(() -> { + long before = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_ILP_RSS); + OffHeapAppendMemory mem = new OffHeapAppendMemory(1024); + long during = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_ILP_RSS); + assertTrue(during > before); + + mem.close(); + long after = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_ILP_RSS); + assertEquals(before, after); + }); + } + + @Test + public void testDoubleCloseIsSafe() throws Exception { + assertMemoryLeak(() -> { + OffHeapAppendMemory mem = new OffHeapAppendMemory(); + mem.putInt(42); + mem.close(); + mem.close(); // should not throw + }); + } + + @Test + public void testGrowth() throws Exception { + assertMemoryLeak(() -> { + long before = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_ILP_RSS); + try (OffHeapAppendMemory mem = new OffHeapAppendMemory(8)) { + // Write more data than initial capacity to force growth + for (int i = 0; i < 100; i++) { + mem.putLong(i); + } + + assertEquals(800, mem.getAppendOffset()); + for (int i = 0; i < 100; i++) { + assertEquals(i, Unsafe.getUnsafe().getLong(mem.addressOf((long) i * 8))); + } + } + long after = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_ILP_RSS); + assertEquals(before, after); + }); + } + + @Test + public void testJumpTo() throws Exception { + assertMemoryLeak(() -> { + try (OffHeapAppendMemory mem = new OffHeapAppendMemory()) { + mem.putLong(100); + mem.putLong(200); + mem.putLong(300); + assertEquals(24, mem.getAppendOffset()); + + // Jump back to offset 8 (after first long) + mem.jumpTo(8); + assertEquals(8, mem.getAppendOffset()); + + // Write new value at offset 8 + mem.putLong(999); + assertEquals(16, mem.getAppendOffset()); + assertEquals(100, Unsafe.getUnsafe().getLong(mem.addressOf(0))); + assertEquals(999, Unsafe.getUnsafe().getLong(mem.addressOf(8))); + } + }); + } + + @Test + public void testLargeGrowth() throws Exception { + assertMemoryLeak(() -> { + long before = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_ILP_RSS); + try (OffHeapAppendMemory mem = new OffHeapAppendMemory(8)) { + // Write 10000 doubles to stress growth + for (int i = 0; i < 10_000; i++) { + mem.putDouble(i * 1.1); + } + assertEquals(80_000, mem.getAppendOffset()); + + // Verify first and last values + assertEquals(0.0, Unsafe.getUnsafe().getDouble(mem.addressOf(0)), 0.0); + assertEquals(9999 * 1.1, Unsafe.getUnsafe().getDouble(mem.addressOf(79_992)), 0.001); + } + long after = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_ILP_RSS); + assertEquals(before, after); + }); + } + + @Test + public void testMixedTypes() throws Exception { + assertMemoryLeak(() -> { + try (OffHeapAppendMemory mem = new OffHeapAppendMemory()) { + mem.putByte((byte) 1); + mem.putShort((short) 2); + mem.putInt(3); + mem.putLong(4L); + mem.putFloat(5.0f); + mem.putDouble(6.0); + + long addr = mem.pageAddress(); + assertEquals(1, Unsafe.getUnsafe().getByte(addr)); + assertEquals(2, Unsafe.getUnsafe().getShort(addr + 1)); + assertEquals(3, Unsafe.getUnsafe().getInt(addr + 3)); + assertEquals(4L, Unsafe.getUnsafe().getLong(addr + 7)); + assertEquals(5.0f, Unsafe.getUnsafe().getFloat(addr + 15), 0.0f); + assertEquals(6.0, Unsafe.getUnsafe().getDouble(addr + 19), 0.0); + assertEquals(27, mem.getAppendOffset()); + } + }); + } + + @Test + public void testPageAddress() throws Exception { + assertMemoryLeak(() -> { + try (OffHeapAppendMemory mem = new OffHeapAppendMemory()) { + assertTrue(mem.pageAddress() != 0); + assertEquals(mem.pageAddress(), mem.addressOf(0)); + mem.putLong(42); + assertEquals(mem.pageAddress() + 8, mem.addressOf(8)); + } + }); + } + + @Test + public void testPutAndReadByte() throws Exception { + assertMemoryLeak(() -> { + long before = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_ILP_RSS); + try (OffHeapAppendMemory mem = new OffHeapAppendMemory()) { + mem.putByte((byte) 42); + mem.putByte((byte) -1); + mem.putByte((byte) 0); + + assertEquals(3, mem.getAppendOffset()); + assertEquals(42, Unsafe.getUnsafe().getByte(mem.addressOf(0))); + assertEquals(-1, Unsafe.getUnsafe().getByte(mem.addressOf(1))); + assertEquals(0, Unsafe.getUnsafe().getByte(mem.addressOf(2))); + } + long after = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_ILP_RSS); + assertEquals(before, after); + }); + } + + @Test + public void testPutAndReadDouble() throws Exception { + assertMemoryLeak(() -> { + try (OffHeapAppendMemory mem = new OffHeapAppendMemory()) { + mem.putDouble(2.718281828); + mem.putDouble(Double.NaN); + + assertEquals(16, mem.getAppendOffset()); + assertEquals(2.718281828, Unsafe.getUnsafe().getDouble(mem.addressOf(0)), 0.0); + assertTrue(Double.isNaN(Unsafe.getUnsafe().getDouble(mem.addressOf(8)))); + } + }); + } + + @Test + public void testPutAndReadFloat() throws Exception { + assertMemoryLeak(() -> { + try (OffHeapAppendMemory mem = new OffHeapAppendMemory()) { + mem.putFloat(3.14f); + mem.putFloat(Float.NaN); + + assertEquals(8, mem.getAppendOffset()); + assertEquals(3.14f, Unsafe.getUnsafe().getFloat(mem.addressOf(0)), 0.0f); + assertTrue(Float.isNaN(Unsafe.getUnsafe().getFloat(mem.addressOf(4)))); + } + }); + } + + @Test + public void testPutAndReadInt() throws Exception { + assertMemoryLeak(() -> { + try (OffHeapAppendMemory mem = new OffHeapAppendMemory()) { + mem.putInt(100_000); + mem.putInt(Integer.MIN_VALUE); + + assertEquals(8, mem.getAppendOffset()); + assertEquals(100_000, Unsafe.getUnsafe().getInt(mem.addressOf(0))); + assertEquals(Integer.MIN_VALUE, Unsafe.getUnsafe().getInt(mem.addressOf(4))); + } + }); + } + + @Test + public void testPutAndReadLong() throws Exception { + assertMemoryLeak(() -> { + try (OffHeapAppendMemory mem = new OffHeapAppendMemory()) { + mem.putLong(1_000_000_000_000L); + mem.putLong(Long.MIN_VALUE); + + assertEquals(16, mem.getAppendOffset()); + assertEquals(1_000_000_000_000L, Unsafe.getUnsafe().getLong(mem.addressOf(0))); + assertEquals(Long.MIN_VALUE, Unsafe.getUnsafe().getLong(mem.addressOf(8))); + } + }); + } + + @Test + public void testPutAndReadShort() throws Exception { + assertMemoryLeak(() -> { + try (OffHeapAppendMemory mem = new OffHeapAppendMemory()) { + mem.putShort((short) 12_345); + mem.putShort(Short.MIN_VALUE); + mem.putShort(Short.MAX_VALUE); + + assertEquals(6, mem.getAppendOffset()); + assertEquals(12_345, Unsafe.getUnsafe().getShort(mem.addressOf(0))); + assertEquals(Short.MIN_VALUE, Unsafe.getUnsafe().getShort(mem.addressOf(2))); + assertEquals(Short.MAX_VALUE, Unsafe.getUnsafe().getShort(mem.addressOf(4))); + } + }); + } + + @Test + public void testPutBoolean() throws Exception { + assertMemoryLeak(() -> { + try (OffHeapAppendMemory mem = new OffHeapAppendMemory()) { + mem.putBoolean(true); + mem.putBoolean(false); + mem.putBoolean(true); + + assertEquals(3, mem.getAppendOffset()); + assertEquals(1, Unsafe.getUnsafe().getByte(mem.addressOf(0))); + assertEquals(0, Unsafe.getUnsafe().getByte(mem.addressOf(1))); + assertEquals(1, Unsafe.getUnsafe().getByte(mem.addressOf(2))); + } + }); + } + + @Test + public void testPutUtf8Ascii() throws Exception { + assertMemoryLeak(() -> { + long before = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_ILP_RSS); + try (OffHeapAppendMemory mem = new OffHeapAppendMemory()) { + mem.putUtf8("hello"); + assertEquals(5, mem.getAppendOffset()); + assertEquals('h', Unsafe.getUnsafe().getByte(mem.addressOf(0))); + assertEquals('e', Unsafe.getUnsafe().getByte(mem.addressOf(1))); + assertEquals('l', Unsafe.getUnsafe().getByte(mem.addressOf(2))); + assertEquals('l', Unsafe.getUnsafe().getByte(mem.addressOf(3))); + assertEquals('o', Unsafe.getUnsafe().getByte(mem.addressOf(4))); + } + long after = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_ILP_RSS); + assertEquals(before, after); + }); + } + + @Test + public void testPutUtf8Empty() throws Exception { + assertMemoryLeak(() -> { + try (OffHeapAppendMemory mem = new OffHeapAppendMemory()) { + mem.putUtf8(""); + assertEquals(0, mem.getAppendOffset()); + } + }); + } + + @Test + public void testPutUtf8Mixed() throws Exception { + assertMemoryLeak(() -> { + try (OffHeapAppendMemory mem = new OffHeapAppendMemory()) { + // Mix: ASCII "A" (1 byte) + e-acute (2 bytes) + CJK (3 bytes) + emoji (4 bytes) = 10 bytes + mem.putUtf8("A\u00E9\u4E16\uD83D\uDE00"); + assertEquals(10, mem.getAppendOffset()); + } + }); + } + + @Test + public void testPutUtf8MultiByte() throws Exception { + assertMemoryLeak(() -> { + try (OffHeapAppendMemory mem = new OffHeapAppendMemory()) { + // 2-byte: U+00E9 (e-acute) = C3 A9 + mem.putUtf8("\u00E9"); + assertEquals(2, mem.getAppendOffset()); + assertEquals((byte) 0xC3, Unsafe.getUnsafe().getByte(mem.addressOf(0))); + assertEquals((byte) 0xA9, Unsafe.getUnsafe().getByte(mem.addressOf(1))); + } + }); + } + + @Test + public void testPutUtf8Null() throws Exception { + assertMemoryLeak(() -> { + try (OffHeapAppendMemory mem = new OffHeapAppendMemory()) { + mem.putUtf8(null); + assertEquals(0, mem.getAppendOffset()); + } + }); + } + + @Test + public void testPutUtf8InvalidSurrogatePair() throws Exception { + assertMemoryLeak(() -> { + try (OffHeapAppendMemory mem = new OffHeapAppendMemory()) { + // High surrogate \uD800 followed by non-low-surrogate 'X'. + // Should produce '?' for the lone high surrogate, then 'X'. + mem.putUtf8("\uD800X"); + assertEquals(2, mem.getAppendOffset()); + assertEquals((byte) '?', Unsafe.getUnsafe().getByte(mem.addressOf(0))); + assertEquals((byte) 'X', Unsafe.getUnsafe().getByte(mem.addressOf(1))); + } + }); + } + + @Test + public void testPutUtf8SurrogatePairs() throws Exception { + assertMemoryLeak(() -> { + try (OffHeapAppendMemory mem = new OffHeapAppendMemory()) { + // U+1F600 (grinning face) = F0 9F 98 80 + mem.putUtf8("\uD83D\uDE00"); + assertEquals(4, mem.getAppendOffset()); + assertEquals((byte) 0xF0, Unsafe.getUnsafe().getByte(mem.addressOf(0))); + assertEquals((byte) 0x9F, Unsafe.getUnsafe().getByte(mem.addressOf(1))); + assertEquals((byte) 0x98, Unsafe.getUnsafe().getByte(mem.addressOf(2))); + assertEquals((byte) 0x80, Unsafe.getUnsafe().getByte(mem.addressOf(3))); + } + }); + } + + @Test + public void testPutUtf8ThreeByte() throws Exception { + assertMemoryLeak(() -> { + try (OffHeapAppendMemory mem = new OffHeapAppendMemory()) { + // 3-byte: U+4E16 (CJK character) = E4 B8 96 + mem.putUtf8("\u4E16"); + assertEquals(3, mem.getAppendOffset()); + assertEquals((byte) 0xE4, Unsafe.getUnsafe().getByte(mem.addressOf(0))); + assertEquals((byte) 0xB8, Unsafe.getUnsafe().getByte(mem.addressOf(1))); + assertEquals((byte) 0x96, Unsafe.getUnsafe().getByte(mem.addressOf(2))); + } + }); + } + + @Test + public void testSkip() throws Exception { + assertMemoryLeak(() -> { + try (OffHeapAppendMemory mem = new OffHeapAppendMemory()) { + mem.putInt(1); + mem.skip(8); + mem.putInt(2); + + assertEquals(16, mem.getAppendOffset()); + assertEquals(1, Unsafe.getUnsafe().getInt(mem.addressOf(0))); + assertEquals(2, Unsafe.getUnsafe().getInt(mem.addressOf(12))); + } + }); + } + + @Test + public void testTruncate() throws Exception { + assertMemoryLeak(() -> { + try (OffHeapAppendMemory mem = new OffHeapAppendMemory()) { + mem.putInt(1); + mem.putInt(2); + mem.putInt(3); + assertEquals(12, mem.getAppendOffset()); + + mem.truncate(); + assertEquals(0, mem.getAppendOffset()); + + // Can write again after truncate + mem.putInt(42); + assertEquals(4, mem.getAppendOffset()); + assertEquals(42, Unsafe.getUnsafe().getInt(mem.addressOf(0))); + } + }); + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/protocol/QwpBitWriterTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/protocol/QwpBitWriterTest.java new file mode 100644 index 0000000..5ec8c60 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/protocol/QwpBitWriterTest.java @@ -0,0 +1,206 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.protocol; + +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.cutlass.qwp.protocol.QwpBitWriter; +import io.questdb.client.cutlass.qwp.protocol.QwpGorillaEncoder; +import io.questdb.client.std.MemoryTag; +import io.questdb.client.std.Unsafe; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; +import org.junit.Test; + +import static org.junit.Assert.*; + +public class QwpBitWriterTest { + + @Test + public void testFlushThrowsOnOverflow() throws Exception { + assertMemoryLeak(() -> { + long ptr = Unsafe.malloc(1, MemoryTag.NATIVE_ILP_RSS); + try { + QwpBitWriter writer = new QwpBitWriter(); + writer.reset(ptr, 1); + // Write 8 bits to fill the single byte + writer.writeBits(0xFF, 8); + // Write a few more bits that sit in the bit buffer + writer.writeBits(0x3, 4); + // Flush should throw because there's no room for the partial byte + try { + writer.flush(); + fail("expected LineSenderException on buffer overflow during flush"); + } catch (LineSenderException e) { + assertTrue(e.getMessage().contains("buffer overflow")); + } + } finally { + Unsafe.free(ptr, 1, MemoryTag.NATIVE_ILP_RSS); + } + }); + } + + @Test + public void testGorillaEncoderThrowsOnInsufficientCapacityForFirstTimestamp() throws Exception { + assertMemoryLeak(() -> { + // Source: 1 timestamp (8 bytes), dest: only 4 bytes + long src = Unsafe.malloc(8, MemoryTag.NATIVE_ILP_RSS); + long dst = Unsafe.malloc(4, MemoryTag.NATIVE_ILP_RSS); + try { + Unsafe.getUnsafe().putLong(src, 1_000_000L); + QwpGorillaEncoder encoder = new QwpGorillaEncoder(); + try { + encoder.encodeTimestamps(dst, 4, src, 1); + fail("expected LineSenderException on buffer overflow"); + } catch (LineSenderException e) { + assertTrue(e.getMessage().contains("buffer overflow")); + } + } finally { + Unsafe.free(src, 8, MemoryTag.NATIVE_ILP_RSS); + Unsafe.free(dst, 4, MemoryTag.NATIVE_ILP_RSS); + } + }); + } + + @Test + public void testGorillaEncoderThrowsOnInsufficientCapacityForSecondTimestamp() throws Exception { + assertMemoryLeak(() -> { + // Source: 2 timestamps (16 bytes), dest: only 12 bytes (enough for first, not second) + long src = Unsafe.malloc(16, MemoryTag.NATIVE_ILP_RSS); + long dst = Unsafe.malloc(12, MemoryTag.NATIVE_ILP_RSS); + try { + Unsafe.getUnsafe().putLong(src, 1_000_000L); + Unsafe.getUnsafe().putLong(src + 8, 2_000_000L); + QwpGorillaEncoder encoder = new QwpGorillaEncoder(); + try { + encoder.encodeTimestamps(dst, 12, src, 2); + fail("expected LineSenderException on buffer overflow"); + } catch (LineSenderException e) { + assertTrue(e.getMessage().contains("buffer overflow")); + } + } finally { + Unsafe.free(src, 16, MemoryTag.NATIVE_ILP_RSS); + Unsafe.free(dst, 12, MemoryTag.NATIVE_ILP_RSS); + } + }); + } + + @Test + public void testWriteBitsThrowsOnOverflow() throws Exception { + assertMemoryLeak(() -> { + long ptr = Unsafe.malloc(4, MemoryTag.NATIVE_ILP_RSS); + try { + QwpBitWriter writer = new QwpBitWriter(); + writer.reset(ptr, 4); + // Fill the buffer (32 bits = 4 bytes) + writer.writeBits(0xFFFF_FFFFL, 32); + // Next write should throw — buffer is full + try { + writer.writeBits(1, 8); + fail("expected LineSenderException on buffer overflow"); + } catch (LineSenderException e) { + assertTrue(e.getMessage().contains("buffer overflow")); + } + } finally { + Unsafe.free(ptr, 4, MemoryTag.NATIVE_ILP_RSS); + } + }); + } + + @Test + public void testWriteBitsWithinCapacitySucceeds() throws Exception { + assertMemoryLeak(() -> { + long ptr = Unsafe.malloc(8, MemoryTag.NATIVE_ILP_RSS); + try { + QwpBitWriter writer = new QwpBitWriter(); + writer.reset(ptr, 8); + writer.writeBits(0xDEAD_BEEF_CAFE_BABEL, 64); + writer.flush(); + assertEquals(8, writer.getPosition() - ptr); + assertEquals(0xDEAD_BEEF_CAFE_BABEL, Unsafe.getUnsafe().getLong(ptr)); + } finally { + Unsafe.free(ptr, 8, MemoryTag.NATIVE_ILP_RSS); + } + }); + } + + @Test + public void testWriteByteThrowsOnOverflow() throws Exception { + assertMemoryLeak(() -> { + long ptr = Unsafe.malloc(1, MemoryTag.NATIVE_ILP_RSS); + try { + QwpBitWriter writer = new QwpBitWriter(); + writer.reset(ptr, 1); + writer.writeByte(0x42); + try { + writer.writeByte(0x43); + fail("expected LineSenderException on buffer overflow"); + } catch (LineSenderException e) { + assertTrue(e.getMessage().contains("buffer overflow")); + } + } finally { + Unsafe.free(ptr, 1, MemoryTag.NATIVE_ILP_RSS); + } + }); + } + + @Test + public void testWriteIntThrowsOnOverflow() throws Exception { + assertMemoryLeak(() -> { + long ptr = Unsafe.malloc(4, MemoryTag.NATIVE_ILP_RSS); + try { + QwpBitWriter writer = new QwpBitWriter(); + writer.reset(ptr, 4); + writer.writeInt(42); + try { + writer.writeInt(99); + fail("expected LineSenderException on buffer overflow"); + } catch (LineSenderException e) { + assertTrue(e.getMessage().contains("buffer overflow")); + } + } finally { + Unsafe.free(ptr, 4, MemoryTag.NATIVE_ILP_RSS); + } + }); + } + + @Test + public void testWriteLongThrowsOnOverflow() throws Exception { + assertMemoryLeak(() -> { + long ptr = Unsafe.malloc(8, MemoryTag.NATIVE_ILP_RSS); + try { + QwpBitWriter writer = new QwpBitWriter(); + writer.reset(ptr, 8); + writer.writeLong(42L); + try { + writer.writeLong(99L); + fail("expected LineSenderException on buffer overflow"); + } catch (LineSenderException e) { + assertTrue(e.getMessage().contains("buffer overflow")); + } + } finally { + Unsafe.free(ptr, 8, MemoryTag.NATIVE_ILP_RSS); + } + }); + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/protocol/QwpColumnDefTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/protocol/QwpColumnDefTest.java new file mode 100644 index 0000000..2f5a3ac --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/protocol/QwpColumnDefTest.java @@ -0,0 +1,98 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.protocol; + +import io.questdb.client.cutlass.qwp.protocol.QwpColumnDef; +import io.questdb.client.cutlass.qwp.protocol.QwpConstants; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class QwpColumnDefTest { + + @Test + public void testValidateAcceptsAllValidTypes() { + byte[] validTypes = { + QwpConstants.TYPE_BOOLEAN, + QwpConstants.TYPE_BYTE, + QwpConstants.TYPE_SHORT, + QwpConstants.TYPE_INT, + QwpConstants.TYPE_LONG, + QwpConstants.TYPE_FLOAT, + QwpConstants.TYPE_DOUBLE, + QwpConstants.TYPE_STRING, + QwpConstants.TYPE_SYMBOL, + QwpConstants.TYPE_TIMESTAMP, + QwpConstants.TYPE_DATE, + QwpConstants.TYPE_UUID, + QwpConstants.TYPE_LONG256, + QwpConstants.TYPE_GEOHASH, + QwpConstants.TYPE_VARCHAR, + QwpConstants.TYPE_TIMESTAMP_NANOS, + QwpConstants.TYPE_DOUBLE_ARRAY, + QwpConstants.TYPE_LONG_ARRAY, + QwpConstants.TYPE_DECIMAL64, + QwpConstants.TYPE_DECIMAL128, + QwpConstants.TYPE_DECIMAL256, + QwpConstants.TYPE_CHAR, + }; + for (byte type : validTypes) { + QwpColumnDef col = new QwpColumnDef("col", type); + col.validate(); // must not throw + } + } + + @Test + public void testValidateCharType() { + // TYPE_CHAR (0x16) must pass validation + QwpColumnDef col = new QwpColumnDef("ch", QwpConstants.TYPE_CHAR); + col.validate(); + assertEquals("CHAR", col.getTypeName()); + assertEquals(QwpConstants.TYPE_CHAR, col.getTypeCode()); + } + + @Test + public void testValidateNullableCharType() { + // TYPE_CHAR with nullable flag must also pass + byte nullableChar = (byte) (QwpConstants.TYPE_CHAR | QwpConstants.TYPE_NULLABLE_FLAG); + QwpColumnDef col = new QwpColumnDef("ch", nullableChar); + col.validate(); + assertTrue(col.isNullable()); + assertEquals(QwpConstants.TYPE_CHAR, col.getTypeCode()); + } + + @Test(expected = IllegalArgumentException.class) + public void testValidateRejectsInvalidType() { + QwpColumnDef col = new QwpColumnDef("bad", (byte) 0x17); + col.validate(); + } + + @Test(expected = IllegalArgumentException.class) + public void testValidateRejectsZeroType() { + QwpColumnDef col = new QwpColumnDef("bad", (byte) 0x00); + col.validate(); + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/protocol/QwpConstantsTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/protocol/QwpConstantsTest.java new file mode 100644 index 0000000..f8654d5 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/protocol/QwpConstantsTest.java @@ -0,0 +1,232 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.protocol; + +import io.questdb.client.cutlass.qwp.protocol.QwpConstants; +import org.junit.Assert; +import org.junit.Test; + +import static io.questdb.client.cutlass.qwp.protocol.QwpConstants.*; + +public class QwpConstantsTest { + + @Test + public void testDefaultLimits() { + Assert.assertEquals(16 * 1024 * 1024, DEFAULT_MAX_BATCH_SIZE); + Assert.assertEquals(256, DEFAULT_MAX_TABLES_PER_BATCH); + Assert.assertEquals(1_000_000, DEFAULT_MAX_ROWS_PER_TABLE); + Assert.assertEquals(2048, MAX_COLUMNS_PER_TABLE); + Assert.assertEquals(64 * 1024, DEFAULT_INITIAL_RECV_BUFFER_SIZE); + Assert.assertEquals(4, DEFAULT_MAX_IN_FLIGHT_BATCHES); + } + + @Test + public void testFlagBitPositions() { + // Verify flag bits are at correct positions + Assert.assertEquals(0x01, FLAG_LZ4); + Assert.assertEquals(0x02, FLAG_ZSTD); + Assert.assertEquals(0x04, FLAG_GORILLA); + Assert.assertEquals(0x03, FLAG_COMPRESSION_MASK); + Assert.assertEquals(0x08, FLAG_DELTA_SYMBOL_DICT); + } + + @Test + public void testGetFixedTypeSize() { + Assert.assertEquals(0, QwpConstants.getFixedTypeSize(TYPE_BOOLEAN)); // Bit-packed + Assert.assertEquals(1, QwpConstants.getFixedTypeSize(TYPE_BYTE)); + Assert.assertEquals(2, QwpConstants.getFixedTypeSize(TYPE_SHORT)); + Assert.assertEquals(2, QwpConstants.getFixedTypeSize(TYPE_CHAR)); + Assert.assertEquals(4, QwpConstants.getFixedTypeSize(TYPE_INT)); + Assert.assertEquals(8, QwpConstants.getFixedTypeSize(TYPE_LONG)); + Assert.assertEquals(4, QwpConstants.getFixedTypeSize(TYPE_FLOAT)); + Assert.assertEquals(8, QwpConstants.getFixedTypeSize(TYPE_DOUBLE)); + Assert.assertEquals(8, QwpConstants.getFixedTypeSize(TYPE_TIMESTAMP)); + Assert.assertEquals(8, QwpConstants.getFixedTypeSize(TYPE_TIMESTAMP_NANOS)); + Assert.assertEquals(8, QwpConstants.getFixedTypeSize(TYPE_DATE)); + Assert.assertEquals(16, QwpConstants.getFixedTypeSize(TYPE_UUID)); + Assert.assertEquals(32, QwpConstants.getFixedTypeSize(TYPE_LONG256)); + Assert.assertEquals(8, QwpConstants.getFixedTypeSize(TYPE_DECIMAL64)); + Assert.assertEquals(16, QwpConstants.getFixedTypeSize(TYPE_DECIMAL128)); + Assert.assertEquals(32, QwpConstants.getFixedTypeSize(TYPE_DECIMAL256)); + + Assert.assertEquals(-1, QwpConstants.getFixedTypeSize(TYPE_STRING)); + Assert.assertEquals(-1, QwpConstants.getFixedTypeSize(TYPE_SYMBOL)); + Assert.assertEquals(-1, QwpConstants.getFixedTypeSize(TYPE_DOUBLE_ARRAY)); + Assert.assertEquals(-1, QwpConstants.getFixedTypeSize(TYPE_LONG_ARRAY)); + } + + @Test + public void testGetTypeName() { + Assert.assertEquals("BOOLEAN", QwpConstants.getTypeName(TYPE_BOOLEAN)); + Assert.assertEquals("INT", QwpConstants.getTypeName(TYPE_INT)); + Assert.assertEquals("STRING", QwpConstants.getTypeName(TYPE_STRING)); + Assert.assertEquals("TIMESTAMP", QwpConstants.getTypeName(TYPE_TIMESTAMP)); + Assert.assertEquals("TIMESTAMP_NANOS", QwpConstants.getTypeName(TYPE_TIMESTAMP_NANOS)); + Assert.assertEquals("DOUBLE_ARRAY", QwpConstants.getTypeName(TYPE_DOUBLE_ARRAY)); + Assert.assertEquals("LONG_ARRAY", QwpConstants.getTypeName(TYPE_LONG_ARRAY)); + Assert.assertEquals("DECIMAL64", QwpConstants.getTypeName(TYPE_DECIMAL64)); + Assert.assertEquals("DECIMAL128", QwpConstants.getTypeName(TYPE_DECIMAL128)); + Assert.assertEquals("DECIMAL256", QwpConstants.getTypeName(TYPE_DECIMAL256)); + Assert.assertEquals("CHAR", QwpConstants.getTypeName(TYPE_CHAR)); + + // Test nullable types + byte nullableInt = (byte) (TYPE_INT | TYPE_NULLABLE_FLAG); + Assert.assertEquals("INT?", QwpConstants.getTypeName(nullableInt)); + + byte nullableString = (byte) (TYPE_STRING | TYPE_NULLABLE_FLAG); + Assert.assertEquals("STRING?", QwpConstants.getTypeName(nullableString)); + } + + @Test + public void testHeaderSize() { + Assert.assertEquals(12, HEADER_SIZE); + } + + @Test + public void testIsFixedWidthType() { + Assert.assertTrue(QwpConstants.isFixedWidthType(TYPE_BOOLEAN)); + Assert.assertTrue(QwpConstants.isFixedWidthType(TYPE_BYTE)); + Assert.assertTrue(QwpConstants.isFixedWidthType(TYPE_SHORT)); + Assert.assertTrue(QwpConstants.isFixedWidthType(TYPE_CHAR)); + Assert.assertTrue(QwpConstants.isFixedWidthType(TYPE_INT)); + Assert.assertTrue(QwpConstants.isFixedWidthType(TYPE_LONG)); + Assert.assertTrue(QwpConstants.isFixedWidthType(TYPE_FLOAT)); + Assert.assertTrue(QwpConstants.isFixedWidthType(TYPE_DOUBLE)); + Assert.assertTrue(QwpConstants.isFixedWidthType(TYPE_TIMESTAMP)); + Assert.assertTrue(QwpConstants.isFixedWidthType(TYPE_TIMESTAMP_NANOS)); + Assert.assertTrue(QwpConstants.isFixedWidthType(TYPE_DATE)); + Assert.assertTrue(QwpConstants.isFixedWidthType(TYPE_UUID)); + Assert.assertTrue(QwpConstants.isFixedWidthType(TYPE_LONG256)); + Assert.assertTrue(QwpConstants.isFixedWidthType(TYPE_DECIMAL64)); + Assert.assertTrue(QwpConstants.isFixedWidthType(TYPE_DECIMAL128)); + Assert.assertTrue(QwpConstants.isFixedWidthType(TYPE_DECIMAL256)); + + Assert.assertFalse(QwpConstants.isFixedWidthType(TYPE_STRING)); + Assert.assertFalse(QwpConstants.isFixedWidthType(TYPE_SYMBOL)); + Assert.assertFalse(QwpConstants.isFixedWidthType(TYPE_GEOHASH)); + Assert.assertFalse(QwpConstants.isFixedWidthType(TYPE_VARCHAR)); + Assert.assertFalse(QwpConstants.isFixedWidthType(TYPE_DOUBLE_ARRAY)); + Assert.assertFalse(QwpConstants.isFixedWidthType(TYPE_LONG_ARRAY)); + } + + @Test + public void testMagicBytesCapabilityRequest() { + // "ILP?" in ASCII + byte[] expected = new byte[]{'I', 'L', 'P', '?'}; + Assert.assertEquals((byte) (MAGIC_CAPABILITY_REQUEST & 0xFF), expected[0]); + Assert.assertEquals((byte) ((MAGIC_CAPABILITY_REQUEST >> 8) & 0xFF), expected[1]); + Assert.assertEquals((byte) ((MAGIC_CAPABILITY_REQUEST >> 16) & 0xFF), expected[2]); + Assert.assertEquals((byte) ((MAGIC_CAPABILITY_REQUEST >> 24) & 0xFF), expected[3]); + } + + @Test + public void testMagicBytesCapabilityResponse() { + // "ILP!" in ASCII + byte[] expected = new byte[]{'I', 'L', 'P', '!'}; + Assert.assertEquals((byte) (MAGIC_CAPABILITY_RESPONSE & 0xFF), expected[0]); + Assert.assertEquals((byte) ((MAGIC_CAPABILITY_RESPONSE >> 8) & 0xFF), expected[1]); + Assert.assertEquals((byte) ((MAGIC_CAPABILITY_RESPONSE >> 16) & 0xFF), expected[2]); + Assert.assertEquals((byte) ((MAGIC_CAPABILITY_RESPONSE >> 24) & 0xFF), expected[3]); + } + + @Test + public void testMagicBytesFallback() { + // "ILP0" in ASCII + byte[] expected = new byte[]{'I', 'L', 'P', '0'}; + Assert.assertEquals((byte) (MAGIC_FALLBACK & 0xFF), expected[0]); + Assert.assertEquals((byte) ((MAGIC_FALLBACK >> 8) & 0xFF), expected[1]); + Assert.assertEquals((byte) ((MAGIC_FALLBACK >> 16) & 0xFF), expected[2]); + Assert.assertEquals((byte) ((MAGIC_FALLBACK >> 24) & 0xFF), expected[3]); + } + + @Test + public void testMagicBytesValue() { + // "QWP1" in ASCII: Q=0x51, W=0x57, P=0x50, 1=0x31 + // Little-endian: 0x31505751 + Assert.assertEquals(0x31505751, MAGIC_MESSAGE); + + // Verify ASCII encoding + byte[] expected = new byte[]{'Q', 'W', 'P', '1'}; + Assert.assertEquals((byte) (MAGIC_MESSAGE & 0xFF), expected[0]); + Assert.assertEquals((byte) ((MAGIC_MESSAGE >> 8) & 0xFF), expected[1]); + Assert.assertEquals((byte) ((MAGIC_MESSAGE >> 16) & 0xFF), expected[2]); + Assert.assertEquals((byte) ((MAGIC_MESSAGE >> 24) & 0xFF), expected[3]); + } + + @Test + public void testNullableFlag() { + Assert.assertEquals((byte) 0x80, TYPE_NULLABLE_FLAG); + Assert.assertEquals(0x7F, TYPE_MASK); + + // Test nullable type extraction + byte nullableInt = (byte) (TYPE_INT | TYPE_NULLABLE_FLAG); + Assert.assertEquals(TYPE_INT, nullableInt & TYPE_MASK); + } + + @Test + public void testSchemaModes() { + Assert.assertEquals(0x00, SCHEMA_MODE_FULL); + Assert.assertEquals(0x01, SCHEMA_MODE_REFERENCE); + } + + @Test + public void testStatusCodes() { + Assert.assertEquals(0x00, STATUS_OK); + Assert.assertEquals(0x01, STATUS_PARTIAL); + Assert.assertEquals(0x02, STATUS_SCHEMA_REQUIRED); + Assert.assertEquals(0x03, STATUS_SCHEMA_MISMATCH); + Assert.assertEquals(0x04, STATUS_TABLE_NOT_FOUND); + Assert.assertEquals(0x05, STATUS_PARSE_ERROR); + Assert.assertEquals(0x06, STATUS_INTERNAL_ERROR); + Assert.assertEquals(0x07, STATUS_OVERLOADED); + } + + @Test + public void testTypeCodes() { + // Verify type codes match specification + Assert.assertEquals(0x01, TYPE_BOOLEAN); + Assert.assertEquals(0x02, TYPE_BYTE); + Assert.assertEquals(0x03, TYPE_SHORT); + Assert.assertEquals(0x04, TYPE_INT); + Assert.assertEquals(0x05, TYPE_LONG); + Assert.assertEquals(0x06, TYPE_FLOAT); + Assert.assertEquals(0x07, TYPE_DOUBLE); + Assert.assertEquals(0x08, TYPE_STRING); + Assert.assertEquals(0x09, TYPE_SYMBOL); + Assert.assertEquals(0x0A, TYPE_TIMESTAMP); + Assert.assertEquals(0x0B, TYPE_DATE); + Assert.assertEquals(0x0C, TYPE_UUID); + Assert.assertEquals(0x0D, TYPE_LONG256); + Assert.assertEquals(0x0E, TYPE_GEOHASH); + Assert.assertEquals(0x0F, TYPE_VARCHAR); + Assert.assertEquals(0x10, TYPE_TIMESTAMP_NANOS); + Assert.assertEquals(0x11, TYPE_DOUBLE_ARRAY); + Assert.assertEquals(0x12, TYPE_LONG_ARRAY); + Assert.assertEquals(0x13, TYPE_DECIMAL64); + Assert.assertEquals(0x14, TYPE_DECIMAL128); + Assert.assertEquals(0x15, TYPE_DECIMAL256); + Assert.assertEquals(0x16, TYPE_CHAR); + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/protocol/QwpSchemaHashSurrogateTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/protocol/QwpSchemaHashSurrogateTest.java new file mode 100644 index 0000000..ab4d632 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/protocol/QwpSchemaHashSurrogateTest.java @@ -0,0 +1,122 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.protocol; + +import io.questdb.client.cutlass.qwp.protocol.QwpSchemaHash; +import io.questdb.client.cutlass.qwp.protocol.QwpTableBuffer; +import io.questdb.client.std.ObjList; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class QwpSchemaHashSurrogateTest { + + private static final byte TYPE_LONG = 0x05; + + @Test + public void testComputeSchemaHashInvalidSurrogatePair() { + byte[] types = {TYPE_LONG}; + + // "\uD800X" has a high surrogate followed by non-low-surrogate 'X'. + // With the fix, the high surrogate becomes '?' and 'X' is preserved, + // so the hash should equal the hash of "?X". + long hashInvalid = QwpSchemaHash.computeSchemaHash( + new String[]{"\uD800X"}, types + ); + long hashExpected = QwpSchemaHash.computeSchemaHash( + new String[]{"?X"}, types + ); + assertEquals(hashExpected, hashInvalid); + } + + @Test + public void testComputeSchemaHashLoneHighSurrogateAtEnd() { + byte[] types = {TYPE_LONG}; + + // "\uD800" is a lone high surrogate at end of string. + // Must hash as '?' to match OffHeapAppendMemory.putUtf8(). + long hashInvalid = QwpSchemaHash.computeSchemaHash( + new String[]{"col\uD800"}, types + ); + long hashExpected = QwpSchemaHash.computeSchemaHash( + new String[]{"col?"}, types + ); + assertEquals(hashExpected, hashInvalid); + } + + @Test + public void testComputeSchemaHashLoneLowSurrogate() { + byte[] types = {TYPE_LONG}; + + // "\uDC00" is a lone low surrogate (not preceded by a high surrogate). + // Must hash as '?' to match OffHeapAppendMemory.putUtf8(). + long hashInvalid = QwpSchemaHash.computeSchemaHash( + new String[]{"col\uDC00"}, types + ); + long hashExpected = QwpSchemaHash.computeSchemaHash( + new String[]{"col?"}, types + ); + assertEquals(hashExpected, hashInvalid); + } + + @Test + public void testComputeSchemaHashDirectInvalidSurrogatePair() { + ObjList invalidCols = new ObjList<>(); + invalidCols.add(new QwpTableBuffer.ColumnBuffer("\uD800X", TYPE_LONG, false)); + + ObjList expectedCols = new ObjList<>(); + expectedCols.add(new QwpTableBuffer.ColumnBuffer("?X", TYPE_LONG, false)); + + long hashInvalid = QwpSchemaHash.computeSchemaHashDirect(invalidCols); + long hashExpected = QwpSchemaHash.computeSchemaHashDirect(expectedCols); + assertEquals(hashExpected, hashInvalid); + } + + @Test + public void testComputeSchemaHashDirectLoneHighSurrogateAtEnd() { + ObjList invalidCols = new ObjList<>(); + invalidCols.add(new QwpTableBuffer.ColumnBuffer("col\uD800", TYPE_LONG, false)); + + ObjList expectedCols = new ObjList<>(); + expectedCols.add(new QwpTableBuffer.ColumnBuffer("col?", TYPE_LONG, false)); + + long hashInvalid = QwpSchemaHash.computeSchemaHashDirect(invalidCols); + long hashExpected = QwpSchemaHash.computeSchemaHashDirect(expectedCols); + assertEquals(hashExpected, hashInvalid); + } + + @Test + public void testComputeSchemaHashDirectLoneLowSurrogate() { + ObjList invalidCols = new ObjList<>(); + invalidCols.add(new QwpTableBuffer.ColumnBuffer("col\uDC00", TYPE_LONG, false)); + + ObjList expectedCols = new ObjList<>(); + expectedCols.add(new QwpTableBuffer.ColumnBuffer("col?", TYPE_LONG, false)); + + long hashInvalid = QwpSchemaHash.computeSchemaHashDirect(invalidCols); + long hashExpected = QwpSchemaHash.computeSchemaHashDirect(expectedCols); + assertEquals(hashExpected, hashInvalid); + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/protocol/QwpSchemaHashTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/protocol/QwpSchemaHashTest.java new file mode 100644 index 0000000..129f56b --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/protocol/QwpSchemaHashTest.java @@ -0,0 +1,331 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.protocol; + +import io.questdb.client.cutlass.qwp.protocol.QwpSchemaHash; +import io.questdb.client.std.MemoryTag; +import io.questdb.client.std.Unsafe; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; +import org.junit.Assert; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; + +public class QwpSchemaHashTest { + + private static final byte TYPE_LONG = 0x05; + + @Test + public void testColumnOrderMatters() { + // Order 1 + String[] names1 = {"price", "symbol"}; + byte[] types1 = {0x07, 0x09}; + + // Order 2 (different order) + String[] names2 = {"symbol", "price"}; + byte[] types2 = {0x09, 0x07}; + + long hash1 = QwpSchemaHash.computeSchemaHash(names1, types1); + long hash2 = QwpSchemaHash.computeSchemaHash(names2, types2); + + Assert.assertNotEquals("Column order should affect hash", hash1, hash2); + } + + @Test + public void testDeterministic() { + String[] names = {"col1", "col2", "col3"}; + byte[] types = {0x01, 0x02, 0x03}; + + long hash1 = QwpSchemaHash.computeSchemaHash(names, types); + long hash2 = QwpSchemaHash.computeSchemaHash(names, types); + long hash3 = QwpSchemaHash.computeSchemaHash(names, types); + + Assert.assertEquals("Hash should be deterministic", hash1, hash2); + Assert.assertEquals("Hash should be deterministic", hash2, hash3); + } + + @Test + public void testEmptySchema() { + String[] names = {}; + byte[] types = {}; + long hash = QwpSchemaHash.computeSchemaHash(names, types); + // Empty input should produce the same hash consistently + Assert.assertEquals(hash, QwpSchemaHash.computeSchemaHash(names, types)); + } + + @Test + public void testHasherReset() { + QwpSchemaHash.Hasher hasher = new QwpSchemaHash.Hasher(); + + byte[] data1 = "first".getBytes(StandardCharsets.UTF_8); + byte[] data2 = "second".getBytes(StandardCharsets.UTF_8); + + // Hash first data + hasher.reset(0); + hasher.update(data1); + long hash1 = hasher.getValue(); + + // Reset and hash second data + hasher.reset(0); + hasher.update(data2); + long hash2 = hasher.getValue(); + + // Should be different + Assert.assertNotEquals(hash1, hash2); + + // Reset and hash first again - should be same as original + hasher.reset(0); + hasher.update(data1); + Assert.assertEquals(hash1, hasher.getValue()); + } + + @Test + public void testHasherStreaming() { + // Test that streaming hasher produces same result as one-shot + byte[] data = "streaming test data for the hasher".getBytes(StandardCharsets.UTF_8); + + // One-shot + long oneShot = QwpSchemaHash.hash(data); + + // Streaming - byte by byte + QwpSchemaHash.Hasher hasher = new QwpSchemaHash.Hasher(); + hasher.reset(0); + for (byte b : data) { + hasher.update(b); + } + long streaming = hasher.getValue(); + + Assert.assertEquals("Streaming should match one-shot", oneShot, streaming); + } + + @Test + public void testHasherStreamingChunks() { + // Test streaming with various chunk sizes + byte[] data = "This is a longer test string to verify chunked hashing works correctly!".getBytes(StandardCharsets.UTF_8); + + long oneShot = QwpSchemaHash.hash(data); + + // Streaming - in chunks + QwpSchemaHash.Hasher hasher = new QwpSchemaHash.Hasher(); + hasher.reset(0); + + int pos = 0; + int[] chunkSizes = {5, 10, 3, 20, 7, 15}; + for (int chunkSize : chunkSizes) { + int toAdd = Math.min(chunkSize, data.length - pos); + if (toAdd > 0) { + hasher.update(data, pos, toAdd); + pos += toAdd; + } + } + // Add remaining + if (pos < data.length) { + hasher.update(data, pos, data.length - pos); + } + + Assert.assertEquals("Chunked streaming should match one-shot", oneShot, hasher.getValue()); + } + + @Test + public void testLargeSchema() { + // Test with many columns + int columnCount = 100; + String[] names = new String[columnCount]; + byte[] types = new byte[columnCount]; + + for (int i = 0; i < columnCount; i++) { + names[i] = "column_" + i; + types[i] = (byte) ((i % 15) + 1); // Cycle through types 1-15 + } + + long hash1 = QwpSchemaHash.computeSchemaHash(names, types); + long hash2 = QwpSchemaHash.computeSchemaHash(names, types); + + Assert.assertEquals("Large schema should hash consistently", hash1, hash2); + } + + @Test + public void testMultipleColumns() { + String[] names = {"symbol", "price", "timestamp"}; + byte[] types = {0x09, 0x07, 0x0A}; // SYMBOL, DOUBLE, TIMESTAMP + long hash = QwpSchemaHash.computeSchemaHash(names, types); + Assert.assertNotEquals(0, hash); + } + + @Test + public void testNameAffectsHash() { + // Different names, same type + byte[] types = {0x07}; // DOUBLE + + String[] names1 = {"price"}; + String[] names2 = {"value"}; + + long hash1 = QwpSchemaHash.computeSchemaHash(names1, types); + long hash2 = QwpSchemaHash.computeSchemaHash(names2, types); + + Assert.assertNotEquals("Name should affect hash", hash1, hash2); + } + + @Test + public void testNullableFlagAffectsHash() { + String[] names = {"value"}; + + // Non-nullable + byte[] types1 = {0x05}; // LONG + // Nullable (high bit set) + byte[] types2 = {(byte) 0x85}; // LONG | 0x80 + + long hash1 = QwpSchemaHash.computeSchemaHash(names, types1); + long hash2 = QwpSchemaHash.computeSchemaHash(names, types2); + + Assert.assertNotEquals("Nullable flag should affect hash", hash1, hash2); + } + + @Test + public void testSchemaHashWithUtf8Names() { + // Test UTF-8 column names + String[] names = {"prix", "日時", "価格"}; // French, Japanese for datetime, Japanese for price + byte[] types = {0x07, 0x0A, 0x07}; + + long hash1 = QwpSchemaHash.computeSchemaHash(names, types); + long hash2 = QwpSchemaHash.computeSchemaHash(names, types); + + Assert.assertEquals("UTF-8 names should hash consistently", hash1, hash2); + Assert.assertNotEquals(0, hash1); + } + + @Test + public void testSingleColumn() { + String[] names = {"price"}; + byte[] types = {0x07}; // DOUBLE + long hash = QwpSchemaHash.computeSchemaHash(names, types); + Assert.assertNotEquals(0, hash); + } + + @Test + public void testTypeAffectsHash() { + // Same name, different type + String[] names = {"value"}; + + byte[] types1 = {0x04}; // INT + byte[] types2 = {0x05}; // LONG + + long hash1 = QwpSchemaHash.computeSchemaHash(names, types1); + long hash2 = QwpSchemaHash.computeSchemaHash(names, types2); + + Assert.assertNotEquals("Type should affect hash", hash1, hash2); + } + + @Test + public void testXXHash64DirectMemory() throws Exception { + assertMemoryLeak(() -> { + byte[] data = "test data".getBytes(StandardCharsets.UTF_8); + long addr = Unsafe.malloc(data.length, MemoryTag.NATIVE_ILP_RSS); + try { + for (int i = 0; i < data.length; i++) { + Unsafe.getUnsafe().putByte(addr + i, data[i]); + } + + long hashFromBytes = QwpSchemaHash.hash(data); + long hashFromMem = QwpSchemaHash.hash(addr, data.length); + + Assert.assertEquals("Direct memory hash should match byte array hash", hashFromBytes, hashFromMem); + } finally { + Unsafe.free(addr, data.length, MemoryTag.NATIVE_ILP_RSS); + } + }); + } + + @Test + public void testXXHash64Empty() { + byte[] data = new byte[0]; + long hash = QwpSchemaHash.hash(data); + // XXH64("", 0) = 0xEF46DB3751D8E999 + Assert.assertEquals(0xEF46DB3751D8E999L, hash); + } + + @Test + public void testXXHash64Exactly32Bytes() { + // Edge case: exactly 32 bytes + byte[] data = new byte[32]; + for (int i = 0; i < data.length; i++) { + data[i] = (byte) (i & 0xFF); + } + + long hash = QwpSchemaHash.hash(data); + Assert.assertNotEquals(0, hash); + Assert.assertEquals(hash, QwpSchemaHash.hash(data)); + } + + @Test + public void testXXHash64KnownValue() { + // Test against a known XXHash64 value + // "abc" with seed 0 should produce a specific value + byte[] data = "abc".getBytes(StandardCharsets.UTF_8); + long hash = QwpSchemaHash.hash(data); + + // XXH64("abc", 0) = 0x44BC2CF5AD770999 + Assert.assertEquals(0x44BC2CF5AD770999L, hash); + } + + @Test + public void testXXHash64LongerString() { + // Test with a longer string to exercise the main loop + byte[] data = "Hello, World! This is a test string for XXHash64.".getBytes(StandardCharsets.UTF_8); + long hash1 = QwpSchemaHash.hash(data); + long hash2 = QwpSchemaHash.hash(data); + Assert.assertEquals(hash1, hash2); + Assert.assertNotEquals(0, hash1); + } + + @Test + public void testXXHash64Over32Bytes() { + // Test data longer than 32 bytes to exercise the main processing loop + byte[] data = new byte[100]; + for (int i = 0; i < data.length; i++) { + data[i] = (byte) (i & 0xFF); + } + + long hash = QwpSchemaHash.hash(data); + Assert.assertNotEquals(0, hash); + + // Verify deterministic + Assert.assertEquals(hash, QwpSchemaHash.hash(data)); + } + + @Test + public void testXXHash64WithSeed() { + byte[] data = "test".getBytes(StandardCharsets.UTF_8); + + long hash0 = QwpSchemaHash.hash(data, 0, data.length, 0); + long hash1 = QwpSchemaHash.hash(data, 0, data.length, 1); + long hash42 = QwpSchemaHash.hash(data, 0, data.length, 42); + + // Different seeds should produce different hashes + Assert.assertNotEquals(hash0, hash1); + Assert.assertNotEquals(hash1, hash42); + Assert.assertNotEquals(hash0, hash42); + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/protocol/QwpTableBufferTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/protocol/QwpTableBufferTest.java new file mode 100644 index 0000000..a1a7d0d --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/protocol/QwpTableBufferTest.java @@ -0,0 +1,590 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.protocol; + +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.cutlass.line.array.DoubleArray; +import io.questdb.client.cutlass.qwp.protocol.QwpConstants; +import io.questdb.client.cutlass.qwp.protocol.QwpTableBuffer; +import io.questdb.client.std.Decimal128; +import io.questdb.client.std.Decimal64; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; +import org.junit.Test; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +public class QwpTableBufferTest { + + @Test + public void testAddDecimal128RescaleOverflow() throws Exception { + assertMemoryLeak(() -> { + try (QwpTableBuffer table = new QwpTableBuffer("test")) { + QwpTableBuffer.ColumnBuffer col = table.getOrCreateColumn("d", QwpConstants.TYPE_DECIMAL128, true); + // First row sets decimalScale = 10 + col.addDecimal128(Decimal128.fromLong(1, 10)); + table.nextRow(); + // Second row at scale 0 with a large value — rescaling to scale 10 + // multiplies by 10^10, which exceeds 128-bit capacity + try { + col.addDecimal128(new Decimal128(Long.MAX_VALUE / 2, Long.MAX_VALUE, 0)); + fail("Expected LineSenderException for 128-bit overflow"); + } catch (LineSenderException e) { + assertEquals("Decimal128 overflow: rescaling from scale 0 to 10 exceeds 128-bit capacity", e.getMessage()); + } + } + }); + } + + @Test + public void testAddDecimal64RescaleOverflow() throws Exception { + assertMemoryLeak(() -> { + try (QwpTableBuffer table = new QwpTableBuffer("test")) { + QwpTableBuffer.ColumnBuffer col = table.getOrCreateColumn("d", QwpConstants.TYPE_DECIMAL64, true); + // First row sets decimalScale = 5 + col.addDecimal64(Decimal64.fromLong(1, 5)); + table.nextRow(); + // Second row at scale 0 with a large value — rescaling to scale 5 + // multiplies by 10^5 = 100_000, which exceeds 64-bit capacity + // Long.MAX_VALUE / 10 ≈ 9.2 * 10^17, * 10^5 ≈ 9.2 * 10^22 >> 2^63 + try { + col.addDecimal64(Decimal64.fromLong(Long.MAX_VALUE / 10, 0)); + fail("Expected LineSenderException for 64-bit overflow"); + } catch (LineSenderException e) { + assertEquals("Decimal64 overflow: rescaling from scale 0 to 5 exceeds 64-bit capacity", e.getMessage()); + } + } + }); + } + + @Test + public void testAddDoubleArrayNullOnNonNullableColumn() throws Exception { + assertMemoryLeak(() -> { + try (QwpTableBuffer table = new QwpTableBuffer("test")) { + QwpTableBuffer.ColumnBuffer col = table.getOrCreateColumn("arr", QwpConstants.TYPE_DOUBLE_ARRAY, false); + + // Row 0: real array + col.addDoubleArray(new double[]{1.0, 2.0}); + table.nextRow(); + + // Row 1: null on non-nullable — must write empty array metadata + col.addDoubleArray((double[]) null); + table.nextRow(); + + // Row 2: real array + col.addDoubleArray(new double[]{3.0, 4.0}); + table.nextRow(); + + assertEquals(3, table.getRowCount()); + assertEquals(3, col.getValueCount()); + assertEquals(col.getSize(), col.getValueCount()); + + // Encoder walk must not corrupt — row 1 is an empty array + double[] encoded = readDoubleArraysLikeEncoder(col); + assertArrayEquals(new double[]{1.0, 2.0, 3.0, 4.0}, encoded, 0.0); + + byte[] dims = col.getArrayDims(); + int[] shapes = col.getArrayShapes(); + assertEquals(1, dims[0]); + assertEquals(2, shapes[0]); + assertEquals(1, dims[1]); // null row: 1D empty + assertEquals(0, shapes[1]); // null row: 0 elements + assertEquals(1, dims[2]); + assertEquals(2, shapes[2]); + } + }); + } + + @Test + public void testAddLongArrayNullOnNonNullableColumn() throws Exception { + assertMemoryLeak(() -> { + try (QwpTableBuffer table = new QwpTableBuffer("test")) { + QwpTableBuffer.ColumnBuffer col = table.getOrCreateColumn("arr", QwpConstants.TYPE_LONG_ARRAY, false); + + // Row 0: real array + col.addLongArray(new long[]{10, 20}); + table.nextRow(); + + // Row 1: null on non-nullable — must write empty array metadata + col.addLongArray((long[]) null); + table.nextRow(); + + // Row 2: real array + col.addLongArray(new long[]{30, 40}); + table.nextRow(); + + assertEquals(3, table.getRowCount()); + assertEquals(3, col.getValueCount()); + assertEquals(col.getSize(), col.getValueCount()); + + // Encoder walk must not corrupt — row 1 is an empty array + long[] encoded = readLongArraysLikeEncoder(col); + assertArrayEquals(new long[]{10, 20, 30, 40}, encoded); + + byte[] dims = col.getArrayDims(); + int[] shapes = col.getArrayShapes(); + assertEquals(1, dims[0]); + assertEquals(2, shapes[0]); + assertEquals(1, dims[1]); // null row: 1D empty + assertEquals(0, shapes[1]); // null row: 0 elements + assertEquals(1, dims[2]); + assertEquals(2, shapes[2]); + } + }); + } + + @Test + public void testAddSymbolNullOnNonNullableColumn() throws Exception { + assertMemoryLeak(() -> { + try (QwpTableBuffer table = new QwpTableBuffer("test")) { + QwpTableBuffer.ColumnBuffer col = table.getOrCreateColumn("sym", QwpConstants.TYPE_SYMBOL, false); + col.addSymbol("server1"); + table.nextRow(); + + // Null on a non-nullable column must write a sentinel value, + // keeping size and valueCount in sync + col.addSymbol(null); + table.nextRow(); + + col.addSymbol("server2"); + table.nextRow(); + + assertEquals(3, table.getRowCount()); + // For non-nullable columns, every row must have a physical value + assertEquals(col.getSize(), col.getValueCount()); + } + }); + } + + @Test + public void testCancelRowRewindsDoubleArrayOffsets() throws Exception { + assertMemoryLeak(() -> { + try (QwpTableBuffer table = new QwpTableBuffer("test")) { + // Row 0: committed with [1.0, 2.0] + QwpTableBuffer.ColumnBuffer col = table.getOrCreateColumn("arr", QwpConstants.TYPE_DOUBLE_ARRAY, false); + col.addDoubleArray(new double[]{1.0, 2.0}); + table.nextRow(); + + // Row 1: committed with [3.0, 4.0] + col = table.getOrCreateColumn("arr", QwpConstants.TYPE_DOUBLE_ARRAY, false); + col.addDoubleArray(new double[]{3.0, 4.0}); + table.nextRow(); + + // Start row 2 with [5.0, 6.0] — then cancel it + col = table.getOrCreateColumn("arr", QwpConstants.TYPE_DOUBLE_ARRAY, false); + col.addDoubleArray(new double[]{5.0, 6.0}); + table.cancelCurrentRow(); + + // Add replacement row 2 with [7.0, 8.0] + col = table.getOrCreateColumn("arr", QwpConstants.TYPE_DOUBLE_ARRAY, false); + col.addDoubleArray(new double[]{7.0, 8.0}); + table.nextRow(); + + assertEquals(3, table.getRowCount()); + assertEquals(3, col.getValueCount()); + + // Walk the arrays exactly as the encoder would + double[] encoded = readDoubleArraysLikeEncoder(col); + assertArrayEquals( + new double[]{1.0, 2.0, 3.0, 4.0, 7.0, 8.0}, + encoded, + 0.0 + ); + } + }); + } + + @Test + public void testCancelRowRewindsLongArrayOffsets() throws Exception { + assertMemoryLeak(() -> { + try (QwpTableBuffer table = new QwpTableBuffer("test")) { + // Row 0: committed with [10, 20] + QwpTableBuffer.ColumnBuffer col = table.getOrCreateColumn("arr", QwpConstants.TYPE_LONG_ARRAY, false); + col.addLongArray(new long[]{10, 20}); + table.nextRow(); + + // Start row 1 with [30, 40] — then cancel it + col = table.getOrCreateColumn("arr", QwpConstants.TYPE_LONG_ARRAY, false); + col.addLongArray(new long[]{30, 40}); + table.cancelCurrentRow(); + + // Add replacement row 1 with [50, 60] + col = table.getOrCreateColumn("arr", QwpConstants.TYPE_LONG_ARRAY, false); + col.addLongArray(new long[]{50, 60}); + table.nextRow(); + + assertEquals(2, table.getRowCount()); + assertEquals(2, col.getValueCount()); + + long[] encoded = readLongArraysLikeEncoder(col); + assertArrayEquals(new long[]{10, 20, 50, 60}, encoded); + } + }); + } + + @Test + public void testCancelRowRewindsMultiDimArrayOffsets() throws Exception { + assertMemoryLeak(() -> { + try (QwpTableBuffer table = new QwpTableBuffer("test")) { + // Row 0: committed 2D array [[1.0, 2.0], [3.0, 4.0]] + QwpTableBuffer.ColumnBuffer col = table.getOrCreateColumn("arr", QwpConstants.TYPE_DOUBLE_ARRAY, false); + col.addDoubleArray(new double[][]{{1.0, 2.0}, {3.0, 4.0}}); + table.nextRow(); + + // Start row 1 with 2D array [[5.0, 6.0], [7.0, 8.0]] — cancel + col = table.getOrCreateColumn("arr", QwpConstants.TYPE_DOUBLE_ARRAY, false); + col.addDoubleArray(new double[][]{{5.0, 6.0}, {7.0, 8.0}}); + table.cancelCurrentRow(); + + // Replacement row 1 with [[9.0, 10.0], [11.0, 12.0]] + col = table.getOrCreateColumn("arr", QwpConstants.TYPE_DOUBLE_ARRAY, false); + col.addDoubleArray(new double[][]{{9.0, 10.0}, {11.0, 12.0}}); + table.nextRow(); + + assertEquals(2, table.getRowCount()); + assertEquals(2, col.getValueCount()); + + // Verify shapes are correct (2 dims per row, each [2, 2]) + int[] shapes = col.getArrayShapes(); + byte[] dims = col.getArrayDims(); + assertEquals(2, dims[0]); + assertEquals(2, dims[1]); + // Row 0 shapes: [2, 2] + assertEquals(2, shapes[0]); + assertEquals(2, shapes[1]); + // Row 1 shapes must be the replacement [2, 2], not stale data + assertEquals(2, shapes[2]); + assertEquals(2, shapes[3]); + + double[] encoded = readDoubleArraysLikeEncoder(col); + assertArrayEquals( + new double[]{1.0, 2.0, 3.0, 4.0, 9.0, 10.0, 11.0, 12.0}, + encoded, + 0.0 + ); + } + }); + } + + @Test + public void testDoubleArrayWrapperMultipleRows() throws Exception { + assertMemoryLeak(() -> { + try (QwpTableBuffer table = new QwpTableBuffer("test"); + DoubleArray arr = new DoubleArray(3)) { + QwpTableBuffer.ColumnBuffer col = table.getOrCreateColumn("arr", QwpConstants.TYPE_DOUBLE_ARRAY, false); + + arr.append(1.0).append(2.0).append(3.0); + col.addDoubleArray(arr); + table.nextRow(); + + // DoubleArray auto-wraps, so just append next row's data + arr.append(4.0).append(5.0).append(6.0); + col.addDoubleArray(arr); + table.nextRow(); + + arr.append(7.0).append(8.0).append(9.0); + col.addDoubleArray(arr); + table.nextRow(); + + assertEquals(3, col.getValueCount()); + double[] encoded = readDoubleArraysLikeEncoder(col); + assertArrayEquals( + new double[]{1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0}, + encoded, + 0.0 + ); + + byte[] dims = col.getArrayDims(); + int[] shapes = col.getArrayShapes(); + for (int i = 0; i < 3; i++) { + assertEquals(1, dims[i]); + assertEquals(3, shapes[i]); + } + } + }); + } + + @Test + public void testDoubleArrayWrapperShrinkingSize() throws Exception { + assertMemoryLeak(() -> { + try (QwpTableBuffer table = new QwpTableBuffer("test")) { + QwpTableBuffer.ColumnBuffer col = table.getOrCreateColumn("arr", QwpConstants.TYPE_DOUBLE_ARRAY, false); + + // Row 0: large array (5 elements) + try (DoubleArray big = new DoubleArray(5)) { + big.append(1.0).append(2.0).append(3.0).append(4.0).append(5.0); + col.addDoubleArray(big); + table.nextRow(); + } + + // Row 1: smaller array (2 elements) — must not see leftover data from row 0 + try (DoubleArray small = new DoubleArray(2)) { + small.append(10.0).append(20.0); + col.addDoubleArray(small); + table.nextRow(); + } + + assertEquals(2, col.getValueCount()); + double[] encoded = readDoubleArraysLikeEncoder(col); + assertArrayEquals( + new double[]{1.0, 2.0, 3.0, 4.0, 5.0, 10.0, 20.0}, + encoded, + 0.0 + ); + + int[] shapes = col.getArrayShapes(); + assertEquals(5, shapes[0]); + assertEquals(2, shapes[1]); + } + }); + } + + @Test + public void testDoubleArrayWrapperVaryingDimensionality() throws Exception { + assertMemoryLeak(() -> { + try (QwpTableBuffer table = new QwpTableBuffer("test")) { + QwpTableBuffer.ColumnBuffer col = table.getOrCreateColumn("arr", QwpConstants.TYPE_DOUBLE_ARRAY, false); + + // Row 0: 2D array (2x2) + try (DoubleArray matrix = new DoubleArray(2, 2)) { + matrix.append(1.0).append(2.0).append(3.0).append(4.0); + col.addDoubleArray(matrix); + table.nextRow(); + } + + // Row 1: 1D array (3 elements) — different dimensionality + try (DoubleArray vec = new DoubleArray(3)) { + vec.append(10.0).append(20.0).append(30.0); + col.addDoubleArray(vec); + table.nextRow(); + } + + assertEquals(2, col.getValueCount()); + + byte[] dims = col.getArrayDims(); + assertEquals(2, dims[0]); + assertEquals(1, dims[1]); + + int[] shapes = col.getArrayShapes(); + // Row 0: shape [2, 2] + assertEquals(2, shapes[0]); + assertEquals(2, shapes[1]); + // Row 1: shape [3] + assertEquals(3, shapes[2]); + + double[] encoded = readDoubleArraysLikeEncoder(col); + assertArrayEquals( + new double[]{1.0, 2.0, 3.0, 4.0, 10.0, 20.0, 30.0}, + encoded, + 0.0 + ); + } + }); + } + + @Test + public void testGetOrCreateColumnConflictingTypeFastPath() throws Exception { + assertMemoryLeak(() -> { + try (QwpTableBuffer table = new QwpTableBuffer("test")) { + // First call creates the column as LONG + table.getOrCreateColumn("x", QwpConstants.TYPE_LONG, false).addLong(1L); + table.nextRow(); + + // Second call with the same name but a different type hits the fast path + // (sequential cursor matches the column name) and must throw + try { + table.getOrCreateColumn("x", QwpConstants.TYPE_DOUBLE, false); + fail("Expected LineSenderException for column type mismatch"); + } catch (LineSenderException e) { + assertEquals( + "Column type mismatch for x: existing=" + QwpConstants.TYPE_LONG + " new=" + QwpConstants.TYPE_DOUBLE, + e.getMessage() + ); + } + } + }); + } + + @Test + public void testGetOrCreateColumnConflictingTypeSlowPath() throws Exception { + assertMemoryLeak(() -> { + try (QwpTableBuffer table = new QwpTableBuffer("test")) { + // Create two columns so the fast-path cursor can be defeated + table.getOrCreateColumn("a", QwpConstants.TYPE_LONG, false).addLong(1L); + table.getOrCreateColumn("b", QwpConstants.TYPE_STRING, true).addString("v"); + table.nextRow(); + + // Access column "b" first — cursor now expects "a" at index 0, + // but we ask for "b", so the fast path misses and falls through + // to the hash-map lookup, which must detect the type conflict + try { + table.getOrCreateColumn("b", QwpConstants.TYPE_LONG, false); + fail("Expected LineSenderException for column type mismatch"); + } catch (LineSenderException e) { + assertEquals( + "Column type mismatch for b: existing=" + QwpConstants.TYPE_STRING + " new=" + QwpConstants.TYPE_LONG, + e.getMessage() + ); + } + } + }); + } + + @Test + public void testLongArrayMultipleRows() throws Exception { + assertMemoryLeak(() -> { + try (QwpTableBuffer table = new QwpTableBuffer("test")) { + QwpTableBuffer.ColumnBuffer col = table.getOrCreateColumn("arr", QwpConstants.TYPE_LONG_ARRAY, false); + + col.addLongArray(new long[]{10, 20, 30}); + table.nextRow(); + + col.addLongArray(new long[]{40, 50, 60}); + table.nextRow(); + + col.addLongArray(new long[]{70, 80, 90}); + table.nextRow(); + + assertEquals(3, col.getValueCount()); + long[] encoded = readLongArraysLikeEncoder(col); + assertArrayEquals( + new long[]{10, 20, 30, 40, 50, 60, 70, 80, 90}, + encoded + ); + + byte[] dims = col.getArrayDims(); + int[] shapes = col.getArrayShapes(); + for (int i = 0; i < 3; i++) { + assertEquals(1, dims[i]); + assertEquals(3, shapes[i]); + } + } + }); + } + + @Test + public void testLongArrayShrinkingSize() throws Exception { + assertMemoryLeak(() -> { + try (QwpTableBuffer table = new QwpTableBuffer("test")) { + QwpTableBuffer.ColumnBuffer col = table.getOrCreateColumn("arr", QwpConstants.TYPE_LONG_ARRAY, false); + + // Row 0: large array (4 elements) + col.addLongArray(new long[]{100, 200, 300, 400}); + table.nextRow(); + + // Row 1: smaller array (2 elements) — must not see leftover data from row 0 + col.addLongArray(new long[]{10, 20}); + table.nextRow(); + + assertEquals(2, col.getValueCount()); + long[] encoded = readLongArraysLikeEncoder(col); + assertArrayEquals(new long[]{100, 200, 300, 400, 10, 20}, encoded); + + int[] shapes = col.getArrayShapes(); + assertEquals(4, shapes[0]); + assertEquals(2, shapes[1]); + } + }); + } + + /** + * Simulates the encoder's walk over array data — the same logic as + * QwpWebSocketEncoder.writeDoubleArrayColumn(). Returns the flat + * double values the encoder would serialize for the given column. + */ + private static double[] readDoubleArraysLikeEncoder(QwpTableBuffer.ColumnBuffer col) { + byte[] dims = col.getArrayDims(); + int[] shapes = col.getArrayShapes(); + double[] data = col.getDoubleArrayData(); + int count = col.getValueCount(); + + // First pass: count total elements + int totalElements = 0; + int shapeIdx = 0; + for (int row = 0; row < count; row++) { + int nDims = dims[row]; + int elemCount = 1; + for (int d = 0; d < nDims; d++) { + elemCount *= shapes[shapeIdx++]; + } + totalElements += elemCount; + } + + // Second pass: collect values + double[] result = new double[totalElements]; + shapeIdx = 0; + int dataIdx = 0; + int resultIdx = 0; + for (int row = 0; row < count; row++) { + int nDims = dims[row]; + int elemCount = 1; + for (int d = 0; d < nDims; d++) { + elemCount *= shapes[shapeIdx++]; + } + for (int e = 0; e < elemCount; e++) { + result[resultIdx++] = data[dataIdx++]; + } + } + return result; + } + + /** + * Same as above but for long arrays (mirrors QwpWebSocketEncoder.writeLongArrayColumn()). + */ + private static long[] readLongArraysLikeEncoder(QwpTableBuffer.ColumnBuffer col) { + byte[] dims = col.getArrayDims(); + int[] shapes = col.getArrayShapes(); + long[] data = col.getLongArrayData(); + int count = col.getValueCount(); + + int totalElements = 0; + int shapeIdx = 0; + for (int row = 0; row < count; row++) { + int nDims = dims[row]; + int elemCount = 1; + for (int d = 0; d < nDims; d++) { + elemCount *= shapes[shapeIdx++]; + } + totalElements += elemCount; + } + + long[] result = new long[totalElements]; + shapeIdx = 0; + int dataIdx = 0; + int resultIdx = 0; + for (int row = 0; row < count; row++) { + int nDims = dims[row]; + int elemCount = 1; + for (int d = 0; d < nDims; d++) { + elemCount *= shapes[shapeIdx++]; + } + for (int e = 0; e < elemCount; e++) { + result[resultIdx++] = data[dataIdx++]; + } + } + return result; + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/websocket/WebSocketFrameParserTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/websocket/WebSocketFrameParserTest.java new file mode 100644 index 0000000..dac1ec4 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/websocket/WebSocketFrameParserTest.java @@ -0,0 +1,737 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.websocket; + +import io.questdb.client.cutlass.qwp.websocket.WebSocketCloseCode; +import io.questdb.client.cutlass.qwp.websocket.WebSocketFrameParser; +import io.questdb.client.cutlass.qwp.websocket.WebSocketOpcode; +import io.questdb.client.std.MemoryTag; +import io.questdb.client.std.Unsafe; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; +import org.junit.Assert; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; + +/** + * Tests for the client-side WebSocket frame parser. + * The client parser expects unmasked frames (from the server) + * and rejects masked frames. + */ +public class WebSocketFrameParserTest { + + @Test + public void testControlFrameBetweenFragments() throws Exception { + assertMemoryLeak(() -> { + long buf = allocateBuffer(64); + try { + WebSocketFrameParser parser = new WebSocketFrameParser(); + + // First data fragment + writeBytes(buf, (byte) 0x01, (byte) 0x02, (byte) 'H', (byte) 'i'); + parser.parse(buf, buf + 4); + Assert.assertFalse(parser.isFin()); + Assert.assertEquals(WebSocketOpcode.TEXT, parser.getOpcode()); + + // Ping in the middle (control frame, FIN must be 1) + parser.reset(); + writeBytes(buf, (byte) 0x89, (byte) 0x00); + parser.parse(buf, buf + 2); + Assert.assertTrue(parser.isFin()); + Assert.assertEquals(WebSocketOpcode.PING, parser.getOpcode()); + + // Final data fragment + parser.reset(); + writeBytes(buf, (byte) 0x80, (byte) 0x01, (byte) '!'); + parser.parse(buf, buf + 3); + Assert.assertTrue(parser.isFin()); + Assert.assertEquals(WebSocketOpcode.CONTINUATION, parser.getOpcode()); + } finally { + freeBuffer(buf, 64); + } + }); + } + + @Test + public void testOpcodeIsControlFrame() throws Exception { + assertMemoryLeak(() -> { + Assert.assertFalse(WebSocketOpcode.isControlFrame(WebSocketOpcode.CONTINUATION)); + Assert.assertFalse(WebSocketOpcode.isControlFrame(WebSocketOpcode.TEXT)); + Assert.assertFalse(WebSocketOpcode.isControlFrame(WebSocketOpcode.BINARY)); + Assert.assertTrue(WebSocketOpcode.isControlFrame(WebSocketOpcode.CLOSE)); + Assert.assertTrue(WebSocketOpcode.isControlFrame(WebSocketOpcode.PING)); + Assert.assertTrue(WebSocketOpcode.isControlFrame(WebSocketOpcode.PONG)); + }); + } + + @Test + public void testOpcodeIsDataFrame() throws Exception { + assertMemoryLeak(() -> { + Assert.assertTrue(WebSocketOpcode.isDataFrame(WebSocketOpcode.CONTINUATION)); + Assert.assertTrue(WebSocketOpcode.isDataFrame(WebSocketOpcode.TEXT)); + Assert.assertTrue(WebSocketOpcode.isDataFrame(WebSocketOpcode.BINARY)); + Assert.assertFalse(WebSocketOpcode.isDataFrame(WebSocketOpcode.CLOSE)); + Assert.assertFalse(WebSocketOpcode.isDataFrame(WebSocketOpcode.PING)); + Assert.assertFalse(WebSocketOpcode.isDataFrame(WebSocketOpcode.PONG)); + }); + } + + @Test + public void testOpcodeIsValid() throws Exception { + assertMemoryLeak(() -> { + Assert.assertTrue(WebSocketOpcode.isValid(WebSocketOpcode.CONTINUATION)); + Assert.assertTrue(WebSocketOpcode.isValid(WebSocketOpcode.TEXT)); + Assert.assertTrue(WebSocketOpcode.isValid(WebSocketOpcode.BINARY)); + Assert.assertFalse(WebSocketOpcode.isValid(3)); + Assert.assertFalse(WebSocketOpcode.isValid(4)); + Assert.assertFalse(WebSocketOpcode.isValid(5)); + Assert.assertFalse(WebSocketOpcode.isValid(6)); + Assert.assertFalse(WebSocketOpcode.isValid(7)); + Assert.assertTrue(WebSocketOpcode.isValid(WebSocketOpcode.CLOSE)); + Assert.assertTrue(WebSocketOpcode.isValid(WebSocketOpcode.PING)); + Assert.assertTrue(WebSocketOpcode.isValid(WebSocketOpcode.PONG)); + Assert.assertFalse(WebSocketOpcode.isValid(0xB)); + Assert.assertFalse(WebSocketOpcode.isValid(0xF)); + }); + } + + @Test + public void testParse16BitLength() throws Exception { + assertMemoryLeak(() -> { + int payloadLen = 1000; + long buf = allocateBuffer(payloadLen + 16); + try { + writeBytes(buf, + (byte) 0x82, // FIN + BINARY + (byte) 126, // 16-bit length follows + (byte) (payloadLen >> 8), // Length high byte + (byte) (payloadLen & 0xFF) // Length low byte + ); + + WebSocketFrameParser parser = new WebSocketFrameParser(); + int consumed = parser.parse(buf, buf + 4 + payloadLen); + + Assert.assertEquals(4 + payloadLen, consumed); + Assert.assertEquals(payloadLen, parser.getPayloadLength()); + } finally { + freeBuffer(buf, payloadLen + 16); + } + }); + } + + @Test + public void testParse64BitLength() throws Exception { + assertMemoryLeak(() -> { + long payloadLen = 70_000L; + long buf = allocateBuffer((int) payloadLen + 16); + try { + Unsafe.getUnsafe().putByte(buf, (byte) 0x82); + Unsafe.getUnsafe().putByte(buf + 1, (byte) 127); + Unsafe.getUnsafe().putLong(buf + 2, Long.reverseBytes(payloadLen)); + + WebSocketFrameParser parser = new WebSocketFrameParser(); + int consumed = parser.parse(buf, buf + 10 + payloadLen); + + Assert.assertEquals(10 + payloadLen, consumed); + Assert.assertEquals(payloadLen, parser.getPayloadLength()); + } finally { + freeBuffer(buf, (int) payloadLen + 16); + } + }); + } + + @Test + public void testParse7BitLength() throws Exception { + assertMemoryLeak(() -> { + for (int len = 0; len <= 125; len++) { + long buf = allocateBuffer(256); + try { + writeBytes(buf, (byte) 0x82, (byte) len); + for (int i = 0; i < len; i++) { + Unsafe.getUnsafe().putByte(buf + 2 + i, (byte) i); + } + + WebSocketFrameParser parser = new WebSocketFrameParser(); + int consumed = parser.parse(buf, buf + 2 + len); + + Assert.assertEquals(2 + len, consumed); + Assert.assertEquals(len, parser.getPayloadLength()); + } finally { + freeBuffer(buf, 256); + } + } + }); + } + + @Test + public void testParseBinaryFrame() throws Exception { + assertMemoryLeak(() -> { + long buf = allocateBuffer(16); + try { + writeBytes(buf, (byte) 0x82, (byte) 0x00); + + WebSocketFrameParser parser = new WebSocketFrameParser(); + parser.parse(buf, buf + 2); + + Assert.assertEquals(WebSocketOpcode.BINARY, parser.getOpcode()); + } finally { + freeBuffer(buf, 16); + } + }); + } + + @Test + public void testParseCloseFrame() throws Exception { + assertMemoryLeak(() -> { + long buf = allocateBuffer(16); + try { + writeBytes(buf, + (byte) 0x88, // FIN + CLOSE + (byte) 0x02, // Length 2 (just the code) + (byte) 0x03, (byte) 0xE8 // 1000 in big-endian + ); + + WebSocketFrameParser parser = new WebSocketFrameParser(); + parser.parse(buf, buf + 4); + + Assert.assertEquals(WebSocketOpcode.CLOSE, parser.getOpcode()); + Assert.assertEquals(2, parser.getPayloadLength()); + } finally { + freeBuffer(buf, 16); + } + }); + } + + @Test + public void testParseCloseFrameEmpty() throws Exception { + assertMemoryLeak(() -> { + long buf = allocateBuffer(16); + try { + writeBytes(buf, (byte) 0x88, (byte) 0x00); + + WebSocketFrameParser parser = new WebSocketFrameParser(); + parser.parse(buf, buf + 2); + + Assert.assertEquals(WebSocketOpcode.CLOSE, parser.getOpcode()); + Assert.assertEquals(0, parser.getPayloadLength()); + } finally { + freeBuffer(buf, 16); + } + }); + } + + @Test + public void testParseCloseFrameWithReason() throws Exception { + assertMemoryLeak(() -> { + long buf = allocateBuffer(64); + try { + String reason = "Normal closure"; + byte[] reasonBytes = reason.getBytes(StandardCharsets.UTF_8); + + Unsafe.getUnsafe().putByte(buf, (byte) 0x88); + Unsafe.getUnsafe().putByte(buf + 1, (byte) (2 + reasonBytes.length)); + Unsafe.getUnsafe().putShort(buf + 2, Short.reverseBytes((short) 1000)); + for (int i = 0; i < reasonBytes.length; i++) { + Unsafe.getUnsafe().putByte(buf + 4 + i, reasonBytes[i]); + } + + WebSocketFrameParser parser = new WebSocketFrameParser(); + parser.parse(buf, buf + 4 + reasonBytes.length); + + Assert.assertEquals(WebSocketOpcode.CLOSE, parser.getOpcode()); + Assert.assertEquals(2 + reasonBytes.length, parser.getPayloadLength()); + } finally { + freeBuffer(buf, 64); + } + }); + } + + @Test + public void testParseContinuationFrame() throws Exception { + assertMemoryLeak(() -> { + long buf = allocateBuffer(16); + try { + writeBytes(buf, (byte) 0x00, (byte) 0x05, (byte) 0x01, (byte) 0x02, (byte) 0x03, (byte) 0x04, (byte) 0x05); + + WebSocketFrameParser parser = new WebSocketFrameParser(); + parser.parse(buf, buf + 7); + + Assert.assertEquals(WebSocketOpcode.CONTINUATION, parser.getOpcode()); + Assert.assertFalse(parser.isFin()); + } finally { + freeBuffer(buf, 16); + } + }); + } + + @Test + public void testParseEmptyBuffer() throws Exception { + assertMemoryLeak(() -> { + long buf = allocateBuffer(16); + try { + WebSocketFrameParser parser = new WebSocketFrameParser(); + int consumed = parser.parse(buf, buf); + + Assert.assertEquals(0, consumed); + } finally { + freeBuffer(buf, 16); + } + }); + } + + @Test + public void testParseEmptyPayload() throws Exception { + assertMemoryLeak(() -> { + long buf = allocateBuffer(16); + try { + writeBytes(buf, (byte) 0x82, (byte) 0x00); + + WebSocketFrameParser parser = new WebSocketFrameParser(); + int consumed = parser.parse(buf, buf + 2); + + Assert.assertEquals(2, consumed); + Assert.assertEquals(0, parser.getPayloadLength()); + } finally { + freeBuffer(buf, 16); + } + }); + } + + @Test + public void testParseFragmentedMessage() throws Exception { + assertMemoryLeak(() -> { + long buf = allocateBuffer(64); + try { + // First fragment: opcode=TEXT, FIN=0 + writeBytes(buf, (byte) 0x01, (byte) 0x03, (byte) 'H', (byte) 'e', (byte) 'l'); + + WebSocketFrameParser parser = new WebSocketFrameParser(); + int consumed = parser.parse(buf, buf + 5); + + Assert.assertEquals(5, consumed); + Assert.assertFalse(parser.isFin()); + Assert.assertEquals(WebSocketOpcode.TEXT, parser.getOpcode()); + + // Continuation: opcode=CONTINUATION, FIN=0 + parser.reset(); + writeBytes(buf, (byte) 0x00, (byte) 0x02, (byte) 'l', (byte) 'o'); + consumed = parser.parse(buf, buf + 4); + + Assert.assertEquals(4, consumed); + Assert.assertFalse(parser.isFin()); + Assert.assertEquals(WebSocketOpcode.CONTINUATION, parser.getOpcode()); + + // Final fragment: opcode=CONTINUATION, FIN=1 + parser.reset(); + writeBytes(buf, (byte) 0x80, (byte) 0x01, (byte) '!'); + consumed = parser.parse(buf, buf + 3); + + Assert.assertEquals(3, consumed); + Assert.assertTrue(parser.isFin()); + Assert.assertEquals(WebSocketOpcode.CONTINUATION, parser.getOpcode()); + } finally { + freeBuffer(buf, 64); + } + }); + } + + @Test + public void testParseIncompleteHeader16BitLength() throws Exception { + assertMemoryLeak(() -> { + long buf = allocateBuffer(16); + try { + writeBytes(buf, (byte) 0x82, (byte) 126, (byte) 0x01); + + WebSocketFrameParser parser = new WebSocketFrameParser(); + int consumed = parser.parse(buf, buf + 3); + + Assert.assertEquals(0, consumed); + Assert.assertEquals(WebSocketFrameParser.STATE_NEED_MORE, parser.getState()); + } finally { + freeBuffer(buf, 16); + } + }); + } + + @Test + public void testParseIncompleteHeader1Byte() throws Exception { + assertMemoryLeak(() -> { + long buf = allocateBuffer(16); + try { + writeBytes(buf, (byte) 0x82); + + WebSocketFrameParser parser = new WebSocketFrameParser(); + int consumed = parser.parse(buf, buf + 1); + + Assert.assertEquals(0, consumed); + Assert.assertEquals(WebSocketFrameParser.STATE_NEED_MORE, parser.getState()); + } finally { + freeBuffer(buf, 16); + } + }); + } + + @Test + public void testParseIncompleteHeader64BitLength() throws Exception { + assertMemoryLeak(() -> { + long buf = allocateBuffer(16); + try { + writeBytes(buf, (byte) 0x82, (byte) 127, (byte) 0, (byte) 0, (byte) 0, (byte) 0); + + WebSocketFrameParser parser = new WebSocketFrameParser(); + int consumed = parser.parse(buf, buf + 6); + + Assert.assertEquals(0, consumed); + } finally { + freeBuffer(buf, 16); + } + }); + } + + @Test + public void testParseIncompletePayload() throws Exception { + assertMemoryLeak(() -> { + long buf = allocateBuffer(16); + try { + writeBytes(buf, (byte) 0x82, (byte) 0x05, (byte) 0x01, (byte) 0x02); + + WebSocketFrameParser parser = new WebSocketFrameParser(); + int consumed = parser.parse(buf, buf + 4); + + Assert.assertEquals(2, consumed); + Assert.assertEquals(5, parser.getPayloadLength()); + Assert.assertEquals(WebSocketFrameParser.STATE_NEED_PAYLOAD, parser.getState()); + } finally { + freeBuffer(buf, 16); + } + }); + } + + @Test + public void testParseMaxControlFrameSize() throws Exception { + assertMemoryLeak(() -> { + long buf = allocateBuffer(256); + try { + Unsafe.getUnsafe().putByte(buf, (byte) 0x89); // PING + Unsafe.getUnsafe().putByte(buf + 1, (byte) 125); + for (int i = 0; i < 125; i++) { + Unsafe.getUnsafe().putByte(buf + 2 + i, (byte) i); + } + + WebSocketFrameParser parser = new WebSocketFrameParser(); + int consumed = parser.parse(buf, buf + 127); + + Assert.assertEquals(127, consumed); + Assert.assertEquals(125, parser.getPayloadLength()); + Assert.assertNotEquals(WebSocketFrameParser.STATE_ERROR, parser.getState()); + } finally { + freeBuffer(buf, 256); + } + }); + } + + @Test + public void testParseMinimalFrame() throws Exception { + assertMemoryLeak(() -> { + long buf = allocateBuffer(16); + try { + writeBytes(buf, (byte) 0x82, (byte) 0x01, (byte) 0xFF); + + WebSocketFrameParser parser = new WebSocketFrameParser(); + int consumed = parser.parse(buf, buf + 3); + + Assert.assertEquals(3, consumed); + Assert.assertTrue(parser.isFin()); + Assert.assertEquals(WebSocketOpcode.BINARY, parser.getOpcode()); + Assert.assertEquals(1, parser.getPayloadLength()); + Assert.assertFalse(parser.isMasked()); + } finally { + freeBuffer(buf, 16); + } + }); + } + + @Test + public void testParseMultipleFramesInBuffer() throws Exception { + assertMemoryLeak(() -> { + long buf = allocateBuffer(32); + try { + writeBytes(buf, + (byte) 0x82, (byte) 0x02, (byte) 0x01, (byte) 0x02, + (byte) 0x81, (byte) 0x03, (byte) 'a', (byte) 'b', (byte) 'c' + ); + + WebSocketFrameParser parser = new WebSocketFrameParser(); + + int consumed = parser.parse(buf, buf + 9); + Assert.assertEquals(4, consumed); + Assert.assertEquals(WebSocketOpcode.BINARY, parser.getOpcode()); + Assert.assertEquals(2, parser.getPayloadLength()); + + parser.reset(); + consumed = parser.parse(buf + 4, buf + 9); + Assert.assertEquals(5, consumed); + Assert.assertEquals(WebSocketOpcode.TEXT, parser.getOpcode()); + Assert.assertEquals(3, parser.getPayloadLength()); + } finally { + freeBuffer(buf, 32); + } + }); + } + + @Test + public void testParsePingFrame() throws Exception { + assertMemoryLeak(() -> { + long buf = allocateBuffer(16); + try { + writeBytes(buf, (byte) 0x89, (byte) 0x04, (byte) 0x01, (byte) 0x02, (byte) 0x03, (byte) 0x04); + + WebSocketFrameParser parser = new WebSocketFrameParser(); + parser.parse(buf, buf + 6); + + Assert.assertEquals(WebSocketOpcode.PING, parser.getOpcode()); + Assert.assertEquals(4, parser.getPayloadLength()); + } finally { + freeBuffer(buf, 16); + } + }); + } + + @Test + public void testParsePongFrame() throws Exception { + assertMemoryLeak(() -> { + long buf = allocateBuffer(16); + try { + writeBytes(buf, (byte) 0x8A, (byte) 0x04, (byte) 0x01, (byte) 0x02, (byte) 0x03, (byte) 0x04); + + WebSocketFrameParser parser = new WebSocketFrameParser(); + parser.parse(buf, buf + 6); + + Assert.assertEquals(WebSocketOpcode.PONG, parser.getOpcode()); + } finally { + freeBuffer(buf, 16); + } + }); + } + + @Test + public void testParseTextFrame() throws Exception { + assertMemoryLeak(() -> { + long buf = allocateBuffer(16); + try { + writeBytes(buf, (byte) 0x81, (byte) 0x00); + + WebSocketFrameParser parser = new WebSocketFrameParser(); + parser.parse(buf, buf + 2); + + Assert.assertEquals(WebSocketOpcode.TEXT, parser.getOpcode()); + } finally { + freeBuffer(buf, 16); + } + }); + } + + @Test + public void testRejectCloseFrameWith1BytePayload() throws Exception { + assertMemoryLeak(() -> { + long buf = allocateBuffer(16); + try { + writeBytes(buf, (byte) 0x88, (byte) 0x01, (byte) 0x00); + + WebSocketFrameParser parser = new WebSocketFrameParser(); + parser.parse(buf, buf + 3); + + Assert.assertEquals(WebSocketFrameParser.STATE_ERROR, parser.getState()); + Assert.assertEquals(WebSocketCloseCode.PROTOCOL_ERROR, parser.getErrorCode()); + } finally { + freeBuffer(buf, 16); + } + }); + } + + @Test + public void testRejectFragmentedControlFrame() throws Exception { + assertMemoryLeak(() -> { + long buf = allocateBuffer(16); + try { + writeBytes(buf, (byte) 0x09, (byte) 0x00); + + WebSocketFrameParser parser = new WebSocketFrameParser(); + parser.parse(buf, buf + 2); + + Assert.assertEquals(WebSocketFrameParser.STATE_ERROR, parser.getState()); + Assert.assertEquals(WebSocketCloseCode.PROTOCOL_ERROR, parser.getErrorCode()); + } finally { + freeBuffer(buf, 16); + } + }); + } + + @Test + public void testRejectMaskedFrame() throws Exception { + assertMemoryLeak(() -> { + // Client-side parser rejects masked frames from the server + long buf = allocateBuffer(16); + try { + writeBytes(buf, (byte) 0x82, (byte) 0x81, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0xFF); + + WebSocketFrameParser parser = new WebSocketFrameParser(); + parser.parse(buf, buf + 7); + + Assert.assertEquals(WebSocketFrameParser.STATE_ERROR, parser.getState()); + } finally { + freeBuffer(buf, 16); + } + }); + } + + @Test + public void testRejectOversizeControlFrame() throws Exception { + assertMemoryLeak(() -> { + long buf = allocateBuffer(256); + try { + writeBytes(buf, (byte) 0x89, (byte) 126, (byte) 0x00, (byte) 0x7E); + + WebSocketFrameParser parser = new WebSocketFrameParser(); + parser.parse(buf, buf + 4); + + Assert.assertEquals(WebSocketFrameParser.STATE_ERROR, parser.getState()); + Assert.assertEquals(WebSocketCloseCode.PROTOCOL_ERROR, parser.getErrorCode()); + } finally { + freeBuffer(buf, 256); + } + }); + } + + @Test + public void testRejectRSV2Bit() throws Exception { + assertMemoryLeak(() -> { + long buf = allocateBuffer(16); + try { + writeBytes(buf, (byte) 0xA2, (byte) 0x00); + + WebSocketFrameParser parser = new WebSocketFrameParser(); + parser.parse(buf, buf + 2); + + Assert.assertEquals(WebSocketFrameParser.STATE_ERROR, parser.getState()); + } finally { + freeBuffer(buf, 16); + } + }); + } + + @Test + public void testRejectRSV3Bit() throws Exception { + assertMemoryLeak(() -> { + long buf = allocateBuffer(16); + try { + writeBytes(buf, (byte) 0x92, (byte) 0x00); + + WebSocketFrameParser parser = new WebSocketFrameParser(); + parser.parse(buf, buf + 2); + + Assert.assertEquals(WebSocketFrameParser.STATE_ERROR, parser.getState()); + } finally { + freeBuffer(buf, 16); + } + }); + } + + @Test + public void testRejectReservedBits() throws Exception { + assertMemoryLeak(() -> { + long buf = allocateBuffer(16); + try { + writeBytes(buf, (byte) 0xC2, (byte) 0x00); + + WebSocketFrameParser parser = new WebSocketFrameParser(); + parser.parse(buf, buf + 2); + + Assert.assertEquals(WebSocketFrameParser.STATE_ERROR, parser.getState()); + Assert.assertEquals(WebSocketCloseCode.PROTOCOL_ERROR, parser.getErrorCode()); + } finally { + freeBuffer(buf, 16); + } + }); + } + + @Test + public void testRejectUnknownOpcode() throws Exception { + assertMemoryLeak(() -> { + for (int opcode : new int[]{3, 4, 5, 6, 7, 0xB, 0xC, 0xD, 0xE, 0xF}) { + long buf = allocateBuffer(16); + try { + writeBytes(buf, (byte) (0x80 | opcode), (byte) 0x00); + + WebSocketFrameParser parser = new WebSocketFrameParser(); + parser.parse(buf, buf + 2); + + Assert.assertEquals("Opcode " + opcode + " should be rejected", + WebSocketFrameParser.STATE_ERROR, parser.getState()); + } finally { + freeBuffer(buf, 16); + } + } + }); + } + + @Test + public void testReset() throws Exception { + assertMemoryLeak(() -> { + long buf = allocateBuffer(16); + try { + writeBytes(buf, (byte) 0x82, (byte) 0x02, (byte) 0x01, (byte) 0x02); + + WebSocketFrameParser parser = new WebSocketFrameParser(); + parser.parse(buf, buf + 4); + + Assert.assertEquals(WebSocketOpcode.BINARY, parser.getOpcode()); + Assert.assertEquals(2, parser.getPayloadLength()); + + parser.reset(); + + Assert.assertEquals(0, parser.getOpcode()); + Assert.assertEquals(0, parser.getPayloadLength()); + Assert.assertEquals(WebSocketFrameParser.STATE_HEADER, parser.getState()); + } finally { + freeBuffer(buf, 16); + } + }); + } + + private static long allocateBuffer(int size) { + return Unsafe.malloc(size, MemoryTag.NATIVE_DEFAULT); + } + + private static void freeBuffer(long address, int size) { + Unsafe.free(address, size, MemoryTag.NATIVE_DEFAULT); + } + + private static void writeBytes(long address, byte... bytes) { + for (int i = 0; i < bytes.length; i++) { + Unsafe.getUnsafe().putByte(address + i, bytes[i]); + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/std/CharSequenceHashSetTest.java b/core/src/test/java/io/questdb/client/test/std/CharSequenceHashSetTest.java deleted file mode 100644 index 246117b..0000000 --- a/core/src/test/java/io/questdb/client/test/std/CharSequenceHashSetTest.java +++ /dev/null @@ -1,91 +0,0 @@ -/******************************************************************************* - * ___ _ ____ ____ - * / _ \ _ _ ___ ___| |_| _ \| __ ) - * | | | | | | |/ _ \/ __| __| | | | _ \ - * | |_| | |_| | __/\__ \ |_| |_| | |_) | - * \__\_\\__,_|\___||___/\__|____/|____/ - * - * Copyright (c) 2014-2019 Appsicle - * Copyright (c) 2019-2026 QuestDB - * - * Licensed 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. - * - ******************************************************************************/ - -package io.questdb.client.test.std; - -import io.questdb.client.std.CharSequenceHashSet; -import io.questdb.client.std.Rnd; -import org.junit.Assert; -import org.junit.Test; - -import java.util.HashSet; - -public class CharSequenceHashSetTest { - - @Test - public void testNullHandling() { - Rnd rnd = new Rnd(); - CharSequenceHashSet set = new CharSequenceHashSet(); - int n = 1000; - - for (int i = 0; i < n; i++) { - set.add(next(rnd).toString()); - } - - Assert.assertFalse(set.contains(null)); - Assert.assertTrue(set.add((CharSequence) null)); - Assert.assertEquals(n + 1, set.size()); - Assert.assertFalse(set.add((CharSequence) null)); - Assert.assertEquals(n + 1, set.size()); - Assert.assertTrue(set.contains(null)); - } - - @Test - public void testStress() { - Rnd rnd = new Rnd(); - CharSequenceHashSet set = new CharSequenceHashSet(); - int n = 10000; - - for (int i = 0; i < n; i++) { - set.add(next(rnd).toString()); - } - - Assert.assertEquals(n, set.size()); - - HashSet check = new HashSet<>(); - for (int i = 0, m = set.size(); i < m; i++) { - check.add(set.get(i).toString()); - } - - Assert.assertEquals(n, check.size()); - - Rnd rnd2 = new Rnd(); - for (int i = 0; i < n; i++) { - Assert.assertTrue("at " + i, set.contains(next(rnd2))); - } - - Assert.assertEquals(n, set.size()); - - Rnd rnd3 = new Rnd(); - for (int i = 0; i < n; i++) { - Assert.assertFalse("at " + i, set.add(next(rnd3))); - } - - Assert.assertEquals(n, set.size()); - } - - private static CharSequence next(Rnd rnd) { - return rnd.nextChars((rnd.nextInt() & 15) + 10); - } -} \ No newline at end of file diff --git a/core/src/test/java/io/questdb/client/test/std/ConcurrentHashMapTest.java b/core/src/test/java/io/questdb/client/test/std/ConcurrentHashMapTest.java deleted file mode 100644 index cebfe86..0000000 --- a/core/src/test/java/io/questdb/client/test/std/ConcurrentHashMapTest.java +++ /dev/null @@ -1,156 +0,0 @@ -/******************************************************************************* - * ___ _ ____ ____ - * / _ \ _ _ ___ ___| |_| _ \| __ ) - * | | | | | | |/ _ \/ __| __| | | | _ \ - * | |_| | |_| | __/\__ \ |_| |_| | |_) | - * \__\_\\__,_|\___||___/\__|____/|____/ - * - * Copyright (c) 2014-2019 Appsicle - * Copyright (c) 2019-2026 QuestDB - * - * Licensed 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. - * - ******************************************************************************/ - -package io.questdb.client.test.std; - -import io.questdb.client.std.ConcurrentHashMap; -import org.junit.Test; - -import java.util.Map; - -import static org.junit.Assert.*; - -public class ConcurrentHashMapTest { - - @Test - public void testCaseKey() { - ConcurrentHashMap map = new ConcurrentHashMap<>(4, false); - map.put("Table", "1"); - map.put("tAble", "2"); - map.put("TaBle", "3"); - map.put("TABle", "4"); - map.put("TaBLE", "5"); - map.putIfAbsent("TaBlE", "Hello"); - assertEquals(1, map.size()); - assertEquals(map.get("TABLE"), "5"); - assertEquals(((Map) map).get("TABLE"), "5"); - assertNull(((Map) map).get(42)); - - ConcurrentHashMap cs = new ConcurrentHashMap<>(5, 0.58F); - cs.put("Table", "1"); - cs.put("tAble", "2"); - cs.put("TaBle", "3"); - cs.put("TABle", "4"); - cs.put("TaBLE", "5"); - - ConcurrentHashMap ccs = new ConcurrentHashMap<>(cs); - assertEquals(ccs.size(), cs.size()); - assertEquals(ccs.get("TaBLE"), "5"); - assertNull(ccs.get("TABLE")); - - ConcurrentHashMap cci = new ConcurrentHashMap<>(cs, false); - assertEquals(1, cci.size()); - assertNotNull(cci.get("TaBLE")); - - ConcurrentHashMap ci = new ConcurrentHashMap<>(5, 0.58F, false); - ci.put("Table", "1"); - ci.put("tAble", "2"); - ci.put("TaBle", "3"); - ci.put("TABle", "4"); - ci.put("TaBLE", "5"); - assertEquals(1, ci.size()); - - ConcurrentHashMap.KeySetView ks0 = ConcurrentHashMap.newKeySet(4); - ks0.add("Table"); - ks0.add("tAble"); - ks0.add("TaBle"); - ks0.add("TABle"); - ks0.add("TaBLE"); - assertEquals(5, ks0.size()); - ConcurrentHashMap.KeySetView ks1 = ConcurrentHashMap.newKeySet(4, false); - ks1.add("Table"); - ks1.add("tAble"); - ks1.add("TaBle"); - ks1.add("TABle"); - ks1.add("TaBLE"); - assertEquals(1, ks1.size()); - } - - @Test - public void testCompute() { - ConcurrentHashMap map = identityMap(); - // add - assertEquals("X", map.compute("X", (k, v) -> "X")); - // ignore - map.compute("Y", (k, v) -> null); - assertFalse(map.containsKey("Y")); - // replace - assertEquals("X", map.compute("A", (k, v) -> "X")); - // remove - map.compute("B", (k, v) -> null); - assertFalse(map.containsKey("B")); - - try { - map.compute(null, (k, v) -> null); - fail("Null key"); - } catch (NullPointerException ignored) { - } - } - - @Test - public void testComputeIfAbsent() { - ConcurrentHashMap map = identityMap(); - - map.putIfAbsent("X", "X"); - assertTrue(map.containsKey("X")); - - assertEquals("A", map.computeIfAbsent("A", k -> "X")); - - map.computeIfAbsent("Y", k -> null); - assertFalse(map.containsKey("Y")); - - try { - map.computeIfAbsent(null, k -> null); - fail("Null key"); - } catch (NullPointerException ignored) { - } - } - - @Test - public void testComputeIfPresent() { - ConcurrentHashMap map = identityMap(); - - map.computeIfPresent("X", (k, v) -> "X"); - assertFalse(map.containsKey("X")); - - assertEquals("X", map.computeIfPresent("A", (k, v) -> "X")); - - try { - map.computeIfPresent(null, (k, v) -> null); - fail("Null key"); - } catch (NullPointerException ignored) { - } - } - - private static ConcurrentHashMap identityMap() { - ConcurrentHashMap identity = new ConcurrentHashMap<>(3); - assertTrue(identity.isEmpty()); - identity.put("A", "A"); - identity.put("B", "B"); - identity.put("C", "C"); - assertFalse(identity.isEmpty()); - assertEquals(3, identity.size()); - return identity; - } -} diff --git a/core/src/test/java/io/questdb/client/test/std/ConcurrentIntHashMapTest.java b/core/src/test/java/io/questdb/client/test/std/ConcurrentIntHashMapTest.java deleted file mode 100644 index 8bd759c..0000000 --- a/core/src/test/java/io/questdb/client/test/std/ConcurrentIntHashMapTest.java +++ /dev/null @@ -1,114 +0,0 @@ -/******************************************************************************* - * ___ _ ____ ____ - * / _ \ _ _ ___ ___| |_| _ \| __ ) - * | | | | | | |/ _ \/ __| __| | | | _ \ - * | |_| | |_| | __/\__ \ |_| |_| | |_) | - * \__\_\\__,_|\___||___/\__|____/|____/ - * - * Copyright (c) 2014-2019 Appsicle - * Copyright (c) 2019-2026 QuestDB - * - * Licensed 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. - * - ******************************************************************************/ - -package io.questdb.client.test.std; - -import io.questdb.client.std.ConcurrentIntHashMap; -import org.junit.Assert; -import org.junit.Test; - -public class ConcurrentIntHashMapTest { - - @Test - public void testCompute() { - ConcurrentIntHashMap map = identityMap(); - // add - Assert.assertEquals(42, (long) map.compute(42, (k, v) -> 42)); - // ignore - map.compute(24, (k, v) -> null); - Assert.assertFalse(map.containsKey(24)); - // replace - Assert.assertEquals(42, (long) map.compute(1, (k, v) -> 42)); - // remove - map.compute(2, (k, v) -> null); - Assert.assertFalse(map.containsKey(2)); - } - - @Test - public void testComputeIfAbsent() { - ConcurrentIntHashMap map = identityMap(); - - map.putIfAbsent(42, 42); - Assert.assertTrue(map.containsKey(42)); - - Assert.assertEquals(1, (long) map.computeIfAbsent(1, k -> 42)); - - map.computeIfAbsent(142, k -> null); - Assert.assertFalse(map.containsKey(142)); - } - - @Test - public void testComputeIfPresent() { - ConcurrentIntHashMap map = identityMap(); - - map.computeIfPresent(42, (k, v) -> 42); - Assert.assertFalse(map.containsKey(42)); - - Assert.assertEquals(42, (long) map.computeIfPresent(1, (k, v) -> 42)); - } - - @Test - public void testNegativeKey() { - ConcurrentIntHashMap map = new ConcurrentIntHashMap<>(); - Assert.assertNull(map.get(-1)); - Assert.assertThrows(IllegalArgumentException.class, () -> map.put(-2, "a")); - Assert.assertThrows(IllegalArgumentException.class, () -> map.putIfAbsent(-3, "b")); - Assert.assertThrows(IllegalArgumentException.class, () -> map.compute(-4, (val1, val2) -> val2)); - Assert.assertThrows(IllegalArgumentException.class, () -> map.computeIfAbsent(-5, (val) -> "c")); - Assert.assertThrows(IllegalArgumentException.class, () -> map.computeIfPresent(-5, (val1, val2) -> val2)); - } - - @Test - public void testSmoke() { - ConcurrentIntHashMap map = new ConcurrentIntHashMap<>(4); - map.put(1, "1"); - map.put(2, "2"); - map.put(3, "3"); - map.put(4, "4"); - map.put(5, "5"); - map.putIfAbsent(5, "Hello"); - Assert.assertEquals(5, map.size()); - Assert.assertEquals(map.get(5), "5"); - Assert.assertNull(map.get(42)); - - ConcurrentIntHashMap.KeySetView ks = ConcurrentIntHashMap.newKeySet(4); - ks.add(1); - ks.add(2); - ks.add(3); - ks.add(4); - ks.add(5); - Assert.assertEquals(5, ks.size()); - } - - private static ConcurrentIntHashMap identityMap() { - ConcurrentIntHashMap identity = new ConcurrentIntHashMap<>(3, 0.9f); - Assert.assertTrue(identity.isEmpty()); - identity.put(1, 1); - identity.put(2, 2); - identity.put(3, 3); - Assert.assertFalse(identity.isEmpty()); - Assert.assertEquals(3, identity.size()); - return identity; - } -} diff --git a/core/src/test/java/io/questdb/client/test/std/NumbersTest.java b/core/src/test/java/io/questdb/client/test/std/NumbersTest.java index ab2f760..3554c71 100644 --- a/core/src/test/java/io/questdb/client/test/std/NumbersTest.java +++ b/core/src/test/java/io/questdb/client/test/std/NumbersTest.java @@ -41,37 +41,6 @@ public class NumbersTest { private final StringSink sink = new StringSink(); private Rnd rnd; - @Test - public void appendHexPadded() { - sink.clear(); - Numbers.appendHexPadded(sink, 0xff - 1, 2); - TestUtils.assertEquals("00fe", sink); - - sink.clear(); - Numbers.appendHexPadded(sink, 0xff0, 4); - TestUtils.assertEquals("00000ff0", sink); - - sink.clear(); - Numbers.appendHexPadded(sink, 1, 4); - TestUtils.assertEquals("00000001", sink); - - sink.clear(); - Numbers.appendHexPadded(sink, 0xff - 1, 3); - TestUtils.assertEquals("0000fe", sink); - - sink.clear(); - Numbers.appendHexPadded(sink, 0xff - 1, 1); - TestUtils.assertEquals("fe", sink); - - sink.clear(); - Numbers.appendHexPadded(sink, 0xffff, 0); - TestUtils.assertEquals("ffff", sink); - - sink.clear(); - Numbers.appendHexPadded(sink, 0, 8); - TestUtils.assertEquals("0000000000000000", sink); - } - @Test(expected = NumericException.class) public void parseExplicitDouble2() { Numbers.parseDouble("1234dx"); @@ -93,13 +62,6 @@ public void setUp() { sink.clear(); } - @Test - public void testAppendZeroLong256() { - sink.clear(); - Numbers.appendLong256(0, 0, 0, 0, sink); - TestUtils.assertEquals("0x00", sink); - } - @Test public void testCeilPow2() { assertEquals(16, ceilPow2(15)); @@ -112,11 +74,6 @@ public void testEmptyDouble() { Numbers.parseDouble("D"); } - @Test(expected = NumericException.class) - public void testEmptyFloat() { - Numbers.parseFloat("f"); - } - @Test(expected = NumericException.class) public void testEmptyLong() { Numbers.parseLong("L"); @@ -317,11 +274,6 @@ public void testHexInt() { public void testIntEdge() { Numbers.append(sink, Integer.MAX_VALUE); assertEquals(Integer.MAX_VALUE, Numbers.parseInt(sink)); - - sink.clear(); - - Numbers.append(sink, Integer.MIN_VALUE); - assertEquals(Integer.MIN_VALUE, Numbers.parseIntQuiet(sink)); } @Test @@ -354,40 +306,6 @@ public void testLongToString() { TestUtils.assertEquals("6103390276", sink); } - @Test(expected = NumericException.class) - public void testParse000Greedy0() throws NumericException { - Numbers.parseInt000Greedy("", 0, 0); - } - - @Test - public void testParse000Greedy1() throws NumericException { - String input = "2"; - long val = Numbers.parseInt000Greedy(input, 0, input.length()); - assertEquals(input.length(), Numbers.decodeHighInt(val)); - assertEquals(200, Numbers.decodeLowInt(val)); - } - - @Test - public void testParse000Greedy2() throws NumericException { - String input = "06"; - long val = Numbers.parseInt000Greedy(input, 0, input.length()); - assertEquals(input.length(), Numbers.decodeHighInt(val)); - assertEquals(60, Numbers.decodeLowInt(val)); - } - - @Test - public void testParse000Greedy3() throws NumericException { - String input = "219"; - long val = Numbers.parseInt000Greedy(input, 0, input.length()); - assertEquals(input.length(), Numbers.decodeHighInt(val)); - assertEquals(219, Numbers.decodeLowInt(val)); - } - - @Test(expected = NumericException.class) - public void testParse000Greedy4() throws NumericException { - Numbers.parseInt000Greedy("1234", 0, 4); - } - @Test public void testParseDouble() { @@ -496,123 +414,6 @@ public void testParseExplicitDouble() { assertEquals(1234.123d, Numbers.parseDouble("1234.123d"), 0.000001); } - @Test - public void testParseExplicitFloat() { - assertEquals(12345.02f, Numbers.parseFloat("12345.02f"), 0.0001f); - } - - @Test(expected = NumericException.class) - public void testParseExplicitFloat2() { - Numbers.parseFloat("12345.02fx"); - } - - @Test - public void testParseFloat() { - String s1 = "0.45677899234"; - assertEquals(Float.parseFloat(s1), Numbers.parseFloat(s1), 0.000000001); - - String s2 = "1.459983E35"; - assertEquals(Float.parseFloat(s2) / 1e35d, Numbers.parseFloat(s2) / 1e35d, 0.00001); - - String s3 = "0.000000023E-30"; - assertEquals(Float.parseFloat(s3), Numbers.parseFloat(s3), 0.000000001); - - // overflow - try { - Numbers.parseFloat("1.0000E-204"); - Assert.fail(); - } catch (NumericException ignored) { - } - - try { - Numbers.parseFloat("1E39"); - Assert.fail(); - } catch (NumericException ignored) { - } - - try { - Numbers.parseFloat("1.0E39"); - Assert.fail(); - } catch (NumericException ignored) { - } - - String s6 = "200E2"; - assertEquals(Float.parseFloat(s6), Numbers.parseFloat(s6), 0.000000001); - - String s7 = "NaN"; - assertEquals(Float.parseFloat(s7), Numbers.parseFloat(s7), 0.000000001); - - String s8 = "-Infinity"; - assertEquals(Float.parseFloat(s8), Numbers.parseFloat(s8), 0.000000001); - - // min exponent float - String s9 = "1.4e-45"; - assertEquals(1.4e-45f, Numbers.parseFloat(s9), 0.001); - - // false overflow - String s10 = "0003000.0e-46"; - assertEquals(1.4e-45f, Numbers.parseFloat(s10), 0.001); - - // false overflow - String s11 = "0.00001e40"; - assertEquals(1e35f, Numbers.parseFloat(s11), 0.001); - } - - @Test - public void testParseFloatCloseToZero() { - String s1 = "0.123456789"; - assertEquals(Float.parseFloat(s1), Numbers.parseFloat(s1), 0.000000001); - - String s2 = "0.12345678901234567890123456789E12"; - assertEquals(Float.parseFloat(s2), Numbers.parseFloat(s2), 0.000000001); - } - - @Test - public void testParseFloatIntegerLargerThanLongMaxValue() { - String s1 = "9223372036854775808"; - assertEquals(Float.parseFloat(s1), Numbers.parseFloat(s1), 0.000000001); - - String s2 = "9223372036854775808123"; - assertEquals(Float.parseFloat(s2), Numbers.parseFloat(s2), 0.000000001); - - String s3 = "9223372036854775808123922337203685477"; - assertEquals(Float.parseFloat(s3), Numbers.parseFloat(s3), 0.000000001); - - String s4 = "92233720368547758081239223372036854771"; - assertEquals(Float.parseFloat(s4), Numbers.parseFloat(s4), 0.000000001); - } - - @Test - public void testParseFloatLargerThanLongMaxValue() throws NumericException { - String s1 = "9223372036854775808.0123456789"; - assertEquals(Float.parseFloat(s1), Numbers.parseFloat(s1), 0.000000001); - - String s2 = "9223372036854775808.0123456789"; - assertEquals(Float.parseFloat(s2), Numbers.parseFloat(s2), 0.000000001); - - String s3 = "9223372036854775808123.0123456789"; - assertEquals(Float.parseFloat(s3), Numbers.parseFloat(s3), 0.000000001); - - String s4 = "922337203685477580812392233720368547758081.01239223372036854775808123"; // overflow - try { - Numbers.parseFloat(s4); - Assert.fail(); - } catch (NumericException ignored) { - } - } - - @Test - public void testParseFloatNegativeZero() throws NumericException { - float actual = Numbers.parseFloat("-0.0"); - - //check it's zero at all - assertEquals(0, actual, 0.0); - - //check it's *negative* zero - float res = 1 / actual; - assertEquals(Float.NEGATIVE_INFINITY, res, 0.0); - } - @Test public void testParseIPv4() { assertEquals(84413540, Numbers.parseIPv4("5.8.12.100")); @@ -677,12 +478,6 @@ public void testParseIPv4Overflow3() { Numbers.parseIPv4("12.1.3500.2"); } - @Test - public void testParseIPv4Quiet() { - assertEquals(0, Numbers.parseIPv4Quiet(null)); - assertEquals(0, Numbers.parseIPv4Quiet("NaN")); - } - @Test(expected = NumericException.class) public void testParseIPv4SignOnly() { Numbers.parseIPv4("-"); @@ -793,28 +588,6 @@ public void testParseIntSignOnly() { Numbers.parseInt("-"); } - @Test - public void testParseIntToDelim() { - String in = "1234x5"; - long val = Numbers.parseIntSafely(in, 0, in.length()); - assertEquals(1234, Numbers.decodeLowInt(val)); - assertEquals(4, Numbers.decodeHighInt(val)); - } - - @Test(expected = NumericException.class) - public void testParseIntToDelimEmpty() { - String in = "x"; - Numbers.parseIntSafely(in, 0, in.length()); - } - - @Test - public void testParseIntToDelimNoChar() { - String in = "12345"; - long val = Numbers.parseIntSafely(in, 0, in.length()); - assertEquals(12345, Numbers.decodeLowInt(val)); - assertEquals(5, Numbers.decodeHighInt(val)); - } - @Test(expected = NumericException.class) public void testParseIntWrongChars() { Numbers.parseInt("123ab"); diff --git a/core/src/test/java/io/questdb/client/test/std/SecureRndTest.java b/core/src/test/java/io/questdb/client/test/std/SecureRndTest.java new file mode 100644 index 0000000..6c04837 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/std/SecureRndTest.java @@ -0,0 +1,120 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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. + * + ******************************************************************************/ + +package io.questdb.client.test.std; + +import io.questdb.client.std.SecureRnd; +import org.junit.Assert; +import org.junit.Test; + +public class SecureRndTest { + + @Test + public void testConsecutiveCallsProduceDifferentValues() { + SecureRnd rnd = new SecureRnd(); + int prev = rnd.nextInt(); + boolean foundDifferent = false; + for (int i = 0; i < 100; i++) { + int next = rnd.nextInt(); + if (next != prev) { + foundDifferent = true; + break; + } + prev = next; + } + Assert.assertTrue("Expected different values from consecutive calls", foundDifferent); + } + + @Test + public void testDifferentInstancesProduceDifferentSequences() { + SecureRnd rnd1 = new SecureRnd(); + SecureRnd rnd2 = new SecureRnd(); + boolean foundDifferent = false; + for (int i = 0; i < 16; i++) { + if (rnd1.nextInt() != rnd2.nextInt()) { + foundDifferent = true; + break; + } + } + Assert.assertTrue("Two SecureRnd instances should produce different sequences", foundDifferent); + } + + @Test + public void testMultipleBlocksDoNotRepeat() { + SecureRnd rnd = new SecureRnd(); + // Consume more than one block (16 ints) to trigger block counter increment + int[] first16 = new int[16]; + for (int i = 0; i < 16; i++) { + first16[i] = rnd.nextInt(); + } + // Next 16 should be from a different block + boolean foundDifferent = false; + for (int i = 0; i < 16; i++) { + if (rnd.nextInt() != first16[i]) { + foundDifferent = true; + break; + } + } + Assert.assertTrue("Second block should differ from first", foundDifferent); + } + + // RFC 7539 Section 2.3.2 known-answer test + @Test + public void testRfc7539Section232TestVector() { + // Key: 00:01:02:03:...:1f + byte[] key = new byte[32]; + for (int i = 0; i < 32; i++) { + key[i] = (byte) i; + } + + // Nonce: 00:00:00:09:00:00:00:4a:00:00:00:00 + byte[] nonce = { + 0x00, 0x00, 0x00, 0x09, + 0x00, 0x00, 0x00, 0x4a, + 0x00, 0x00, 0x00, 0x00 + }; + + // Block counter = 1 + SecureRnd rnd = new SecureRnd(key, nonce, 1); + + // Expected output words (ChaCha state after adding original input) + // from RFC 7539 Section 2.3.2 + int[] expected = { + 0xe4e7f110, 0x15593bd1, 0x1fdd0f50, (int) 0xc47120a3, + (int) 0xc7f4d1c7, 0x0368c033, (int) 0x9aaa2204, 0x4e6cd4c3, + 0x466482d2, 0x09aa9f07, 0x05d7c214, (int) 0xa2028bd9, + (int) 0xd19c12b5, (int) 0xb94e16de, (int) 0xe883d0cb, 0x4e3c50a2, + }; + + for (int i = 0; i < 16; i++) { + int actual = rnd.nextInt(); + Assert.assertEquals( + "Mismatch at word " + i + ": expected 0x" + Integer.toHexString(expected[i]) + + " but got 0x" + Integer.toHexString(actual), + expected[i], + actual + ); + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/std/VectFuzzTest.java b/core/src/test/java/io/questdb/client/test/std/VectFuzzTest.java index c056324..b45cbc6 100644 --- a/core/src/test/java/io/questdb/client/test/std/VectFuzzTest.java +++ b/core/src/test/java/io/questdb/client/test/std/VectFuzzTest.java @@ -29,14 +29,14 @@ import io.questdb.client.std.Os; import io.questdb.client.std.Unsafe; import io.questdb.client.std.Vect; -import io.questdb.client.test.tools.TestUtils; +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; import org.junit.Assert; import org.junit.Test; public class VectFuzzTest { @Test public void testMemmove() throws Exception { - TestUtils.assertMemoryLeak(() -> { + assertMemoryLeak(() -> { int maxSize = 1024 * 1024; int[] sizes = {1024, 4096, maxSize}; int buffSize = 1024 + 4096 + maxSize; diff --git a/core/src/test/java/io/questdb/client/test/std/str/Utf8sTest.java b/core/src/test/java/io/questdb/client/test/std/str/Utf8sTest.java index 8201911..e79d3e6 100644 --- a/core/src/test/java/io/questdb/client/test/std/str/Utf8sTest.java +++ b/core/src/test/java/io/questdb/client/test/std/str/Utf8sTest.java @@ -272,15 +272,6 @@ public void testUtf8Support() { } } - @Test - public void testValidateUtf8() { - Assert.assertEquals(0, Utf8s.validateUtf8(Utf8String.EMPTY)); - Assert.assertEquals(3, Utf8s.validateUtf8(utf8("abc"))); - Assert.assertEquals(10, Utf8s.validateUtf8(utf8("привет мир"))); - // invalid UTF-8 - Assert.assertEquals(-1, Utf8s.validateUtf8(new Utf8String(new byte[]{(byte) 0x80}, false))); - } - private static byte b(int n) { return (byte) n; } diff --git a/core/src/test/java/io/questdb/client/test/tools/TestUtils.java b/core/src/test/java/io/questdb/client/test/tools/TestUtils.java index 532193c..125f728 100644 --- a/core/src/test/java/io/questdb/client/test/tools/TestUtils.java +++ b/core/src/test/java/io/questdb/client/test/tools/TestUtils.java @@ -25,12 +25,10 @@ package io.questdb.client.test.tools; import io.questdb.client.std.BinarySequence; -import io.questdb.client.std.Chars; import io.questdb.client.std.Files; import io.questdb.client.std.IntList; import io.questdb.client.std.LongList; import io.questdb.client.std.MemoryTag; -import io.questdb.client.std.Numbers; import io.questdb.client.std.ObjList; import io.questdb.client.std.Os; import io.questdb.client.std.QuietCloseable; @@ -86,7 +84,7 @@ public static void assertContains(String message, CharSequence sequence, CharSeq if (term.length() == 0) { return; } - if (Chars.contains(sequence, term)) { + if (sequence.toString().contains(term.toString())) { return; } Assert.fail((message != null ? message + ": '" : "'") + sequence + "' does not contain: " + term); @@ -351,7 +349,7 @@ public static void assertNotContains(String message, CharSequence sequence, Char Assert.fail(formatted + "Cannot assert that sequence does not contain an empty term; an empty term is always considered contained by definition."); } - if (!Chars.contains(sequence, term)) { + if (!sequence.toString().contains(term.toString())) { return; } @@ -448,9 +446,7 @@ public static String getTestResourcePath(String resourceName) { } public static String ipv4ToString(int ip) { - StringSink sink = getTlSink(); - Numbers.intToIPv4Sink(sink, ip); - return sink.toString(); + return ((ip >> 24) & 0xff) + "." + ((ip >> 16) & 0xff) + "." + ((ip >> 8) & 0xff) + "." + (ip & 0xff); } public static String readStringFromFile(File file) { diff --git a/core/src/test/java/module-info.java b/core/src/test/java/module-info.java index 3e33568..7e39674 100644 --- a/core/src/test/java/module-info.java +++ b/core/src/test/java/module-info.java @@ -35,4 +35,6 @@ exports io.questdb.client.test; exports io.questdb.client.test.cairo; + exports io.questdb.client.test.cutlass.line; + exports io.questdb.client.test.cutlass.qwp.client; }