diff --git a/pom.xml b/pom.xml index 5c23001..bfffdbd 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.tidesdb tidesdb-java - 0.8.1 + 0.8.2 jar TidesDB Java diff --git a/src/main/c/CMakeLists.txt b/src/main/c/CMakeLists.txt index 258dc35..a8d2790 100644 --- a/src/main/c/CMakeLists.txt +++ b/src/main/c/CMakeLists.txt @@ -47,6 +47,7 @@ else() target_link_libraries(tidesdb_jni ${TIDESDB_LIBRARY} ${JNI_LIBRARIES} + ${CMAKE_DL_LIBS} ) endif() diff --git a/src/main/c/com_tidesdb_TidesDB.c b/src/main/c/com_tidesdb_TidesDB.c index c8b4985..e0f5f72 100644 --- a/src/main/c/com_tidesdb_TidesDB.c +++ b/src/main/c/com_tidesdb_TidesDB.c @@ -20,6 +20,9 @@ #include #include #include +#ifndef _WIN32 +#include +#endif static void throwTidesDBException(JNIEnv *env, int errorCode, const char *message) { @@ -89,7 +92,7 @@ JNIEXPORT jlong JNICALL Java_com_tidesdb_TidesDB_nativeOpen( jlong oscMultipartPartSize, jboolean oscSyncManifestToObject, jboolean oscReplicateWal, jboolean oscWalUploadSync, jlong oscWalSyncThresholdBytes, jboolean oscWalSyncOnCommit, jboolean oscReplicaMode, jlong oscReplicaSyncIntervalUs, jboolean oscReplicaReplayWal, - jint maxConcurrentFlushes, jboolean finishCompactionsOnClose) + jint maxConcurrentFlushes, jboolean finishCompactionsOnClose, jlong objStoreHandle) { const char *path = (*env)->GetStringUTFChars(env, dbPath, NULL); if (path == NULL) @@ -98,10 +101,16 @@ JNIEXPORT jlong JNICALL Java_com_tidesdb_TidesDB_nativeOpen( return 0; } - /* object store connector (filesystem) */ + /* object store connector: a prebuilt connector handle (e.g. S3, created via + * nativeObjstoreS3Create) takes precedence; otherwise fall back to the filesystem + * connector built from objectStoreFsPath. */ tidesdb_objstore_t *obj_store = NULL; const char *fs_path = NULL; - if (objectStoreFsPath != NULL) + if (objStoreHandle != 0) + { + obj_store = (tidesdb_objstore_t *)(uintptr_t)objStoreHandle; + } + else if (objectStoreFsPath != NULL) { fs_path = (*env)->GetStringUTFChars(env, objectStoreFsPath, NULL); if (fs_path != NULL) @@ -178,6 +187,90 @@ JNIEXPORT jlong JNICALL Java_com_tidesdb_TidesDB_nativeOpen( return (jlong)(uintptr_t)db; } +/* S3 object store support is an optional build feature of the core library + * (TIDESDB_WITH_S3=ON). Resolve the factory at runtime via dlsym so this JNI library links and + * loads against a core build that lacks S3 -- callers get a clear exception instead of a + * load-time failure. */ +typedef tidesdb_objstore_t *(*tdb_s3_create_config_fn)(const tidesdb_objstore_s3_config_t *); + +static tdb_s3_create_config_fn resolve_s3_create_config(void) +{ +#ifdef _WIN32 + return NULL; /* S3 connector is not exposed on Windows builds */ +#else + return (tdb_s3_create_config_fn)dlsym(RTLD_DEFAULT, "tidesdb_objstore_s3_create_config"); +#endif +} + +JNIEXPORT jboolean JNICALL Java_com_tidesdb_TidesDB_nativeS3Available(JNIEnv *env, jclass cls) +{ + (void)env; + (void)cls; + return resolve_s3_create_config() != NULL ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT jlong JNICALL Java_com_tidesdb_TidesDB_nativeObjstoreS3Create( + JNIEnv *env, jclass cls, jstring endpoint, jstring bucket, jstring prefix, jstring accessKey, + jstring secretKey, jstring region, jboolean useSsl, jboolean usePathStyle, jstring tlsCaPath, + jboolean tlsInsecureSkipVerify, jlong multipartThreshold, jlong multipartPartSize) +{ + (void)cls; + + tdb_s3_create_config_fn create_fn = resolve_s3_create_config(); + if (create_fn == NULL) + { + throwTidesDBException( + env, TDB_ERR_INVALID_ARGS, + "TidesDB was built without S3 support (rebuild the core library with " + "TIDESDB_WITH_S3=ON)"); + return 0; + } + + /* required strings */ + const char *c_endpoint = endpoint ? (*env)->GetStringUTFChars(env, endpoint, NULL) : NULL; + const char *c_bucket = bucket ? (*env)->GetStringUTFChars(env, bucket, NULL) : NULL; + const char *c_access = accessKey ? (*env)->GetStringUTFChars(env, accessKey, NULL) : NULL; + const char *c_secret = secretKey ? (*env)->GetStringUTFChars(env, secretKey, NULL) : NULL; + /* optional strings */ + const char *c_prefix = prefix ? (*env)->GetStringUTFChars(env, prefix, NULL) : NULL; + const char *c_region = region ? (*env)->GetStringUTFChars(env, region, NULL) : NULL; + const char *c_ca = tlsCaPath ? (*env)->GetStringUTFChars(env, tlsCaPath, NULL) : NULL; + + tidesdb_objstore_s3_config_t cfg = { + .endpoint = c_endpoint, + .bucket = c_bucket, + .prefix = c_prefix, + .access_key = c_access, + .secret_key = c_secret, + .region = c_region, + .use_ssl = useSsl ? 1 : 0, + .use_path_style = usePathStyle ? 1 : 0, + .tls_ca_path = c_ca, + .tls_insecure_skip_verify = tlsInsecureSkipVerify ? 1 : 0, + .multipart_threshold = (size_t)multipartThreshold, + .multipart_part_size = (size_t)multipartPartSize}; + + tidesdb_objstore_t *connector = create_fn(&cfg); + + if (endpoint) (*env)->ReleaseStringUTFChars(env, endpoint, c_endpoint); + if (bucket) (*env)->ReleaseStringUTFChars(env, bucket, c_bucket); + if (accessKey) (*env)->ReleaseStringUTFChars(env, accessKey, c_access); + if (secretKey) (*env)->ReleaseStringUTFChars(env, secretKey, c_secret); + if (prefix) (*env)->ReleaseStringUTFChars(env, prefix, c_prefix); + if (region) (*env)->ReleaseStringUTFChars(env, region, c_region); + if (tlsCaPath) (*env)->ReleaseStringUTFChars(env, tlsCaPath, c_ca); + + if (connector == NULL) + { + throwTidesDBException(env, TDB_ERR_IO, + "Failed to create S3 object store connector (check endpoint, " + "credentials, and bucket)"); + return 0; + } + + return (jlong)(uintptr_t)connector; +} + JNIEXPORT void JNICALL Java_com_tidesdb_TidesDB_nativeClose(JNIEnv *env, jclass cls, jlong handle) { tidesdb_t *db = (tidesdb_t *)(uintptr_t)handle; diff --git a/src/main/java/com/tidesdb/Config.java b/src/main/java/com/tidesdb/Config.java index b72dbd2..16d9d2c 100644 --- a/src/main/java/com/tidesdb/Config.java +++ b/src/main/java/com/tidesdb/Config.java @@ -44,6 +44,7 @@ public class Config { private long unifiedMemtableSyncIntervalUs; private String objectStoreFsPath; private ObjectStoreConfig objectStoreConfig; + private S3Config objectStoreS3Config; private int maxConcurrentFlushes; private boolean finishCompactionsOnClose; @@ -65,6 +66,7 @@ private Config(Builder builder) { this.unifiedMemtableSyncIntervalUs = builder.unifiedMemtableSyncIntervalUs; this.objectStoreFsPath = builder.objectStoreFsPath; this.objectStoreConfig = builder.objectStoreConfig; + this.objectStoreS3Config = builder.objectStoreS3Config; this.maxConcurrentFlushes = builder.maxConcurrentFlushes; this.finishCompactionsOnClose = builder.finishCompactionsOnClose; } @@ -170,6 +172,16 @@ public ObjectStoreConfig getObjectStoreConfig() { return objectStoreConfig; } + /** + * Returns the S3-compatible object store connector configuration, or null if the database + * is not backed by S3. + * + * @return the S3 connector config, or null + */ + public S3Config getObjectStoreS3Config() { + return objectStoreS3Config; + } + public int getMaxConcurrentFlushes() { return maxConcurrentFlushes; } @@ -205,6 +217,7 @@ public static class Builder { private long unifiedMemtableSyncIntervalUs = 0; private String objectStoreFsPath = null; private ObjectStoreConfig objectStoreConfig = null; + private S3Config objectStoreS3Config = null; private int maxConcurrentFlushes = 0; private boolean finishCompactionsOnClose = false; @@ -293,6 +306,20 @@ public Builder objectStoreConfig(ObjectStoreConfig objectStoreConfig) { return this; } + /** + * Backs the database with an S3-compatible object store connector (AWS S3, MinIO, etc.). + * Takes precedence over {@link #objectStoreFsPath(String)} when both are set. Pair with + * {@link #objectStoreConfig(ObjectStoreConfig)} to tune cache, multipart, and replication + * behavior. Requires the native library to be built with {@code TIDESDB_WITH_S3=ON}. + * + * @param objectStoreS3Config the S3 connector configuration, or null for none + * @return this builder + */ + public Builder objectStoreS3Config(S3Config objectStoreS3Config) { + this.objectStoreS3Config = objectStoreS3Config; + return this; + } + public Builder maxConcurrentFlushes(int maxConcurrentFlushes) { this.maxConcurrentFlushes = maxConcurrentFlushes; return this; diff --git a/src/main/java/com/tidesdb/S3Config.java b/src/main/java/com/tidesdb/S3Config.java new file mode 100644 index 0000000..029608f --- /dev/null +++ b/src/main/java/com/tidesdb/S3Config.java @@ -0,0 +1,163 @@ +/** + * + * Copyright (C) TidesDB + * + * Original Author: Alex Gaetano Padula + * + * Licensed under the Mozilla Public License, v. 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 + * + * https://www.mozilla.org/en-US/MPL/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 com.tidesdb; + +/** + * Configuration for an S3-compatible object store connector (AWS S3, MinIO, etc.). + * + *

