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()); + } + } }