Mirrors {@code tidesdb_objstore_s3_config_t}. Set it on a {@link Config} via + * {@link Config.Builder#objectStoreS3Config(S3Config)} to back a database with object storage. + * Combine it with an {@link ObjectStoreConfig} (cache, multipart, WAL replication, replica mode) + * for full control over object store behavior. + * + *

Requires the native TidesDB library to have been built with {@code TIDESDB_WITH_S3=ON}; + * otherwise opening the database throws a {@link TidesDBException}. Use + * {@link TidesDB#isS3Available()} to probe support at runtime. + */ +public class S3Config { + + private final String endpoint; + private final String bucket; + private final String prefix; + private final String accessKey; + private final String secretKey; + private final String region; + private final boolean useSsl; + private final boolean usePathStyle; + private final String tlsCaPath; + private final boolean tlsInsecureSkipVerify; + private final long multipartThreshold; + private final long multipartPartSize; + + private S3Config(Builder builder) { + this.endpoint = builder.endpoint; + this.bucket = builder.bucket; + this.prefix = builder.prefix; + this.accessKey = builder.accessKey; + this.secretKey = builder.secretKey; + this.region = builder.region; + this.useSsl = builder.useSsl; + this.usePathStyle = builder.usePathStyle; + this.tlsCaPath = builder.tlsCaPath; + this.tlsInsecureSkipVerify = builder.tlsInsecureSkipVerify; + this.multipartThreshold = builder.multipartThreshold; + this.multipartPartSize = builder.multipartPartSize; + } + + public static Builder builder() { + return new Builder(); + } + + public String getEndpoint() { return endpoint; } + public String getBucket() { return bucket; } + public String getPrefix() { return prefix; } + public String getAccessKey() { return accessKey; } + public String getSecretKey() { return secretKey; } + public String getRegion() { return region; } + public boolean isUseSsl() { return useSsl; } + public boolean isUsePathStyle() { return usePathStyle; } + public String getTlsCaPath() { return tlsCaPath; } + public boolean isTlsInsecureSkipVerify() { return tlsInsecureSkipVerify; } + public long getMultipartThreshold() { return multipartThreshold; } + public long getMultipartPartSize() { return multipartPartSize; } + + /** + * Builder for {@link S3Config}. {@code endpoint}, {@code bucket}, {@code accessKey}, and + * {@code secretKey} are required; the rest default to secure, AWS-friendly values + * (HTTPS on, virtual-hosted URLs, TLS verification enabled, built-in multipart sizing). + */ + public static class Builder { + private String endpoint; + private String bucket; + private String prefix = null; + private String accessKey; + private String secretKey; + private String region = null; + private boolean useSsl = true; + private boolean usePathStyle = false; + private String tlsCaPath = null; + private boolean tlsInsecureSkipVerify = false; + private long multipartThreshold = 0; // 0 = library default + private long multipartPartSize = 0; // 0 = library default + + /** S3 endpoint, e.g. "s3.amazonaws.com" or "minio.local:9000" (required). */ + public Builder endpoint(String endpoint) { this.endpoint = endpoint; return this; } + + /** Bucket name (required). */ + public Builder bucket(String bucket) { this.bucket = bucket; return this; } + + /** Key prefix, e.g. "production/db1/" (optional). */ + public Builder prefix(String prefix) { this.prefix = prefix; return this; } + + /** AWS access key ID (required). */ + public Builder accessKey(String accessKey) { this.accessKey = accessKey; return this; } + + /** AWS secret access key (required). */ + public Builder secretKey(String secretKey) { this.secretKey = secretKey; return this; } + + /** AWS region, e.g. "us-east-1"; null for MinIO/default. */ + public Builder region(String region) { this.region = region; return this; } + + /** 1 for HTTPS (default), 0 for HTTP. */ + public Builder useSsl(boolean useSsl) { this.useSsl = useSsl; return this; } + + /** Path-style URLs (MinIO) when true; virtual-hosted (AWS) when false (default). */ + public Builder usePathStyle(boolean usePathStyle) { this.usePathStyle = usePathStyle; return this; } + + /** Custom CA bundle file path, or null for the system bundle. */ + public Builder tlsCaPath(String tlsCaPath) { this.tlsCaPath = tlsCaPath; return this; } + + /** + * Disable TLS peer and host verification when true (test only, insecure). Default false + * keeps verification on. + */ + public Builder tlsInsecureSkipVerify(boolean tlsInsecureSkipVerify) { + this.tlsInsecureSkipVerify = tlsInsecureSkipVerify; + return this; + } + + /** Object size at/above which multipart upload is used; 0 uses the library default. */ + public Builder multipartThreshold(long multipartThreshold) { + this.multipartThreshold = multipartThreshold; + return this; + } + + /** Multipart chunk size in bytes; 0 uses the library default. */ + public Builder multipartPartSize(long multipartPartSize) { + this.multipartPartSize = multipartPartSize; + return this; + } + + public S3Config build() { + if (endpoint == null || endpoint.isEmpty()) { + throw new IllegalArgumentException("S3 endpoint is required"); + } + if (bucket == null || bucket.isEmpty()) { + throw new IllegalArgumentException("S3 bucket is required"); + } + if (accessKey == null || accessKey.isEmpty()) { + throw new IllegalArgumentException("S3 access key is required"); + } + if (secretKey == null || secretKey.isEmpty()) { + throw new IllegalArgumentException("S3 secret key is required"); + } + return new S3Config(this); + } + } +} diff --git a/src/main/java/com/tidesdb/TidesDB.java b/src/main/java/com/tidesdb/TidesDB.java index 0000601..c5a10c7 100644 --- a/src/main/java/com/tidesdb/TidesDB.java +++ b/src/main/java/com/tidesdb/TidesDB.java @@ -54,6 +54,28 @@ public static TidesDB open(Config config) throws TidesDBException { ObjectStoreConfig osc = config.getObjectStoreConfig(); + // Build an S3 connector up front, if requested. The native handle is owned by the + // database after a successful open (released by close), mirroring the filesystem + // connector path. Creation throws if the library was built without S3 support. + S3Config s3 = config.getObjectStoreS3Config(); + long objStoreHandle = 0; + if (s3 != null) { + objStoreHandle = nativeObjstoreS3Create( + s3.getEndpoint(), + s3.getBucket(), + s3.getPrefix(), + s3.getAccessKey(), + s3.getSecretKey(), + s3.getRegion(), + s3.isUseSsl(), + s3.isUsePathStyle(), + s3.getTlsCaPath(), + s3.isTlsInsecureSkipVerify(), + s3.getMultipartThreshold(), + s3.getMultipartPartSize() + ); + } + long handle = nativeOpen( config.getDbPath(), config.getNumFlushThreads(), @@ -88,11 +110,23 @@ public static TidesDB open(Config config) throws TidesDBException { osc != null ? osc.getReplicaSyncIntervalUs() : 5000000, osc != null ? osc.isReplicaReplayWal() : true, config.getMaxConcurrentFlushes(), - config.isFinishCompactionsOnClose() + config.isFinishCompactionsOnClose(), + objStoreHandle ); return new TidesDB(handle); } + + /** + * Reports whether the native TidesDB library was built with S3 object store support + * ({@code TIDESDB_WITH_S3=ON}). When false, configuring a {@link S3Config} and opening the + * database throws a {@link TidesDBException}. + * + * @return true if an S3-compatible connector can be created, false otherwise + */ + public static boolean isS3Available() { + return nativeS3Available(); + } /** * Closes the database instance and releases all resources. @@ -426,8 +460,18 @@ private static native long nativeOpen(String dbPath, int numFlushThreads, int nu long oscReplicaSyncIntervalUs, boolean oscReplicaReplayWal, int maxConcurrentFlushes, - boolean finishCompactionsOnClose) throws TidesDBException; - + boolean finishCompactionsOnClose, + long objStoreHandle) throws TidesDBException; + + private static native long nativeObjstoreS3Create(String endpoint, String bucket, String prefix, + String accessKey, String secretKey, String region, + boolean useSsl, boolean usePathStyle, + String tlsCaPath, boolean tlsInsecureSkipVerify, + long multipartThreshold, long multipartPartSize) + throws TidesDBException; + + private static native boolean nativeS3Available(); + private static native void nativeClose(long handle); private static native void nativeCreateColumnFamily(long handle, String name, diff --git a/src/test/java/com/tidesdb/TidesDBTest.java b/src/test/java/com/tidesdb/TidesDBTest.java index 77b991e..5456c0b 100644 --- a/src/test/java/com/tidesdb/TidesDBTest.java +++ b/src/test/java/com/tidesdb/TidesDBTest.java @@ -1955,4 +1955,73 @@ void testWriteAmplificationCounters() throws TidesDBException { assertTrue(dbStats.getCompactionCount() >= 0); } } + + @Test + @Order(54) + void testS3ConfigBuilderValidation() { + // Required fields must be present + assertThrows(IllegalArgumentException.class, () -> S3Config.builder().build()); + assertThrows(IllegalArgumentException.class, + () -> S3Config.builder().endpoint("s3.amazonaws.com").build()); + assertThrows(IllegalArgumentException.class, + () -> S3Config.builder().endpoint("s3.amazonaws.com").bucket("b").build()); + assertThrows(IllegalArgumentException.class, + () -> S3Config.builder().endpoint("s3.amazonaws.com").bucket("b").accessKey("ak").build()); + + // A fully specified config builds and exposes its values, with secure defaults + S3Config s3 = S3Config.builder() + .endpoint("s3.amazonaws.com") + .bucket("my-bucket") + .prefix("prod/db1/") + .accessKey("AKID") + .secretKey("SECRET") + .region("us-east-1") + .build(); + assertEquals("s3.amazonaws.com", s3.getEndpoint()); + assertEquals("my-bucket", s3.getBucket()); + assertEquals("prod/db1/", s3.getPrefix()); + assertEquals("us-east-1", s3.getRegion()); + assertTrue(s3.isUseSsl(), "TLS should be on by default"); + assertFalse(s3.isUsePathStyle(), "virtual-hosted by default"); + assertFalse(s3.isTlsInsecureSkipVerify(), "TLS verification on by default"); + assertEquals(0, s3.getMultipartThreshold()); + assertEquals(0, s3.getMultipartPartSize()); + } + + @Test + @Order(55) + void testS3Availability() { + // Probe must be callable and return a definite boolean without throwing. + boolean available = TidesDB.isS3Available(); + assertTrue(available || !available); + } + + @Test + @Order(56) + void testOpenWithS3Config() throws TidesDBException { + S3Config s3 = S3Config.builder() + .endpoint("127.0.0.1:9000") + .bucket("tidesdb-test") + .accessKey("minioadmin") + .secretKey("minioadmin") + .usePathStyle(true) + .useSsl(false) + .build(); + + Config config = Config.builder(tempDir.resolve("testdb_s3").toString()) + .objectStoreS3Config(s3) + .build(); + + if (TidesDB.isS3Available()) { + // With a built-in S3 backend but no live MinIO/S3 endpoint, connector creation or + // open is expected to fail -- but it must surface as a TidesDBException, not a crash. + assertThrows(TidesDBException.class, () -> { TidesDB.open(config).close(); }); + } else { + // No S3 support compiled in: opening must throw a clear, catchable exception. + TidesDBException ex = + assertThrows(TidesDBException.class, () -> TidesDB.open(config)); + assertTrue(ex.getMessage().toLowerCase().contains("s3"), + "exception should explain S3 is unavailable, was: " + ex.getMessage()); + } + } }