From 20cc0e5e612fe169b193fc4a54ad979ae8f5e9b5 Mon Sep 17 00:00:00 2001 From: Minseok Kim Date: Mon, 9 Feb 2026 11:08:07 +0900 Subject: [PATCH 1/9] feat(engine): add StorageBackend abstraction with memory implementation Introduce StorageBackend interface and supporting types as the foundation for a pluggable storage layer, with an in-memory implementation for testing. New types: - StorageBackend: Interface for storage backend implementations - StorageBucket: Interface for key-value operations (get, put, delete, scan, etc.) - StorageBuckets: Data class holding edge and lock buckets - DatastoreUri: Utility for parsing datastore:// URIs with input validation - MemoryStorageBucket: In-memory StorageBucket backed by ByteArrayStore - MemoryStorageBackend: In-memory StorageBackend with isolated stores per namespace Tests: - DatastoreUriTest: URI parsing and validation - StorageBucketCompatibilityTest: Abstract test suite for StorageBucket contracts - MemoryStorageBucketCompatibilityTest: Memory implementation passes all contracts - MemoryStorageBackendTest: Backend-level isolation and lifecycle tests Part of #173 Co-Authored-By: Claude Opus 4.6 --- .../v2/engine/storage/DatastoreUri.kt | 36 ++ .../v2/engine/storage/StorageBackend.kt | 29 ++ .../v2/engine/storage/StorageBucket.kt | 45 +++ .../v2/engine/storage/StorageBuckets.kt | 6 + .../storage/memory/MemoryStorageBackend.kt | 50 +++ .../storage/memory/MemoryStorageBucket.kt | 83 +++++ .../v2/engine/storage/DatastoreUriTest.kt | 99 ++++++ .../storage/MemoryStorageBackendTest.kt | 120 +++++++ .../MemoryStorageBucketCompatibilityTest.kt | 9 + .../storage/StorageBucketCompatibilityTest.kt | 332 ++++++++++++++++++ 10 files changed, 809 insertions(+) create mode 100644 engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/DatastoreUri.kt create mode 100644 engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBackend.kt create mode 100644 engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBucket.kt create mode 100644 engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBuckets.kt create mode 100644 engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageBackend.kt create mode 100644 engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageBucket.kt create mode 100644 engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/DatastoreUriTest.kt create mode 100644 engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBackendTest.kt create mode 100644 engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBucketCompatibilityTest.kt create mode 100644 engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBucketCompatibilityTest.kt diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/DatastoreUri.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/DatastoreUri.kt new file mode 100644 index 00000000..b58b7a8f --- /dev/null +++ b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/DatastoreUri.kt @@ -0,0 +1,36 @@ +package com.kakao.actionbase.v2.engine.storage + +/** + * Utility for parsing datastore URIs. + * + * Format: datastore://{namespace}/{tableName} + */ +object DatastoreUri { + private const val PREFIX = "datastore://" + private val SAFE_NAME_PATTERN = Regex("^[a-zA-Z0-9_-]+$") + + /** + * Parses a datastore URI and returns namespace and table name. + * + * @param uri The URI to parse (e.g., "datastore://my_namespace/my_table") + * @return Pair of (namespace, tableName) + * @throws IllegalArgumentException if URI format is invalid + */ + fun parse(uri: String): Pair { + require(uri.startsWith(PREFIX)) { + "Invalid datastore URI: $uri. Must start with '$PREFIX'" + } + val parts = uri.removePrefix(PREFIX).split("/") + require(parts.size == 2) { + "Invalid datastore URI: $uri. Expected format: datastore://{namespace}/{tableName}" + } + val (namespace, tableName) = parts[0] to parts[1] + require(namespace.isEmpty() || namespace.matches(SAFE_NAME_PATTERN)) { + "Invalid namespace: $namespace. Must contain only alphanumeric, underscore, or hyphen." + } + require(tableName.matches(SAFE_NAME_PATTERN)) { + "Invalid table name: $tableName. Must contain only alphanumeric, underscore, or hyphen." + } + return namespace to tableName + } +} diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBackend.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBackend.kt new file mode 100644 index 00000000..117f94d9 --- /dev/null +++ b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBackend.kt @@ -0,0 +1,29 @@ +package com.kakao.actionbase.v2.engine.storage + +import com.kakao.actionbase.v2.engine.storage.hbase.HBaseTables + +import reactor.core.publisher.Mono + +interface StorageBackend : AutoCloseable { + fun getBucket( + namespace: String, + name: String, + ): Mono + + fun getBucket(uri: String): Mono + + /** + * Returns HBaseTables for backward compatibility with existing Label implementations. + */ + @Deprecated("Use getBucket() instead", ReplaceWith("getBucket(namespace, name)")) + fun getTable( + namespace: String, + name: String, + ): Mono + + /** + * Returns HBaseTables for backward compatibility with existing Label implementations. + */ + @Deprecated("Use getBucket() instead", ReplaceWith("getBucket(uri)")) + fun getTable(uri: String): Mono +} diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBucket.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBucket.kt new file mode 100644 index 00000000..fbf7f510 --- /dev/null +++ b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBucket.kt @@ -0,0 +1,45 @@ +package com.kakao.actionbase.v2.engine.storage + +import com.kakao.actionbase.core.storage.HBaseRecord +import com.kakao.actionbase.core.storage.MutationRequest + +import reactor.core.publisher.Mono + +interface StorageBucket { + fun get(key: ByteArray): Mono + + fun get(keys: List): Mono> + + fun put( + key: ByteArray, + value: ByteArray, + ): Mono + + fun delete(key: ByteArray): Mono + + fun scan( + prefix: ByteArray, + limit: Int, + start: ByteArray?, + stop: ByteArray?, + ): Mono> + + fun increment( + key: ByteArray, + delta: Long, + ): Mono + + fun batch(requests: List): Mono + + fun exists(key: ByteArray): Mono + + fun setIfNotExists( + key: ByteArray, + value: ByteArray, + ): Mono + + fun deleteIfEquals( + key: ByteArray, + expectedValue: ByteArray, + ): Mono +} diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBuckets.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBuckets.kt new file mode 100644 index 00000000..2ca83dc0 --- /dev/null +++ b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBuckets.kt @@ -0,0 +1,6 @@ +package com.kakao.actionbase.v2.engine.storage + +data class StorageBuckets( + val edge: StorageBucket, + val lock: StorageBucket, +) diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageBackend.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageBackend.kt new file mode 100644 index 00000000..38811f44 --- /dev/null +++ b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageBackend.kt @@ -0,0 +1,50 @@ +package com.kakao.actionbase.v2.engine.storage.memory + +import com.kakao.actionbase.engine.datastore.impl.ByteArrayStore +import com.kakao.actionbase.v2.engine.storage.DatastoreUri +import com.kakao.actionbase.v2.engine.storage.StorageBackend +import com.kakao.actionbase.v2.engine.storage.StorageBuckets +import com.kakao.actionbase.v2.engine.storage.hbase.HBaseTables + +import java.util.concurrent.ConcurrentHashMap + +import reactor.core.publisher.Mono + +class MemoryStorageBackend : StorageBackend { + private val stores = ConcurrentHashMap() + + private fun getOrCreateStore( + namespace: String, + name: String, + ): ByteArrayStore { + val key = "$namespace:$name" + return stores.computeIfAbsent(key) { ByteArrayStore() } + } + + override fun getBucket( + namespace: String, + name: String, + ): Mono { + val store = getOrCreateStore(namespace, name) + val bucket = MemoryStorageBucket(store) + return Mono.just(StorageBuckets(bucket, bucket)) + } + + override fun getBucket(uri: String): Mono { + val (ns, name) = DatastoreUri.parse(uri) + return getBucket(ns, name) + } + + @Deprecated("Use getBucket() instead", ReplaceWith("getBucket(namespace, name)")) + override fun getTable( + namespace: String, + name: String, + ): Mono = Mono.error(UnsupportedOperationException("MemoryStorageBackend does not support HBaseTables. Use getBucket() instead.")) + + @Deprecated("Use getBucket() instead", ReplaceWith("getBucket(uri)")) + override fun getTable(uri: String): Mono = Mono.error(UnsupportedOperationException("MemoryStorageBackend does not support HBaseTables. Use getBucket() instead.")) + + override fun close() { + // nothing to close + } +} diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageBucket.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageBucket.kt new file mode 100644 index 00000000..aae3bca0 --- /dev/null +++ b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageBucket.kt @@ -0,0 +1,83 @@ +package com.kakao.actionbase.v2.engine.storage.memory + +import com.kakao.actionbase.core.storage.HBaseRecord +import com.kakao.actionbase.core.storage.MutationRequest +import com.kakao.actionbase.engine.datastore.impl.ByteArrayStore +import com.kakao.actionbase.v2.engine.storage.StorageBucket + +import reactor.core.publisher.Mono + +class MemoryStorageBucket( + private val store: ByteArrayStore, +) : StorageBucket { + override fun get(key: ByteArray): Mono = Mono.fromCallable { store[key] } + + override fun get(keys: List): Mono> = + Mono.fromCallable { + keys.mapNotNull { k -> store[k]?.let { HBaseRecord(key = k, value = it) } } + } + + override fun put( + key: ByteArray, + value: ByteArray, + ): Mono = Mono.fromCallable { store[key] = value }.then() + + override fun delete(key: ByteArray): Mono = Mono.fromCallable { store.remove(key) }.then() + + override fun scan( + prefix: ByteArray, + limit: Int, + start: ByteArray?, + stop: ByteArray?, + ): Mono> = + Mono.fromCallable { + store + .prefixScan(prefix) + .filter { record -> + val afterStart = start == null || compareByteArrays(record.key, start) >= 0 + val beforeStop = stop == null || compareByteArrays(record.key, stop) < 0 + afterStart && beforeStop + }.take(limit) + } + + private fun compareByteArrays( + a: ByteArray, + b: ByteArray, + ): Int { + val minLen = minOf(a.size, b.size) + for (i in 0 until minLen) { + val cmp = (a[i].toInt() and 0xFF) - (b[i].toInt() and 0xFF) + if (cmp != 0) return cmp + } + return a.size - b.size + } + + override fun increment( + key: ByteArray, + delta: Long, + ): Mono = Mono.fromCallable { store.increment(key, delta) } + + override fun batch(requests: List): Mono = + Mono + .fromCallable { + requests.forEach { + when (it) { + is MutationRequest.Put -> store[it.key] = it.value + is MutationRequest.Delete -> store.remove(it.key) + is MutationRequest.Increment -> store.increment(it.key, it.value) + } + } + }.then() + + override fun exists(key: ByteArray): Mono = Mono.fromCallable { store[key] != null } + + override fun setIfNotExists( + key: ByteArray, + value: ByteArray, + ): Mono = Mono.fromCallable { store.checkAndSet(key, null, value) } + + override fun deleteIfEquals( + key: ByteArray, + expectedValue: ByteArray, + ): Mono = Mono.fromCallable { store.checkAndSet(key, expectedValue, null) } +} diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/DatastoreUriTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/DatastoreUriTest.kt new file mode 100644 index 00000000..aaf76509 --- /dev/null +++ b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/DatastoreUriTest.kt @@ -0,0 +1,99 @@ +package com.kakao.actionbase.v2.engine.storage + +import kotlin.test.assertEquals + +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class DatastoreUriTest { + @Nested + @DisplayName("parse") + inner class ParseTest { + @Test + fun `parses valid URI`() { + val (namespace, tableName) = DatastoreUri.parse("datastore://my_namespace/my_table") + + assertEquals("my_namespace", namespace) + assertEquals("my_table", tableName) + } + + @Test + fun `parses URI with empty namespace`() { + val (namespace, tableName) = DatastoreUri.parse("datastore:///my_table") + + assertEquals("", namespace) + assertEquals("my_table", tableName) + } + + @Test + fun `throws for invalid prefix`() { + assertThrows { + DatastoreUri.parse("invalid://namespace/table") + }.also { + assert(it.message!!.contains("Must start with")) + } + } + + @Test + fun `throws for missing prefix`() { + assertThrows { + DatastoreUri.parse("namespace/table") + }.also { + assert(it.message!!.contains("Must start with")) + } + } + + @Test + fun `throws for missing table name`() { + assertThrows { + DatastoreUri.parse("datastore://namespace") + }.also { + assert(it.message!!.contains("Expected format")) + } + } + + @Test + fun `throws for too many path segments`() { + assertThrows { + DatastoreUri.parse("datastore://namespace/table/extra") + }.also { + assert(it.message!!.contains("Expected format")) + } + } + + @Test + fun `throws for empty URI`() { + assertThrows { + DatastoreUri.parse("") + } + } + + @Test + fun `throws for invalid namespace characters`() { + assertThrows { + DatastoreUri.parse("datastore://name space/table") + }.also { + assert(it.message!!.contains("Invalid namespace")) + } + } + + @Test + fun `throws for invalid table name characters`() { + assertThrows { + DatastoreUri.parse("datastore://namespace/table;drop") + }.also { + assert(it.message!!.contains("Invalid table name")) + } + } + + @Test + fun `accepts hyphen and underscore in names`() { + val (namespace, tableName) = DatastoreUri.parse("datastore://my-namespace_1/my_table-2") + + assertEquals("my-namespace_1", namespace) + assertEquals("my_table-2", tableName) + } + } +} diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBackendTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBackendTest.kt new file mode 100644 index 00000000..3e4efb25 --- /dev/null +++ b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBackendTest.kt @@ -0,0 +1,120 @@ +package com.kakao.actionbase.v2.engine.storage + +import com.kakao.actionbase.v2.engine.storage.memory.MemoryStorageBackend + +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class MemoryStorageBackendTest { + private lateinit var backend: MemoryStorageBackend + + @BeforeEach + fun setUp() { + backend = MemoryStorageBackend() + } + + @AfterEach + fun tearDown() { + backend.close() + } + + @Nested + @DisplayName("getBucket") + inner class GetBucketTest { + @Test + fun `returns StorageBuckets with namespace and name`() { + val buckets = backend.getBucket("test-ns", "test-table").block()!! + + assert(buckets.edge != null) + assert(buckets.lock != null) + } + + @Test + fun `returns StorageBuckets with uri`() { + val buckets = backend.getBucket("datastore://test-ns/test-table").block()!! + + assert(buckets.edge != null) + assert(buckets.lock != null) + } + + @Test + fun `buckets share the same underlying store`() { + val buckets = backend.getBucket("test-ns", "test-table").block()!! + val key = "test-key".toByteArray() + val value = "test-value".toByteArray() + + buckets.edge.put(key, value).block() + + // Both edge and lock should see the same data since they share the store + assert( + buckets.edge + .get(key) + .block() + ?.contentEquals(value) == true, + ) + assert( + buckets.lock + .get(key) + .block() + ?.contentEquals(value) == true, + ) + } + + @Test + fun `different buckets are isolated from each other`() { + val buckets1 = backend.getBucket("ns1", "table1").block()!! + val buckets2 = backend.getBucket("ns2", "table2").block()!! + val key = "same-key".toByteArray() + val value1 = "value-from-bucket1".toByteArray() + val value2 = "value-from-bucket2".toByteArray() + + buckets1.edge.put(key, value1).block() + buckets2.edge.put(key, value2).block() + + // Each bucket should have its own value for the same key + assert( + buckets1.edge + .get(key) + .block() + ?.contentEquals(value1) == true, + ) { "bucket1 should have value1" } + assert( + buckets2.edge + .get(key) + .block() + ?.contentEquals(value2) == true, + ) { "bucket2 should have value2" } + } + + @Test + fun `same namespace and name returns same store`() { + val buckets1 = backend.getBucket("ns", "table").block()!! + val buckets2 = backend.getBucket("ns", "table").block()!! + val key = "test-key".toByteArray() + val value = "test-value".toByteArray() + + buckets1.edge.put(key, value).block() + + // Second getBucket with same namespace/name should see the data + assert( + buckets2.edge + .get(key) + .block() + ?.contentEquals(value) == true, + ) { "same namespace+name should share store" } + } + } + + @Nested + @DisplayName("close") + inner class CloseTest { + @Test + fun `close is idempotent`() { + backend.close() + backend.close() // Should not throw + } + } +} diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBucketCompatibilityTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBucketCompatibilityTest.kt new file mode 100644 index 00000000..ce961deb --- /dev/null +++ b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBucketCompatibilityTest.kt @@ -0,0 +1,9 @@ +package com.kakao.actionbase.v2.engine.storage + +import com.kakao.actionbase.engine.datastore.impl.ByteArrayStore +import com.kakao.actionbase.v2.engine.storage.memory.MemoryStorageBucket + +/** Memory (ByteArrayStore) compatibility test for StorageBucket. */ +class MemoryStorageBucketCompatibilityTest : StorageBucketCompatibilityTest() { + override fun createBucket(): StorageBucket = MemoryStorageBucket(ByteArrayStore()) +} diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBucketCompatibilityTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBucketCompatibilityTest.kt new file mode 100644 index 00000000..3254801b --- /dev/null +++ b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBucketCompatibilityTest.kt @@ -0,0 +1,332 @@ +package com.kakao.actionbase.v2.engine.storage + +import com.kakao.actionbase.core.storage.MutationRequest + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicInteger + +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +/** + * Abstract compatibility test for StorageBucket implementations. + * + * Required operations: get, scan, put, delete, increment, batch, checkAndMutate. + */ +abstract class StorageBucketCompatibilityTest { + protected abstract fun createBucket(): StorageBucket + + protected open fun supportsCheckAndMutate(): Boolean = true + + protected open fun supportsScanLimit(): Boolean = true + + protected open fun supportsIncrement(): Boolean = true + + private lateinit var bucket: StorageBucket + + @BeforeEach + fun setUp() { + bucket = createBucket() + } + + @Nested + @DisplayName("get") + inner class GetTest { + @Test + fun `returns value when key exists`() { + bucket.put(b("key"), b("value")).block() + assert(bucket.get(b("key")).block()?.contentEquals(b("value")) == true) + } + + @Test + fun `returns null when key not exists`() { + assert(bucket.get(b("missing")).block() == null) + } + + @Test + fun `getAll returns matching records`() { + bucket.put(b("k1"), b("v1")).block() + bucket.put(b("k2"), b("v2")).block() + assert(bucket.get(listOf(b("k1"), b("k2"))).block()!!.size == 2) + } + + @Test + fun `getAll skips missing keys`() { + bucket.put(b("exists"), b("v")).block() + assert(bucket.get(listOf(b("exists"), b("missing"))).block()!!.size == 1) + } + } + + @Nested + @DisplayName("scan") + inner class ScanTest { + @BeforeEach + fun setup() { + listOf("user:001:a", "user:001:b", "user:002:a", "post:001").forEach { + bucket.put(b(it), b("v")).block() + } + } + + @Test + fun `returns matching prefix`() { + val results = bucket.scan(b("user:001"), 100, null, null).block()!! + assert(results.size == 2) + assert(results.all { String(it.key).startsWith("user:001") }) + } + + @Test + fun `returns empty for non-matching prefix`() { + assert(bucket.scan(b("nonexistent"), 100, null, null).block()!!.isEmpty()) + } + + @Test + fun `returns sorted keys`() { + val keys = bucket.scan(b("user:"), 100, null, null).block()!!.map { String(it.key) } + assert(keys == keys.sorted()) + } + + @Test + fun `respects limit`() { + assumeTrue(supportsScanLimit()) + assert(bucket.scan(b("user:"), 2, null, null).block()!!.size == 2) + } + } + + @Nested + @DisplayName("put") + inner class PutTest { + @Test + fun `stores value`() { + bucket.put(b("k"), b("v")).block() + assert(bucket.get(b("k")).block()?.contentEquals(b("v")) == true) + } + + @Test + fun `overwrites existing`() { + bucket.put(b("k"), b("old")).block() + bucket.put(b("k"), b("new")).block() + assert(String(bucket.get(b("k")).block()!!) == "new") + } + } + + @Nested + @DisplayName("delete") + inner class DeleteTest { + @Test + fun `removes key`() { + bucket.put(b("k"), b("v")).block() + bucket.delete(b("k")).block() + assert(bucket.get(b("k")).block() == null) + } + + @Test + fun `silently succeeds for missing key`() { + bucket.delete(b("nonexistent")).block() + } + } + + @Nested + @DisplayName("increment") + inner class IncrementTest { + @BeforeEach + fun checkSupport() { + assumeTrue(supportsIncrement()) + } + + @Test + fun `creates counter if not exists`() { + assert(bucket.increment(b("cnt"), 10).block() == 10L) + } + + @Test + fun `updates existing counter`() { + bucket.put(b("cnt"), longToBytes(100)).block() + assert(bucket.increment(b("cnt"), 50).block() == 150L) + } + + @Test + fun `decrements with negative delta`() { + bucket.put(b("cnt"), longToBytes(100)).block() + assert(bucket.increment(b("cnt"), -30).block() == 70L) + } + } + + @Nested + @DisplayName("batch") + inner class BatchTest { + @Test + fun `executes puts`() { + bucket.batch(listOf(MutationRequest.Put(b("b1"), b("v1")), MutationRequest.Put(b("b2"), b("v2")))).block() + assert(bucket.get(listOf(b("b1"), b("b2"))).block()!!.size == 2) + } + + @Test + fun `executes deletes`() { + bucket.put(b("d1"), b("v")).block() + bucket.put(b("d2"), b("v")).block() + bucket.batch(listOf(MutationRequest.Delete(b("d1")), MutationRequest.Delete(b("d2")))).block() + assert(bucket.get(listOf(b("d1"), b("d2"))).block()!!.isEmpty()) + } + + @Test + fun `executes increments`() { + assumeTrue(supportsIncrement()) + bucket.batch(listOf(MutationRequest.Increment(b("c1"), 10), MutationRequest.Increment(b("c2"), 20))).block() + assert(bytesToLong(bucket.get(b("c1")).block()!!) == 10L) + assert(bytesToLong(bucket.get(b("c2")).block()!!) == 20L) + } + + @Test + fun `executes mixed mutations`() { + assumeTrue(supportsIncrement()) + bucket.put(b("to-delete"), b("v")).block() + bucket + .batch( + listOf( + MutationRequest.Put(b("new"), b("v")), + MutationRequest.Delete(b("to-delete")), + MutationRequest.Increment(b("cnt"), 100), + ), + ).block() + assert(bucket.get(b("new")).block() != null) + assert(bucket.get(b("to-delete")).block() == null) + assert(bytesToLong(bucket.get(b("cnt")).block()!!) == 100L) + } + } + + @Nested + @DisplayName("exists") + inner class ExistsTest { + @Test + fun `returns true when key exists`() { + bucket.put(b("k"), b("v")).block() + assert(bucket.exists(b("k")).block() == true) + } + + @Test + fun `returns false when key not exists`() { + assert(bucket.exists(b("missing")).block() == false) + } + } + + @Nested + @DisplayName("checkAndMutate") + inner class CheckAndMutateTest { + @BeforeEach + fun checkSupport() { + assumeTrue(supportsCheckAndMutate()) + } + + @Nested + @DisplayName("setIfNotExists") + inner class SetIfNotExistsTest { + @Test + fun `succeeds when key not exists`() { + assert(bucket.setIfNotExists(b("lock"), b("owner")).block() == true) + assert(bucket.get(b("lock")).block()?.contentEquals(b("owner")) == true) + } + + @Test + fun `fails when key exists`() { + bucket.put(b("lock"), b("existing")).block() + assert(bucket.setIfNotExists(b("lock"), b("new")).block() == false) + assert(String(bucket.get(b("lock")).block()!!) == "existing") + } + } + + @Nested + @DisplayName("deleteIfEquals") + inner class DeleteIfEqualsTest { + @Test + fun `succeeds when value matches`() { + bucket.put(b("lock"), b("owner")).block() + assert(bucket.deleteIfEquals(b("lock"), b("owner")).block() == true) + assert(bucket.get(b("lock")).block() == null) + } + + @Test + fun `fails when value differs`() { + bucket.put(b("lock"), b("owner")).block() + assert(bucket.deleteIfEquals(b("lock"), b("different")).block() == false) + assert(bucket.get(b("lock")).block() != null) + } + + @Test + fun `fails when key not exists`() { + assert(bucket.deleteIfEquals(b("missing"), b("v")).block() == false) + } + } + + @Nested + @DisplayName("concurrent") + inner class ConcurrentTest { + @Test + fun `only one thread acquires lock`() { + val threads = 10 + val acquired = AtomicInteger(0) + val latch = CountDownLatch(threads) + val executor = Executors.newFixedThreadPool(threads) + + repeat(threads) { i -> + executor.submit { + try { + if (bucket.setIfNotExists(b("lock"), b("owner-$i")).block() == true) { + acquired.incrementAndGet() + } + } finally { + latch.countDown() + } + } + } + + latch.await() + executor.shutdown() + assert(acquired.get() == 1) { "Expected 1 but got ${acquired.get()}" } + } + + @Test + fun `only owner releases lock`() { + bucket.put(b("lock"), b("owner-0")).block() + val threads = 10 + val released = AtomicInteger(0) + val latch = CountDownLatch(threads) + val executor = Executors.newFixedThreadPool(threads) + + repeat(threads) { i -> + executor.submit { + try { + if (bucket.deleteIfEquals(b("lock"), b("owner-$i")).block() == true) { + released.incrementAndGet() + } + } finally { + latch.countDown() + } + } + } + + latch.await() + executor.shutdown() + assert(released.get() == 1) { "Expected 1 but got ${released.get()}" } + } + } + } + + companion object { + fun b(s: String): ByteArray = s.toByteArray() + + fun longToBytes(v: Long): ByteArray = + ByteBuffer + .allocate(8) + .order(ByteOrder.BIG_ENDIAN) + .putLong(v) + .array() + + fun bytesToLong(b: ByteArray): Long = ByteBuffer.wrap(b).order(ByteOrder.BIG_ENDIAN).long + } +} From abb2e1c921f5bc6a58d5eb0e0210bf8834bd4600 Mon Sep 17 00:00:00 2001 From: Minseok Kim Date: Mon, 9 Feb 2026 12:25:03 +0900 Subject: [PATCH 2/9] refactor(engine): rename StorageBucket to StorageTable, remove StorageTables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StorageBucket → StorageTable (matches table semantics: scan, get, put) - StorageBuckets removed (edge/lock distinction is legacy, unused) - StorageBackend.getBucket() → StorageBackend.open() → Mono - MemoryStorageBucket → MemoryStorageTable Co-Authored-By: Claude Opus 4.6 --- .../v2/engine/storage/StorageBackend.kt | 10 +- .../v2/engine/storage/StorageBuckets.kt | 6 - .../{StorageBucket.kt => StorageTable.kt} | 2 +- .../storage/memory/MemoryStorageBackend.kt | 21 ++- ...StorageBucket.kt => MemoryStorageTable.kt} | 6 +- .../storage/MemoryStorageBackendTest.kt | 75 ++++------ .../MemoryStorageBucketCompatibilityTest.kt | 9 -- .../MemoryStorageTableCompatibilityTest.kt | 9 ++ ...st.kt => StorageTableCompatibilityTest.kt} | 128 +++++++++--------- 9 files changed, 117 insertions(+), 149 deletions(-) delete mode 100644 engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBuckets.kt rename engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/{StorageBucket.kt => StorageTable.kt} (97%) rename engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/{MemoryStorageBucket.kt => MemoryStorageTable.kt} (96%) delete mode 100644 engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBucketCompatibilityTest.kt create mode 100644 engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageTableCompatibilityTest.kt rename engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/{StorageBucketCompatibilityTest.kt => StorageTableCompatibilityTest.kt} (60%) diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBackend.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBackend.kt index 117f94d9..1ff2aff0 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBackend.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBackend.kt @@ -5,17 +5,17 @@ import com.kakao.actionbase.v2.engine.storage.hbase.HBaseTables import reactor.core.publisher.Mono interface StorageBackend : AutoCloseable { - fun getBucket( + fun open( namespace: String, name: String, - ): Mono + ): Mono - fun getBucket(uri: String): Mono + fun open(uri: String): Mono /** * Returns HBaseTables for backward compatibility with existing Label implementations. */ - @Deprecated("Use getBucket() instead", ReplaceWith("getBucket(namespace, name)")) + @Deprecated("Use open() instead", ReplaceWith("open(namespace, name)")) fun getTable( namespace: String, name: String, @@ -24,6 +24,6 @@ interface StorageBackend : AutoCloseable { /** * Returns HBaseTables for backward compatibility with existing Label implementations. */ - @Deprecated("Use getBucket() instead", ReplaceWith("getBucket(uri)")) + @Deprecated("Use open() instead", ReplaceWith("open(uri)")) fun getTable(uri: String): Mono } diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBuckets.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBuckets.kt deleted file mode 100644 index 2ca83dc0..00000000 --- a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBuckets.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.kakao.actionbase.v2.engine.storage - -data class StorageBuckets( - val edge: StorageBucket, - val lock: StorageBucket, -) diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBucket.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageTable.kt similarity index 97% rename from engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBucket.kt rename to engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageTable.kt index fbf7f510..96daf2ea 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBucket.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageTable.kt @@ -5,7 +5,7 @@ import com.kakao.actionbase.core.storage.MutationRequest import reactor.core.publisher.Mono -interface StorageBucket { +interface StorageTable { fun get(key: ByteArray): Mono fun get(keys: List): Mono> diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageBackend.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageBackend.kt index 38811f44..277c1232 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageBackend.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageBackend.kt @@ -3,7 +3,7 @@ package com.kakao.actionbase.v2.engine.storage.memory import com.kakao.actionbase.engine.datastore.impl.ByteArrayStore import com.kakao.actionbase.v2.engine.storage.DatastoreUri import com.kakao.actionbase.v2.engine.storage.StorageBackend -import com.kakao.actionbase.v2.engine.storage.StorageBuckets +import com.kakao.actionbase.v2.engine.storage.StorageTable import com.kakao.actionbase.v2.engine.storage.hbase.HBaseTables import java.util.concurrent.ConcurrentHashMap @@ -21,28 +21,27 @@ class MemoryStorageBackend : StorageBackend { return stores.computeIfAbsent(key) { ByteArrayStore() } } - override fun getBucket( + override fun open( namespace: String, name: String, - ): Mono { + ): Mono { val store = getOrCreateStore(namespace, name) - val bucket = MemoryStorageBucket(store) - return Mono.just(StorageBuckets(bucket, bucket)) + return Mono.just(MemoryStorageTable(store)) } - override fun getBucket(uri: String): Mono { + override fun open(uri: String): Mono { val (ns, name) = DatastoreUri.parse(uri) - return getBucket(ns, name) + return open(ns, name) } - @Deprecated("Use getBucket() instead", ReplaceWith("getBucket(namespace, name)")) + @Deprecated("Use open() instead", ReplaceWith("open(namespace, name)")) override fun getTable( namespace: String, name: String, - ): Mono = Mono.error(UnsupportedOperationException("MemoryStorageBackend does not support HBaseTables. Use getBucket() instead.")) + ): Mono = Mono.error(UnsupportedOperationException("MemoryStorageBackend does not support HBaseTables. Use open() instead.")) - @Deprecated("Use getBucket() instead", ReplaceWith("getBucket(uri)")) - override fun getTable(uri: String): Mono = Mono.error(UnsupportedOperationException("MemoryStorageBackend does not support HBaseTables. Use getBucket() instead.")) + @Deprecated("Use open() instead", ReplaceWith("open(uri)")) + override fun getTable(uri: String): Mono = Mono.error(UnsupportedOperationException("MemoryStorageBackend does not support HBaseTables. Use open() instead.")) override fun close() { // nothing to close diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageBucket.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageTable.kt similarity index 96% rename from engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageBucket.kt rename to engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageTable.kt index aae3bca0..62afafc6 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageBucket.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageTable.kt @@ -3,13 +3,13 @@ package com.kakao.actionbase.v2.engine.storage.memory import com.kakao.actionbase.core.storage.HBaseRecord import com.kakao.actionbase.core.storage.MutationRequest import com.kakao.actionbase.engine.datastore.impl.ByteArrayStore -import com.kakao.actionbase.v2.engine.storage.StorageBucket +import com.kakao.actionbase.v2.engine.storage.StorageTable import reactor.core.publisher.Mono -class MemoryStorageBucket( +class MemoryStorageTable( private val store: ByteArrayStore, -) : StorageBucket { +) : StorageTable { override fun get(key: ByteArray): Mono = Mono.fromCallable { store[key] } override fun get(keys: List): Mono> = diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBackendTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBackendTest.kt index 3e4efb25..3f6f95ac 100644 --- a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBackendTest.kt +++ b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBackendTest.kt @@ -22,85 +22,60 @@ class MemoryStorageBackendTest { } @Nested - @DisplayName("getBucket") - inner class GetBucketTest { + @DisplayName("open") + inner class OpenTest { @Test - fun `returns StorageBuckets with namespace and name`() { - val buckets = backend.getBucket("test-ns", "test-table").block()!! + fun `returns StorageTable with namespace and name`() { + val table = backend.open("test-ns", "test-table").block()!! - assert(buckets.edge != null) - assert(buckets.lock != null) + assert(table != null) } @Test - fun `returns StorageBuckets with uri`() { - val buckets = backend.getBucket("datastore://test-ns/test-table").block()!! + fun `returns StorageTable with uri`() { + val table = backend.open("datastore://test-ns/test-table").block()!! - assert(buckets.edge != null) - assert(buckets.lock != null) + assert(table != null) } @Test - fun `buckets share the same underlying store`() { - val buckets = backend.getBucket("test-ns", "test-table").block()!! - val key = "test-key".toByteArray() - val value = "test-value".toByteArray() - - buckets.edge.put(key, value).block() - - // Both edge and lock should see the same data since they share the store - assert( - buckets.edge - .get(key) - .block() - ?.contentEquals(value) == true, - ) - assert( - buckets.lock - .get(key) - .block() - ?.contentEquals(value) == true, - ) - } - - @Test - fun `different buckets are isolated from each other`() { - val buckets1 = backend.getBucket("ns1", "table1").block()!! - val buckets2 = backend.getBucket("ns2", "table2").block()!! + fun `different tables are isolated from each other`() { + val table1 = backend.open("ns1", "table1").block()!! + val table2 = backend.open("ns2", "table2").block()!! val key = "same-key".toByteArray() - val value1 = "value-from-bucket1".toByteArray() - val value2 = "value-from-bucket2".toByteArray() + val value1 = "value-from-table1".toByteArray() + val value2 = "value-from-table2".toByteArray() - buckets1.edge.put(key, value1).block() - buckets2.edge.put(key, value2).block() + table1.put(key, value1).block() + table2.put(key, value2).block() - // Each bucket should have its own value for the same key + // Each table should have its own value for the same key assert( - buckets1.edge + table1 .get(key) .block() ?.contentEquals(value1) == true, - ) { "bucket1 should have value1" } + ) { "table1 should have value1" } assert( - buckets2.edge + table2 .get(key) .block() ?.contentEquals(value2) == true, - ) { "bucket2 should have value2" } + ) { "table2 should have value2" } } @Test fun `same namespace and name returns same store`() { - val buckets1 = backend.getBucket("ns", "table").block()!! - val buckets2 = backend.getBucket("ns", "table").block()!! + val table1 = backend.open("ns", "table").block()!! + val table2 = backend.open("ns", "table").block()!! val key = "test-key".toByteArray() val value = "test-value".toByteArray() - buckets1.edge.put(key, value).block() + table1.put(key, value).block() - // Second getBucket with same namespace/name should see the data + // Second open with same namespace/name should see the data assert( - buckets2.edge + table2 .get(key) .block() ?.contentEquals(value) == true, diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBucketCompatibilityTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBucketCompatibilityTest.kt deleted file mode 100644 index ce961deb..00000000 --- a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBucketCompatibilityTest.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.kakao.actionbase.v2.engine.storage - -import com.kakao.actionbase.engine.datastore.impl.ByteArrayStore -import com.kakao.actionbase.v2.engine.storage.memory.MemoryStorageBucket - -/** Memory (ByteArrayStore) compatibility test for StorageBucket. */ -class MemoryStorageBucketCompatibilityTest : StorageBucketCompatibilityTest() { - override fun createBucket(): StorageBucket = MemoryStorageBucket(ByteArrayStore()) -} diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageTableCompatibilityTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageTableCompatibilityTest.kt new file mode 100644 index 00000000..a29f4341 --- /dev/null +++ b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageTableCompatibilityTest.kt @@ -0,0 +1,9 @@ +package com.kakao.actionbase.v2.engine.storage + +import com.kakao.actionbase.engine.datastore.impl.ByteArrayStore +import com.kakao.actionbase.v2.engine.storage.memory.MemoryStorageTable + +/** Memory (ByteArrayStore) compatibility test for StorageTable. */ +class MemoryStorageTableCompatibilityTest : StorageTableCompatibilityTest() { + override fun createTable(): StorageTable = MemoryStorageTable(ByteArrayStore()) +} diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBucketCompatibilityTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/StorageTableCompatibilityTest.kt similarity index 60% rename from engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBucketCompatibilityTest.kt rename to engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/StorageTableCompatibilityTest.kt index 3254801b..504b8627 100644 --- a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBucketCompatibilityTest.kt +++ b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/StorageTableCompatibilityTest.kt @@ -15,12 +15,12 @@ import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test /** - * Abstract compatibility test for StorageBucket implementations. + * Abstract compatibility test for StorageTable implementations. * * Required operations: get, scan, put, delete, increment, batch, checkAndMutate. */ -abstract class StorageBucketCompatibilityTest { - protected abstract fun createBucket(): StorageBucket +abstract class StorageTableCompatibilityTest { + protected abstract fun createTable(): StorageTable protected open fun supportsCheckAndMutate(): Boolean = true @@ -28,11 +28,11 @@ abstract class StorageBucketCompatibilityTest { protected open fun supportsIncrement(): Boolean = true - private lateinit var bucket: StorageBucket + private lateinit var table: StorageTable @BeforeEach fun setUp() { - bucket = createBucket() + table = createTable() } @Nested @@ -40,26 +40,26 @@ abstract class StorageBucketCompatibilityTest { inner class GetTest { @Test fun `returns value when key exists`() { - bucket.put(b("key"), b("value")).block() - assert(bucket.get(b("key")).block()?.contentEquals(b("value")) == true) + table.put(b("key"), b("value")).block() + assert(table.get(b("key")).block()?.contentEquals(b("value")) == true) } @Test fun `returns null when key not exists`() { - assert(bucket.get(b("missing")).block() == null) + assert(table.get(b("missing")).block() == null) } @Test fun `getAll returns matching records`() { - bucket.put(b("k1"), b("v1")).block() - bucket.put(b("k2"), b("v2")).block() - assert(bucket.get(listOf(b("k1"), b("k2"))).block()!!.size == 2) + table.put(b("k1"), b("v1")).block() + table.put(b("k2"), b("v2")).block() + assert(table.get(listOf(b("k1"), b("k2"))).block()!!.size == 2) } @Test fun `getAll skips missing keys`() { - bucket.put(b("exists"), b("v")).block() - assert(bucket.get(listOf(b("exists"), b("missing"))).block()!!.size == 1) + table.put(b("exists"), b("v")).block() + assert(table.get(listOf(b("exists"), b("missing"))).block()!!.size == 1) } } @@ -69,32 +69,32 @@ abstract class StorageBucketCompatibilityTest { @BeforeEach fun setup() { listOf("user:001:a", "user:001:b", "user:002:a", "post:001").forEach { - bucket.put(b(it), b("v")).block() + table.put(b(it), b("v")).block() } } @Test fun `returns matching prefix`() { - val results = bucket.scan(b("user:001"), 100, null, null).block()!! + val results = table.scan(b("user:001"), 100, null, null).block()!! assert(results.size == 2) assert(results.all { String(it.key).startsWith("user:001") }) } @Test fun `returns empty for non-matching prefix`() { - assert(bucket.scan(b("nonexistent"), 100, null, null).block()!!.isEmpty()) + assert(table.scan(b("nonexistent"), 100, null, null).block()!!.isEmpty()) } @Test fun `returns sorted keys`() { - val keys = bucket.scan(b("user:"), 100, null, null).block()!!.map { String(it.key) } + val keys = table.scan(b("user:"), 100, null, null).block()!!.map { String(it.key) } assert(keys == keys.sorted()) } @Test fun `respects limit`() { assumeTrue(supportsScanLimit()) - assert(bucket.scan(b("user:"), 2, null, null).block()!!.size == 2) + assert(table.scan(b("user:"), 2, null, null).block()!!.size == 2) } } @@ -103,15 +103,15 @@ abstract class StorageBucketCompatibilityTest { inner class PutTest { @Test fun `stores value`() { - bucket.put(b("k"), b("v")).block() - assert(bucket.get(b("k")).block()?.contentEquals(b("v")) == true) + table.put(b("k"), b("v")).block() + assert(table.get(b("k")).block()?.contentEquals(b("v")) == true) } @Test fun `overwrites existing`() { - bucket.put(b("k"), b("old")).block() - bucket.put(b("k"), b("new")).block() - assert(String(bucket.get(b("k")).block()!!) == "new") + table.put(b("k"), b("old")).block() + table.put(b("k"), b("new")).block() + assert(String(table.get(b("k")).block()!!) == "new") } } @@ -120,14 +120,14 @@ abstract class StorageBucketCompatibilityTest { inner class DeleteTest { @Test fun `removes key`() { - bucket.put(b("k"), b("v")).block() - bucket.delete(b("k")).block() - assert(bucket.get(b("k")).block() == null) + table.put(b("k"), b("v")).block() + table.delete(b("k")).block() + assert(table.get(b("k")).block() == null) } @Test fun `silently succeeds for missing key`() { - bucket.delete(b("nonexistent")).block() + table.delete(b("nonexistent")).block() } } @@ -141,19 +141,19 @@ abstract class StorageBucketCompatibilityTest { @Test fun `creates counter if not exists`() { - assert(bucket.increment(b("cnt"), 10).block() == 10L) + assert(table.increment(b("cnt"), 10).block() == 10L) } @Test fun `updates existing counter`() { - bucket.put(b("cnt"), longToBytes(100)).block() - assert(bucket.increment(b("cnt"), 50).block() == 150L) + table.put(b("cnt"), longToBytes(100)).block() + assert(table.increment(b("cnt"), 50).block() == 150L) } @Test fun `decrements with negative delta`() { - bucket.put(b("cnt"), longToBytes(100)).block() - assert(bucket.increment(b("cnt"), -30).block() == 70L) + table.put(b("cnt"), longToBytes(100)).block() + assert(table.increment(b("cnt"), -30).block() == 70L) } } @@ -162,31 +162,31 @@ abstract class StorageBucketCompatibilityTest { inner class BatchTest { @Test fun `executes puts`() { - bucket.batch(listOf(MutationRequest.Put(b("b1"), b("v1")), MutationRequest.Put(b("b2"), b("v2")))).block() - assert(bucket.get(listOf(b("b1"), b("b2"))).block()!!.size == 2) + table.batch(listOf(MutationRequest.Put(b("b1"), b("v1")), MutationRequest.Put(b("b2"), b("v2")))).block() + assert(table.get(listOf(b("b1"), b("b2"))).block()!!.size == 2) } @Test fun `executes deletes`() { - bucket.put(b("d1"), b("v")).block() - bucket.put(b("d2"), b("v")).block() - bucket.batch(listOf(MutationRequest.Delete(b("d1")), MutationRequest.Delete(b("d2")))).block() - assert(bucket.get(listOf(b("d1"), b("d2"))).block()!!.isEmpty()) + table.put(b("d1"), b("v")).block() + table.put(b("d2"), b("v")).block() + table.batch(listOf(MutationRequest.Delete(b("d1")), MutationRequest.Delete(b("d2")))).block() + assert(table.get(listOf(b("d1"), b("d2"))).block()!!.isEmpty()) } @Test fun `executes increments`() { assumeTrue(supportsIncrement()) - bucket.batch(listOf(MutationRequest.Increment(b("c1"), 10), MutationRequest.Increment(b("c2"), 20))).block() - assert(bytesToLong(bucket.get(b("c1")).block()!!) == 10L) - assert(bytesToLong(bucket.get(b("c2")).block()!!) == 20L) + table.batch(listOf(MutationRequest.Increment(b("c1"), 10), MutationRequest.Increment(b("c2"), 20))).block() + assert(bytesToLong(table.get(b("c1")).block()!!) == 10L) + assert(bytesToLong(table.get(b("c2")).block()!!) == 20L) } @Test fun `executes mixed mutations`() { assumeTrue(supportsIncrement()) - bucket.put(b("to-delete"), b("v")).block() - bucket + table.put(b("to-delete"), b("v")).block() + table .batch( listOf( MutationRequest.Put(b("new"), b("v")), @@ -194,9 +194,9 @@ abstract class StorageBucketCompatibilityTest { MutationRequest.Increment(b("cnt"), 100), ), ).block() - assert(bucket.get(b("new")).block() != null) - assert(bucket.get(b("to-delete")).block() == null) - assert(bytesToLong(bucket.get(b("cnt")).block()!!) == 100L) + assert(table.get(b("new")).block() != null) + assert(table.get(b("to-delete")).block() == null) + assert(bytesToLong(table.get(b("cnt")).block()!!) == 100L) } } @@ -205,13 +205,13 @@ abstract class StorageBucketCompatibilityTest { inner class ExistsTest { @Test fun `returns true when key exists`() { - bucket.put(b("k"), b("v")).block() - assert(bucket.exists(b("k")).block() == true) + table.put(b("k"), b("v")).block() + assert(table.exists(b("k")).block() == true) } @Test fun `returns false when key not exists`() { - assert(bucket.exists(b("missing")).block() == false) + assert(table.exists(b("missing")).block() == false) } } @@ -228,15 +228,15 @@ abstract class StorageBucketCompatibilityTest { inner class SetIfNotExistsTest { @Test fun `succeeds when key not exists`() { - assert(bucket.setIfNotExists(b("lock"), b("owner")).block() == true) - assert(bucket.get(b("lock")).block()?.contentEquals(b("owner")) == true) + assert(table.setIfNotExists(b("lock"), b("owner")).block() == true) + assert(table.get(b("lock")).block()?.contentEquals(b("owner")) == true) } @Test fun `fails when key exists`() { - bucket.put(b("lock"), b("existing")).block() - assert(bucket.setIfNotExists(b("lock"), b("new")).block() == false) - assert(String(bucket.get(b("lock")).block()!!) == "existing") + table.put(b("lock"), b("existing")).block() + assert(table.setIfNotExists(b("lock"), b("new")).block() == false) + assert(String(table.get(b("lock")).block()!!) == "existing") } } @@ -245,21 +245,21 @@ abstract class StorageBucketCompatibilityTest { inner class DeleteIfEqualsTest { @Test fun `succeeds when value matches`() { - bucket.put(b("lock"), b("owner")).block() - assert(bucket.deleteIfEquals(b("lock"), b("owner")).block() == true) - assert(bucket.get(b("lock")).block() == null) + table.put(b("lock"), b("owner")).block() + assert(table.deleteIfEquals(b("lock"), b("owner")).block() == true) + assert(table.get(b("lock")).block() == null) } @Test fun `fails when value differs`() { - bucket.put(b("lock"), b("owner")).block() - assert(bucket.deleteIfEquals(b("lock"), b("different")).block() == false) - assert(bucket.get(b("lock")).block() != null) + table.put(b("lock"), b("owner")).block() + assert(table.deleteIfEquals(b("lock"), b("different")).block() == false) + assert(table.get(b("lock")).block() != null) } @Test fun `fails when key not exists`() { - assert(bucket.deleteIfEquals(b("missing"), b("v")).block() == false) + assert(table.deleteIfEquals(b("missing"), b("v")).block() == false) } } @@ -276,7 +276,7 @@ abstract class StorageBucketCompatibilityTest { repeat(threads) { i -> executor.submit { try { - if (bucket.setIfNotExists(b("lock"), b("owner-$i")).block() == true) { + if (table.setIfNotExists(b("lock"), b("owner-$i")).block() == true) { acquired.incrementAndGet() } } finally { @@ -292,7 +292,7 @@ abstract class StorageBucketCompatibilityTest { @Test fun `only owner releases lock`() { - bucket.put(b("lock"), b("owner-0")).block() + table.put(b("lock"), b("owner-0")).block() val threads = 10 val released = AtomicInteger(0) val latch = CountDownLatch(threads) @@ -301,7 +301,7 @@ abstract class StorageBucketCompatibilityTest { repeat(threads) { i -> executor.submit { try { - if (bucket.deleteIfEquals(b("lock"), b("owner-$i")).block() == true) { + if (table.deleteIfEquals(b("lock"), b("owner-$i")).block() == true) { released.incrementAndGet() } } finally { From 594a7515d2bc98c8e9c901b86eac0f39f7b2783c Mon Sep 17 00:00:00 2001 From: Minseok Kim Date: Mon, 9 Feb 2026 11:13:55 +0900 Subject: [PATCH 3/9] feat(engine): add HBase StorageBackend and DefaultStorageBackendFactory Add HBase-specific StorageBackend implementation and a factory for creating storage backends based on configuration. New types: - HBaseStorageBucket: StorageBucket backed by HBase AsyncTable - HBaseStorageBackend: StorageBackend for HBase clusters with Kerberos support - MockHBaseStorageBackend: Mock HBase backend using MockHTable for testing - DefaultStorageBackendFactory: Factory for creating backends (memory/embedded/hbase) Test fixtures: - HBaseTestingStorageBackend: Backend using HBase testing cluster - HBaseTestingClusterExtension: Updated to initialize DefaultStorageBackendFactory Tests: - HBaseStorageBucketCompatibilityTest: HBase impl passes StorageBucket contract - HBaseStorageBackendTest: Validation of create() parameters - DefaultStorageBackendFactoryTest: Factory initialization and lifecycle Part of #173 Co-Authored-By: Claude Opus 4.6 --- .../storage/DefaultStorageBackendFactory.kt | 106 ++++++++++ .../storage/hbase/HBaseStorageBackend.kt | 197 ++++++++++++++++++ .../storage/hbase/HBaseStorageBucket.kt | 148 +++++++++++++ .../storage/hbase/MockHBaseStorageBackend.kt | 66 ++++++ .../DefaultStorageBackendFactoryTest.kt | 49 +++++ .../engine/storage/HBaseStorageBackendTest.kt | 72 +++++++ .../HBaseStorageBucketCompatibilityTest.kt | 89 ++++++++ .../hbase/HBaseTestingClusterExtension.kt | 4 + .../test/hbase/HBaseTestingStorageBackend.kt | 65 ++++++ 9 files changed, 796 insertions(+) create mode 100644 engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/DefaultStorageBackendFactory.kt create mode 100644 engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageBackend.kt create mode 100644 engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageBucket.kt create mode 100644 engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/MockHBaseStorageBackend.kt create mode 100644 engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/DefaultStorageBackendFactoryTest.kt create mode 100644 engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/HBaseStorageBackendTest.kt create mode 100644 engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/HBaseStorageBucketCompatibilityTest.kt create mode 100644 engine/src/testFixtures/kotlin/com/kakao/actionbase/test/hbase/HBaseTestingStorageBackend.kt diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/DefaultStorageBackendFactory.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/DefaultStorageBackendFactory.kt new file mode 100644 index 00000000..9ea0ac9e --- /dev/null +++ b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/DefaultStorageBackendFactory.kt @@ -0,0 +1,106 @@ +package com.kakao.actionbase.v2.engine.storage + +import com.kakao.actionbase.v2.engine.storage.hbase.HBaseStorageBackend +import com.kakao.actionbase.v2.engine.storage.hbase.MockHBaseStorageBackend +import com.kakao.actionbase.v2.engine.storage.memory.MemoryStorageBackend + +import org.slf4j.LoggerFactory + +/** + * Factory for creating StorageBackend instances. + * + * Thread-safety: This factory is designed to be initialized once at application startup. + * The initialize() method is synchronized to prevent race conditions during initialization. + * Once initialized, the factory cannot be re-initialized. + * + * Usage: + * ```yaml + * hbase: + * type: memory # memory | embedded | hbase (default) + * ``` + */ +object DefaultStorageBackendFactory { + private val logger = LoggerFactory.getLogger(DefaultStorageBackendFactory::class.java) + + @Volatile + private var instance0: StorageBackend? = null + + @Volatile + private var defaultNamespace0: String = "default" + + val INSTANCE: StorageBackend + get() = instance0 ?: throw IllegalStateException("StorageBackend not initialized. Call initialize() first.") + + val defaultNamespace: String + get() = defaultNamespace0 + + val isInitialized: Boolean + get() = instance0 != null + + /** + * Initializes the storage backend based on the provided properties. + * If already initialized, this method does nothing (idempotent). + * + * @param properties Configuration properties including: + * - type: Backend type (memory, embedded, hbase). Defaults to "hbase". + * - For HBase type, see HBaseStorageBackend.create for additional properties. + */ + @Synchronized + fun initialize(properties: Map) { + if (isInitialized) { + logger.debug("StorageBackend already initialized, skipping") + return + } + val type = properties["type"] ?: "hbase" + defaultNamespace0 = properties["namespace"] ?: "default" + logger.info("Initializing StorageBackend with type: {}, namespace: {}", type, defaultNamespace0) + + instance0 = + when (type) { + "memory" -> { + logger.info("Using MemoryStorageBackend") + MemoryStorageBackend() + } + "embedded" -> { + logger.info("Using MockHBaseStorageBackend (embedded)") + MockHBaseStorageBackend() + } + else -> { + if (properties.isEmpty() || properties["version"] == "embedded") { + logger.info("🚀 - Using Embedded Mock Storage (legacy)") + MockHBaseStorageBackend() + } else { + logger.info("Using HBaseStorageBackend") + HBaseStorageBackend.create(properties) + } + } + } + } + + /** + * Initializes the factory with a pre-created StorageBackend instance. + * If already initialized, this method does nothing (idempotent). + * + * @param backend The StorageBackend instance to use. + * @param namespace The default namespace to use. + */ + @Synchronized + fun initialize( + backend: StorageBackend, + namespace: String = "default", + ) { + if (isInitialized) { + logger.debug("StorageBackend already initialized, skipping") + return + } + logger.info("Initializing StorageBackend with provided instance: {}, namespace: {}", backend::class.simpleName, namespace) + instance0 = backend + defaultNamespace0 = namespace + } + + @Synchronized + fun close() { + instance0?.close() + instance0 = null + } +} diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageBackend.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageBackend.kt new file mode 100644 index 00000000..ccca9d9c --- /dev/null +++ b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageBackend.kt @@ -0,0 +1,197 @@ +package com.kakao.actionbase.v2.engine.storage.hbase + +import com.kakao.actionbase.v2.engine.storage.DatastoreUri +import com.kakao.actionbase.v2.engine.storage.StorageBackend +import com.kakao.actionbase.v2.engine.storage.StorageBuckets + +import org.apache.hadoop.conf.Configuration +import org.apache.hadoop.hbase.HBaseConfiguration +import org.apache.hadoop.hbase.TableName +import org.apache.hadoop.hbase.client.AsyncConnection +import org.apache.hadoop.hbase.client.ConnectionFactory +import org.apache.hadoop.security.UserGroupInformation +import org.slf4j.LoggerFactory + +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers + +class HBaseStorageBackend private constructor( + private val connectionMono: Mono, + // Retained for potential future use (e.g., default namespace fallback, admin operations) + @Suppress("unused") private val namespace: String, + // Retained for potential future use (e.g., connection pool management, config inspection) + @Suppress("unused") private val config: Configuration, +) : StorageBackend { + /** + * Returns a cached Mono of AsyncAdmin for HBase admin operations. + * Use this instead of accessing the raw connection directly. + */ + fun getAdminMono(): Mono = connectionMono.map { it.admin }.cache() + + override fun getBucket( + namespace: String, + name: String, + ): Mono = + connectionMono.map { conn -> + val table = conn.getTable(TableName.valueOf(namespace, name)) + val hbaseTable = HBaseTable.create(table) + val bucket = HBaseStorageBucket(hbaseTable) + StorageBuckets(bucket, bucket) + } + + override fun getBucket(uri: String): Mono { + val (ns, name) = DatastoreUri.parse(uri) + return getBucket(ns, name) + } + + @Deprecated("Use getBucket() instead", ReplaceWith("getBucket(namespace, name)")) + override fun getTable( + namespace: String, + name: String, + ): Mono = + connectionMono.map { conn -> + val table = conn.getTable(TableName.valueOf(namespace, name)) + val hbaseTable = HBaseTable.create(table) + HBaseTables(hbaseTable, hbaseTable) + } + + @Deprecated("Use getBucket() instead", ReplaceWith("getBucket(uri)")) + override fun getTable(uri: String): Mono { + val (ns, name) = DatastoreUri.parse(uri) + return getTable(ns, name) + } + + override fun close() { + connectionMono.block()?.close() + } + + companion object { + const val DEFAULT_HBASE_NAMESPACE = "default" + const val LEGACY_DEFAULT_KERBEROS_REALM = "KAKAO.HADOOP" + + private val logger = LoggerFactory.getLogger(HBaseStorageBackend::class.java) + + /** + * Creates HBaseStorageBackend from properties. + * + * # Properties + * secure: true or false + * version: 2.4 or 2.5 + * namespace: HBase namespace + * + * # for 2.4 + * hbase.zookeeper.quorum: host1:2181,host2:2181,host3:2181 + * # for 2.5 + * hbase.client.bootstrap.servers: host1:16000,host2:16000,host3:16000 + * + * # for secure cluster + * kerberos.realm: e.g. EXAMPLE.COM (or env AB_KERBEROS_REALM) + * - If missing, defaults to KAKAO.HADOOP for backward compatibility (deprecated) + * krb5ConfPath: /path/to/krb5.conf (or env AB_KRB5_CONF_PATH) + * keytabPath: e.g. /path/to/hadoop-cdl-write.keytab (or env AB_KEYTAB_PATH) + * principal: e.g. hadoop-cdl-write@EXAMPLE.COM (or env AB_PRINCIPAL) + */ + fun create(properties: Map): HBaseStorageBackend { + logger.info("HBaseStorageBackend is being initialized.") + + val config = HBaseConfiguration.create() + + val isSecure = properties["secure"]?.toBoolean() ?: false + val version = properties["version"] ?: "2.4" + val namespace = properties["namespace"] ?: throw IllegalArgumentException("HBase namespace is not set") + + require(version.startsWith("2.4") || version.startsWith("2.5")) { + "Unsupported HBase version: $version. Supported versions are 2.4.x and 2.5.x." + } + + val krb5ConfPathOpt: String? = properties["krb5ConfPath"] ?: System.getenv("AB_KRB5_CONF_PATH") + val principalOpt: String? = properties["principal"] ?: System.getenv("AB_PRINCIPAL") + val keytabPathOpt: String? = properties["keytabPath"] ?: System.getenv("AB_KEYTAB_PATH") + + val zookeeperQuorumOpt: String? = properties["hbase.zookeeper.quorum"] + val clientBootstrapServersOpt: String? = properties["hbase.client.bootstrap.servers"] + + if (isSecure) { + val krb5ConfPath = krb5ConfPathOpt ?: throw IllegalStateException("Kerberos krb5.conf path is not set") + val principal = principalOpt ?: throw IllegalStateException("Kerberos principal is not set") + val keytabPath = keytabPathOpt ?: throw IllegalStateException("Kerberos keytab path is not set") + val kerberosRealm = resolveKerberosRealm(properties) + + System.setProperty("java.security.krb5.conf", krb5ConfPath) + + config["hadoop.security.authentication"] = "kerberos" + config["hbase.security.authentication"] = "kerberos" + config["hbase.master.kerberos.principal"] = "hbase/_HOST@$kerberosRealm" + config["hbase.regionserver.kerberos.principal"] = "hbase/_HOST@$kerberosRealm" + + config["hbase.client.keytab.principal"] = principal + config["hbase.client.keytab.file"] = keytabPath + } + + if (version.startsWith("2.4")) { + logger.info("🚀 - Using HBase 2.4 - zookeeperQuorum: $zookeeperQuorumOpt") + config["hbase.zookeeper.quorum"] = + zookeeperQuorumOpt ?: throw IllegalStateException("zookeeper.quorum is not set") + } else if (version.startsWith("2.5")) { + logger.info("🚀 - Using HBase 2.5 - clientBootstrapServers: $clientBootstrapServersOpt") + config["hbase.client.registry.impl"] = "org.apache.hadoop.hbase.client.RpcConnectionRegistry" + config["hbase.client.bootstrap.servers"] = + clientBootstrapServersOpt ?: throw IllegalStateException("hbase.client.bootstrap.servers is not set") + } + + properties.forEach { (key, value) -> + if (key.startsWith("hbase.") || key.startsWith("hadoop.")) { + config[key] = value + } + } + + if (isSecure) { + logger.info("🚀 - Using secure HBase cluster with Kerberos authentication") + UserGroupInformation.setConfiguration(config) + } + + val checkConnectionConfig = Configuration(config) + // For HBase 2.4.x + checkConnectionConfig.setInt("zookeeper.recovery.retry", 1) + checkConnectionConfig.setInt("hbase.client.retries.number", 1) + + // For HBase 2.5+ + checkConnectionConfig.setInt("hbase.client.connection.registry.impl.retry", 1) + checkConnectionConfig.setInt("hbase.client.registry.timeout", 10000) + checkConnectionConfig.setInt("hbase.client.operation.timeout", 10000) + checkConnectionConfig.setInt("hbase.rpc.timeout", 10000) + + val connectionMono = + Mono + .fromFuture(ConnectionFactory.createAsyncConnection(checkConnectionConfig)) + .publishOn(Schedulers.boundedElastic()) + .doOnSuccess { conn -> + logger.info("🚀 - Successfully established a new HBase connection") + conn.close() + }.flatMap { + Mono.fromFuture(ConnectionFactory.createAsyncConnection(config)) + }.cache() + + return HBaseStorageBackend(connectionMono, namespace, config) + } + + internal fun resolveKerberosRealm( + properties: Map, + envKerberosRealm: String? = System.getenv("AB_KERBEROS_REALM"), + ): String { + val kerberosRealm = (properties["kerberos.realm"] ?: envKerberosRealm)?.trim() + + if (kerberosRealm == null) { + logger.warn( + "`kerberos.realm` is not set; falling back to legacy default realm `{}` for backward compatibility. This fallback is deprecated and will be removed in a future release.", + LEGACY_DEFAULT_KERBEROS_REALM, + ) + // TODO(ab#180): Remove legacy fallback and require explicit kerberos.realm after migration period. + return LEGACY_DEFAULT_KERBEROS_REALM + } + + require(kerberosRealm.isNotEmpty()) { "Kerberos realm must not be blank" } + return kerberosRealm + } + } +} diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageBucket.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageBucket.kt new file mode 100644 index 00000000..0b673c85 --- /dev/null +++ b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageBucket.kt @@ -0,0 +1,148 @@ +package com.kakao.actionbase.v2.engine.storage.hbase + +import com.kakao.actionbase.core.Constants +import com.kakao.actionbase.core.storage.HBaseRecord +import com.kakao.actionbase.core.storage.MutationRequest +import com.kakao.actionbase.v2.engine.storage.StorageBucket + +import org.apache.hadoop.hbase.client.CheckAndMutate +import org.apache.hadoop.hbase.client.Delete +import org.apache.hadoop.hbase.client.Get +import org.apache.hadoop.hbase.client.Increment +import org.apache.hadoop.hbase.client.Put +import org.apache.hadoop.hbase.client.Scan + +import reactor.core.publisher.Mono + +class HBaseStorageBucket( + private val table: HBaseTable, +) : StorageBucket { + override fun get(key: ByteArray): Mono { + val get = Get(key).addColumn(Constants.DEFAULT_COLUMN_FAMILY, Constants.DEFAULT_QUALIFIER) + return table.get(get).handle { result, sink -> + if (!result.isEmpty) { + sink.next(result.getValue(Constants.DEFAULT_COLUMN_FAMILY, Constants.DEFAULT_QUALIFIER)) + } + // For empty result, don't emit anything (Mono will complete with null) + } + } + + override fun get(keys: List): Mono> { + val gets = keys.map { Get(it).addColumn(Constants.DEFAULT_COLUMN_FAMILY, Constants.DEFAULT_QUALIFIER) } + return table.get(gets).map { results -> + results.filter { !it.isEmpty }.map { result -> + HBaseRecord( + key = result.row, + value = result.getValue(Constants.DEFAULT_COLUMN_FAMILY, Constants.DEFAULT_QUALIFIER), + ) + } + } + } + + override fun put( + key: ByteArray, + value: ByteArray, + ): Mono { + val put = Put(key).addColumn(Constants.DEFAULT_COLUMN_FAMILY, Constants.DEFAULT_QUALIFIER, value) + return table.put(put) + } + + override fun delete(key: ByteArray): Mono { + val delete = Delete(key) + return table.delete(delete) + } + + override fun scan( + prefix: ByteArray, + limit: Int, + start: ByteArray?, + stop: ByteArray?, + ): Mono> { + val scan = + Scan() + .addColumn(Constants.DEFAULT_COLUMN_FAMILY, Constants.DEFAULT_QUALIFIER) + .setRowPrefixFilter(prefix) + + if (start != null) scan.withStartRow(start, true) + if (stop != null) scan.withStopRow(stop, false) + + return table.scan(scan, limit).map { results -> + results.map { result -> + HBaseRecord( + key = result.row, + value = result.getValue(Constants.DEFAULT_COLUMN_FAMILY, Constants.DEFAULT_QUALIFIER), + ) + } + } + } + + override fun increment( + key: ByteArray, + delta: Long, + ): Mono { + val increment = Increment(key).addColumn(Constants.DEFAULT_COLUMN_FAMILY, Constants.DEFAULT_QUALIFIER, delta) + return table.increment(increment).map { result -> + result.getValue(Constants.DEFAULT_COLUMN_FAMILY, Constants.DEFAULT_QUALIFIER).toLong() + } + } + + override fun batch(requests: List): Mono { + val mutations = + requests.map { + when (it) { + is MutationRequest.Put -> + Put(it.key).addColumn( + Constants.DEFAULT_COLUMN_FAMILY, + Constants.DEFAULT_QUALIFIER, + it.value, + ) + is MutationRequest.Delete -> Delete(it.key) + is MutationRequest.Increment -> + Increment(it.key).addColumn( + Constants.DEFAULT_COLUMN_FAMILY, + Constants.DEFAULT_QUALIFIER, + it.value, + ) + } + } + return table.batch(mutations) + } + + override fun exists(key: ByteArray): Mono { + val get = Get(key).addColumn(Constants.DEFAULT_COLUMN_FAMILY, Constants.DEFAULT_QUALIFIER) + return table.exists(get) + } + + override fun setIfNotExists( + key: ByteArray, + value: ByteArray, + ): Mono { + val checkAndMutate = + CheckAndMutate + .newBuilder(key) + .ifNotExists(Constants.DEFAULT_COLUMN_FAMILY, Constants.DEFAULT_QUALIFIER) + .build(Put(key).addColumn(Constants.DEFAULT_COLUMN_FAMILY, Constants.DEFAULT_QUALIFIER, value)) + return table.checkAndMutate(checkAndMutate).map { it.isSuccess } + } + + override fun deleteIfEquals( + key: ByteArray, + expectedValue: ByteArray, + ): Mono { + val checkAndMutate = + CheckAndMutate + .newBuilder(key) + .ifEquals(Constants.DEFAULT_COLUMN_FAMILY, Constants.DEFAULT_QUALIFIER, expectedValue) + .build(Delete(key)) + return table.checkAndMutate(checkAndMutate).map { it.isSuccess } + } + + companion object { + private fun ByteArray.toLong(): Long { + require(size == 8) { "Expected 8 bytes, got $size" } + return (0..7).fold(0L) { acc, i -> + (acc shl 8) or (this[i].toLong() and 0xFF) + } + } + } +} diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/MockHBaseStorageBackend.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/MockHBaseStorageBackend.kt new file mode 100644 index 00000000..05507a0b --- /dev/null +++ b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/MockHBaseStorageBackend.kt @@ -0,0 +1,66 @@ +package com.kakao.actionbase.v2.engine.storage.hbase + +import com.kakao.actionbase.v2.engine.storage.DatastoreUri +import com.kakao.actionbase.v2.engine.storage.StorageBackend +import com.kakao.actionbase.v2.engine.storage.StorageBuckets +import com.kakao.actionbase.v2.engine.storage.hbase.impl.NewMockTable + +import org.apache.hadoop.hbase.TableName +import org.apache.hadoop.hbase.client.mock.MockHTable + +import reactor.core.publisher.Mono + +/** + * Mock HBase storage backend for testing and embedded mode. + * Uses HBase MockHTable for storage operations. + * + * Each namespace + name combination gets its own isolated table. + */ +class MockHBaseStorageBackend : StorageBackend { + override fun getBucket( + namespace: String, + name: String, + ): Mono { + val hbaseTable = createMockHBaseTable(namespace, name) + val bucket = HBaseStorageBucket(hbaseTable) + return Mono.just(StorageBuckets(bucket, bucket)) + } + + override fun getBucket(uri: String): Mono { + val (ns, name) = DatastoreUri.parse(uri) + return getBucket(ns, name) + } + + @Deprecated("Use getBucket() instead", ReplaceWith("getBucket(namespace, name)")) + override fun getTable( + namespace: String, + name: String, + ): Mono { + val hbaseTable = createMockHBaseTable(namespace, name) + return Mono.just(HBaseTables(hbaseTable, hbaseTable)) + } + + @Deprecated("Use getBucket() instead", ReplaceWith("getBucket(uri)")) + override fun getTable(uri: String): Mono { + val (ns, name) = DatastoreUri.parse(uri) + return getTable(ns, name) + } + + override fun close() { + // nothing to close + } + + /** + * Creates a mock HBase table with proper namespace:name isolation. + */ + private fun createMockHBaseTable( + namespace: String, + name: String, + ): HBaseTable { + val conn = HBaseConnections.getMockConnection(namespace) + val tableName = if (name.isEmpty()) "edges" else name + val mockTable = conn.getTable(TableName.valueOf(tableName)) as MockHTable + val table = NewMockTable(mockTable) + return HBaseTable.create(table) + } +} diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/DefaultStorageBackendFactoryTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/DefaultStorageBackendFactoryTest.kt new file mode 100644 index 00000000..72a63a9e --- /dev/null +++ b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/DefaultStorageBackendFactoryTest.kt @@ -0,0 +1,49 @@ +package com.kakao.actionbase.v2.engine.storage + +import com.kakao.actionbase.test.hbase.HBaseTestingClusterExtension + +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +/** + * Tests for DefaultStorageBackendFactory. + * + * Uses HBaseTestingClusterExtension to ensure consistent initialization + * with the HBase testing backend across all tests. + */ +@ExtendWith(HBaseTestingClusterExtension::class) +class DefaultStorageBackendFactoryTest { + @Nested + @DisplayName("initialize") + inner class InitializeTest { + @Test + fun `initialize is idempotent`() { + // Extension already initialized - second call should not throw + DefaultStorageBackendFactory.initialize(mapOf("type" to "memory")) + DefaultStorageBackendFactory.initialize(mapOf("type" to "embedded")) + + assert(DefaultStorageBackendFactory.isInitialized) + } + } + + @Nested + @DisplayName("isInitialized") + inner class IsInitializedTest { + @Test + fun `returns true after initialization`() { + assert(DefaultStorageBackendFactory.isInitialized) + } + } + + @Nested + @DisplayName("close") + inner class CloseTest { + @Test + fun `close is idempotent`() { + DefaultStorageBackendFactory.close() + DefaultStorageBackendFactory.close() // Should not throw + } + } +} diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/HBaseStorageBackendTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/HBaseStorageBackendTest.kt new file mode 100644 index 00000000..135aee95 --- /dev/null +++ b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/HBaseStorageBackendTest.kt @@ -0,0 +1,72 @@ +package com.kakao.actionbase.v2.engine.storage + +import com.kakao.actionbase.v2.engine.storage.hbase.HBaseStorageBackend + +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class HBaseStorageBackendTest { + @Nested + @DisplayName("create") + inner class CreateTest { + @Test + fun `throws when namespace is missing`() { + val props = mapOf("version" to "2.4", "hbase.zookeeper.quorum" to "localhost:2181") + + assertThrows { + HBaseStorageBackend.create(props) + } + } + + @Test + fun `throws when version is unsupported`() { + val props = mapOf("namespace" to "test", "version" to "3.0") + + assertThrows { + HBaseStorageBackend.create(props) + } + } + + @Test + fun `throws when zookeeper quorum is missing for 2_4`() { + val props = mapOf("namespace" to "test", "version" to "2.4") + + assertThrows { + HBaseStorageBackend.create(props) + } + } + + @Test + fun `throws when bootstrap servers is missing for 2_5`() { + val props = mapOf("namespace" to "test", "version" to "2.5") + + assertThrows { + HBaseStorageBackend.create(props) + } + } + + @Test + fun `throws when kerberos config is incomplete`() { + val props = + mapOf( + "namespace" to "test", + "version" to "2.4", + "hbase.zookeeper.quorum" to "localhost:2181", + "secure" to "true", + ) + + assertThrows { + HBaseStorageBackend.create(props) + } + } + } + + @Nested + @DisplayName("parseDatastoreUri") + inner class ParseDatastoreUriTest { + // Note: parseDatastoreUri is private, so we test it through getBucket + // These are covered implicitly by integration tests + } +} diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/HBaseStorageBucketCompatibilityTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/HBaseStorageBucketCompatibilityTest.kt new file mode 100644 index 00000000..68a472ee --- /dev/null +++ b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/HBaseStorageBucketCompatibilityTest.kt @@ -0,0 +1,89 @@ +package com.kakao.actionbase.v2.engine.storage + +import com.kakao.actionbase.test.hbase.HBaseTestingCluster +import com.kakao.actionbase.v2.engine.storage.hbase.HBaseConnections +import com.kakao.actionbase.v2.engine.storage.hbase.HBaseStorageBucket +import com.kakao.actionbase.v2.engine.storage.hbase.HBaseTable +import com.kakao.actionbase.v2.engine.storage.hbase.impl.HBaseSyncTable +import com.kakao.actionbase.v2.engine.storage.hbase.impl.NewMockTable + +import org.apache.hadoop.hbase.NamespaceDescriptor +import org.apache.hadoop.hbase.TableName +import org.apache.hadoop.hbase.client.Admin +import org.apache.hadoop.hbase.client.ColumnFamilyDescriptorBuilder +import org.apache.hadoop.hbase.client.Delete +import org.apache.hadoop.hbase.client.Scan +import org.apache.hadoop.hbase.client.Table +import org.apache.hadoop.hbase.client.TableDescriptorBuilder +import org.apache.hadoop.hbase.client.mock.MockHTable +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.TestInstance + +/** + * HBase compatibility test for StorageBucket. + * Default: MockConnection. Set HBASE_MINI_CLUSTER=true for mini cluster. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class HBaseStorageBucketCompatibilityTest : StorageBucketCompatibilityTest() { + private lateinit var table: Table + private lateinit var hbaseTable: HBaseTable + private val tableName = TableName.valueOf("test", "storage_bucket_test") + private val cf = "f".toByteArray() + private val useMiniCluster = System.getenv("HBASE_MINI_CLUSTER") == "true" + + @BeforeAll + fun setUpHBase() { + val connection = + if (useMiniCluster) { + HBaseTestingCluster.startIfNeeded() + HBaseTestingCluster.connection.also { createTableIfNeeded(it.admin) } + } else { + HBaseConnections.getMockConnection("test") + } + table = connection.getTable(tableName) + hbaseTable = + if (useMiniCluster) { + HBaseSyncTable(table) + } else { + HBaseTable.create(NewMockTable(table as MockHTable)) + } + } + + @AfterAll + fun tearDownHBase() { + table.close() + if (useMiniCluster) HBaseTestingCluster.stopIfNeeded() + } + + @BeforeEach + fun cleanup() { + table.getScanner(Scan()).use { s -> + s.map { Delete(it.row) }.takeIf { it.isNotEmpty() }?.let { table.delete(it) } + } + } + + override fun createBucket(): StorageBucket = HBaseStorageBucket(hbaseTable) + + override fun supportsCheckAndMutate() = useMiniCluster + + override fun supportsScanLimit() = useMiniCluster + + override fun supportsIncrement() = useMiniCluster + + private fun createTableIfNeeded(admin: Admin) { + val ns = tableName.namespaceAsString + if (admin.listNamespaceDescriptors().none { it.name == ns }) { + admin.createNamespace(NamespaceDescriptor.create(ns).build()) + } + if (!admin.tableExists(tableName)) { + admin.createTable( + TableDescriptorBuilder + .newBuilder(tableName) + .setColumnFamily(ColumnFamilyDescriptorBuilder.of(cf)) + .build(), + ) + } + } +} diff --git a/engine/src/testFixtures/kotlin/com/kakao/actionbase/test/hbase/HBaseTestingClusterExtension.kt b/engine/src/testFixtures/kotlin/com/kakao/actionbase/test/hbase/HBaseTestingClusterExtension.kt index d9afb787..8380125e 100644 --- a/engine/src/testFixtures/kotlin/com/kakao/actionbase/test/hbase/HBaseTestingClusterExtension.kt +++ b/engine/src/testFixtures/kotlin/com/kakao/actionbase/test/hbase/HBaseTestingClusterExtension.kt @@ -1,6 +1,7 @@ package com.kakao.actionbase.test.hbase import com.kakao.actionbase.v2.engine.compat.DefaultHBaseCluster +import com.kakao.actionbase.v2.engine.storage.DefaultStorageBackendFactory import org.apache.hadoop.hbase.client.AsyncConnection import org.apache.hadoop.hbase.client.AsyncTable @@ -27,6 +28,9 @@ class HBaseTestingClusterExtension : override fun beforeAll(context: ExtensionContext) { HBaseTestingCluster.startIfNeeded() DefaultHBaseCluster.initialize(Mono.just(HBaseTestingCluster.asyncConnection), "ab_test", HBaseTestingCluster.hbaseConfiguration) + // Initialize DefaultStorageBackendFactory with the HBase testing cluster (idempotent) + val testingBackend = HBaseTestingStorageBackend(Mono.just(HBaseTestingCluster.asyncConnection), "ab_test") + DefaultStorageBackendFactory.initialize(testingBackend, "ab_test") } override fun supportsParameter( diff --git a/engine/src/testFixtures/kotlin/com/kakao/actionbase/test/hbase/HBaseTestingStorageBackend.kt b/engine/src/testFixtures/kotlin/com/kakao/actionbase/test/hbase/HBaseTestingStorageBackend.kt new file mode 100644 index 00000000..2819b59d --- /dev/null +++ b/engine/src/testFixtures/kotlin/com/kakao/actionbase/test/hbase/HBaseTestingStorageBackend.kt @@ -0,0 +1,65 @@ +package com.kakao.actionbase.test.hbase + +import com.kakao.actionbase.v2.engine.storage.DatastoreUri +import com.kakao.actionbase.v2.engine.storage.StorageBackend +import com.kakao.actionbase.v2.engine.storage.StorageBuckets +import com.kakao.actionbase.v2.engine.storage.hbase.HBaseStorageBucket +import com.kakao.actionbase.v2.engine.storage.hbase.HBaseTable +import com.kakao.actionbase.v2.engine.storage.hbase.HBaseTables + +import org.apache.hadoop.hbase.TableName +import org.apache.hadoop.hbase.client.AsyncConnection + +import reactor.core.publisher.Mono + +/** + * Storage backend that uses the HBase testing cluster. + * This backend creates tables using the provided AsyncConnection. + */ +class HBaseTestingStorageBackend( + private val connectionMono: Mono, + private val defaultNamespace: String, +) : StorageBackend { + override fun getBucket( + namespace: String, + name: String, + ): Mono { + val effectiveNs = namespace.ifEmpty { defaultNamespace } + return connectionMono.map { conn -> + val tableName = TableName.valueOf(effectiveNs, name) + val asyncTable = conn.getTable(tableName) + val hbaseTable = HBaseTable.create(asyncTable) + val bucket = HBaseStorageBucket(hbaseTable) + StorageBuckets(bucket, bucket) + } + } + + override fun getBucket(uri: String): Mono { + val (ns, name) = DatastoreUri.parse(uri) + return getBucket(ns, name) + } + + @Deprecated("Use getBucket() instead", ReplaceWith("getBucket(namespace, name)")) + override fun getTable( + namespace: String, + name: String, + ): Mono { + val effectiveNs = namespace.ifEmpty { defaultNamespace } + return connectionMono.map { conn -> + val tableName = TableName.valueOf(effectiveNs, name) + val asyncTable = conn.getTable(tableName) + val hbaseTable = HBaseTable.create(asyncTable) + HBaseTables(hbaseTable, hbaseTable) + } + } + + @Deprecated("Use getBucket() instead", ReplaceWith("getBucket(uri)")) + override fun getTable(uri: String): Mono { + val (ns, name) = DatastoreUri.parse(uri) + return getTable(ns, name) + } + + override fun close() { + // Connection is managed by HBaseTestingCluster + } +} From 7c70670a33f0bb64344df9f7c6ef6f0a06eaba61 Mon Sep 17 00:00:00 2001 From: Minseok Kim Date: Mon, 9 Feb 2026 12:28:46 +0900 Subject: [PATCH 4/9] refactor(engine): rename HBaseStorageBucket to HBaseStorageTable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align HBase implementation with the renamed StorageTable interface. - HBaseStorageBucket → HBaseStorageTable - getBucket() → open() across all StorageBackend implementations - Remove StorageBuckets references (edge/lock distinction was unused) Co-Authored-By: Claude Opus 4.6 --- .../storage/hbase/HBaseStorageBackend.kt | 17 ++++++++--------- ...eStorageBucket.kt => HBaseStorageTable.kt} | 6 +++--- .../storage/hbase/MockHBaseStorageBackend.kt | 17 ++++++++--------- ... => HBaseStorageTableCompatibilityTest.kt} | 10 +++++----- .../test/hbase/HBaseTestingStorageBackend.kt | 19 +++++++++---------- 5 files changed, 33 insertions(+), 36 deletions(-) rename engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/{HBaseStorageBucket.kt => HBaseStorageTable.kt} (98%) rename engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/{HBaseStorageBucketCompatibilityTest.kt => HBaseStorageTableCompatibilityTest.kt} (91%) diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageBackend.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageBackend.kt index ccca9d9c..f6f7e2c0 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageBackend.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageBackend.kt @@ -2,7 +2,7 @@ package com.kakao.actionbase.v2.engine.storage.hbase import com.kakao.actionbase.v2.engine.storage.DatastoreUri import com.kakao.actionbase.v2.engine.storage.StorageBackend -import com.kakao.actionbase.v2.engine.storage.StorageBuckets +import com.kakao.actionbase.v2.engine.storage.StorageTable import org.apache.hadoop.conf.Configuration import org.apache.hadoop.hbase.HBaseConfiguration @@ -28,23 +28,22 @@ class HBaseStorageBackend private constructor( */ fun getAdminMono(): Mono = connectionMono.map { it.admin }.cache() - override fun getBucket( + override fun open( namespace: String, name: String, - ): Mono = + ): Mono = connectionMono.map { conn -> val table = conn.getTable(TableName.valueOf(namespace, name)) val hbaseTable = HBaseTable.create(table) - val bucket = HBaseStorageBucket(hbaseTable) - StorageBuckets(bucket, bucket) + HBaseStorageTable(hbaseTable) } - override fun getBucket(uri: String): Mono { + override fun open(uri: String): Mono { val (ns, name) = DatastoreUri.parse(uri) - return getBucket(ns, name) + return open(ns, name) } - @Deprecated("Use getBucket() instead", ReplaceWith("getBucket(namespace, name)")) + @Deprecated("Use open() instead", ReplaceWith("open(namespace, name)")) override fun getTable( namespace: String, name: String, @@ -55,7 +54,7 @@ class HBaseStorageBackend private constructor( HBaseTables(hbaseTable, hbaseTable) } - @Deprecated("Use getBucket() instead", ReplaceWith("getBucket(uri)")) + @Deprecated("Use open() instead", ReplaceWith("open(uri)")) override fun getTable(uri: String): Mono { val (ns, name) = DatastoreUri.parse(uri) return getTable(ns, name) diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageBucket.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageTable.kt similarity index 98% rename from engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageBucket.kt rename to engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageTable.kt index 0b673c85..28531965 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageBucket.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageTable.kt @@ -3,7 +3,7 @@ package com.kakao.actionbase.v2.engine.storage.hbase import com.kakao.actionbase.core.Constants import com.kakao.actionbase.core.storage.HBaseRecord import com.kakao.actionbase.core.storage.MutationRequest -import com.kakao.actionbase.v2.engine.storage.StorageBucket +import com.kakao.actionbase.v2.engine.storage.StorageTable import org.apache.hadoop.hbase.client.CheckAndMutate import org.apache.hadoop.hbase.client.Delete @@ -14,9 +14,9 @@ import org.apache.hadoop.hbase.client.Scan import reactor.core.publisher.Mono -class HBaseStorageBucket( +class HBaseStorageTable( private val table: HBaseTable, -) : StorageBucket { +) : StorageTable { override fun get(key: ByteArray): Mono { val get = Get(key).addColumn(Constants.DEFAULT_COLUMN_FAMILY, Constants.DEFAULT_QUALIFIER) return table.get(get).handle { result, sink -> diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/MockHBaseStorageBackend.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/MockHBaseStorageBackend.kt index 05507a0b..317f44e6 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/MockHBaseStorageBackend.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/MockHBaseStorageBackend.kt @@ -2,7 +2,7 @@ package com.kakao.actionbase.v2.engine.storage.hbase import com.kakao.actionbase.v2.engine.storage.DatastoreUri import com.kakao.actionbase.v2.engine.storage.StorageBackend -import com.kakao.actionbase.v2.engine.storage.StorageBuckets +import com.kakao.actionbase.v2.engine.storage.StorageTable import com.kakao.actionbase.v2.engine.storage.hbase.impl.NewMockTable import org.apache.hadoop.hbase.TableName @@ -17,21 +17,20 @@ import reactor.core.publisher.Mono * Each namespace + name combination gets its own isolated table. */ class MockHBaseStorageBackend : StorageBackend { - override fun getBucket( + override fun open( namespace: String, name: String, - ): Mono { + ): Mono { val hbaseTable = createMockHBaseTable(namespace, name) - val bucket = HBaseStorageBucket(hbaseTable) - return Mono.just(StorageBuckets(bucket, bucket)) + return Mono.just(HBaseStorageTable(hbaseTable)) } - override fun getBucket(uri: String): Mono { + override fun open(uri: String): Mono { val (ns, name) = DatastoreUri.parse(uri) - return getBucket(ns, name) + return open(ns, name) } - @Deprecated("Use getBucket() instead", ReplaceWith("getBucket(namespace, name)")) + @Deprecated("Use open() instead", ReplaceWith("open(namespace, name)")) override fun getTable( namespace: String, name: String, @@ -40,7 +39,7 @@ class MockHBaseStorageBackend : StorageBackend { return Mono.just(HBaseTables(hbaseTable, hbaseTable)) } - @Deprecated("Use getBucket() instead", ReplaceWith("getBucket(uri)")) + @Deprecated("Use open() instead", ReplaceWith("open(uri)")) override fun getTable(uri: String): Mono { val (ns, name) = DatastoreUri.parse(uri) return getTable(ns, name) diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/HBaseStorageBucketCompatibilityTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/HBaseStorageTableCompatibilityTest.kt similarity index 91% rename from engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/HBaseStorageBucketCompatibilityTest.kt rename to engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/HBaseStorageTableCompatibilityTest.kt index 68a472ee..3fecc95a 100644 --- a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/HBaseStorageBucketCompatibilityTest.kt +++ b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/HBaseStorageTableCompatibilityTest.kt @@ -2,7 +2,7 @@ package com.kakao.actionbase.v2.engine.storage import com.kakao.actionbase.test.hbase.HBaseTestingCluster import com.kakao.actionbase.v2.engine.storage.hbase.HBaseConnections -import com.kakao.actionbase.v2.engine.storage.hbase.HBaseStorageBucket +import com.kakao.actionbase.v2.engine.storage.hbase.HBaseStorageTable import com.kakao.actionbase.v2.engine.storage.hbase.HBaseTable import com.kakao.actionbase.v2.engine.storage.hbase.impl.HBaseSyncTable import com.kakao.actionbase.v2.engine.storage.hbase.impl.NewMockTable @@ -22,14 +22,14 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.TestInstance /** - * HBase compatibility test for StorageBucket. + * HBase compatibility test for StorageTable. * Default: MockConnection. Set HBASE_MINI_CLUSTER=true for mini cluster. */ @TestInstance(TestInstance.Lifecycle.PER_CLASS) -class HBaseStorageBucketCompatibilityTest : StorageBucketCompatibilityTest() { +class HBaseStorageTableCompatibilityTest : StorageTableCompatibilityTest() { private lateinit var table: Table private lateinit var hbaseTable: HBaseTable - private val tableName = TableName.valueOf("test", "storage_bucket_test") + private val tableName = TableName.valueOf("test", "storage_table_test") private val cf = "f".toByteArray() private val useMiniCluster = System.getenv("HBASE_MINI_CLUSTER") == "true" @@ -64,7 +64,7 @@ class HBaseStorageBucketCompatibilityTest : StorageBucketCompatibilityTest() { } } - override fun createBucket(): StorageBucket = HBaseStorageBucket(hbaseTable) + override fun createTable(): StorageTable = HBaseStorageTable(hbaseTable) override fun supportsCheckAndMutate() = useMiniCluster diff --git a/engine/src/testFixtures/kotlin/com/kakao/actionbase/test/hbase/HBaseTestingStorageBackend.kt b/engine/src/testFixtures/kotlin/com/kakao/actionbase/test/hbase/HBaseTestingStorageBackend.kt index 2819b59d..5107950e 100644 --- a/engine/src/testFixtures/kotlin/com/kakao/actionbase/test/hbase/HBaseTestingStorageBackend.kt +++ b/engine/src/testFixtures/kotlin/com/kakao/actionbase/test/hbase/HBaseTestingStorageBackend.kt @@ -2,8 +2,8 @@ package com.kakao.actionbase.test.hbase import com.kakao.actionbase.v2.engine.storage.DatastoreUri import com.kakao.actionbase.v2.engine.storage.StorageBackend -import com.kakao.actionbase.v2.engine.storage.StorageBuckets -import com.kakao.actionbase.v2.engine.storage.hbase.HBaseStorageBucket +import com.kakao.actionbase.v2.engine.storage.StorageTable +import com.kakao.actionbase.v2.engine.storage.hbase.HBaseStorageTable import com.kakao.actionbase.v2.engine.storage.hbase.HBaseTable import com.kakao.actionbase.v2.engine.storage.hbase.HBaseTables @@ -20,26 +20,25 @@ class HBaseTestingStorageBackend( private val connectionMono: Mono, private val defaultNamespace: String, ) : StorageBackend { - override fun getBucket( + override fun open( namespace: String, name: String, - ): Mono { + ): Mono { val effectiveNs = namespace.ifEmpty { defaultNamespace } return connectionMono.map { conn -> val tableName = TableName.valueOf(effectiveNs, name) val asyncTable = conn.getTable(tableName) val hbaseTable = HBaseTable.create(asyncTable) - val bucket = HBaseStorageBucket(hbaseTable) - StorageBuckets(bucket, bucket) + HBaseStorageTable(hbaseTable) } } - override fun getBucket(uri: String): Mono { + override fun open(uri: String): Mono { val (ns, name) = DatastoreUri.parse(uri) - return getBucket(ns, name) + return open(ns, name) } - @Deprecated("Use getBucket() instead", ReplaceWith("getBucket(namespace, name)")) + @Deprecated("Use open() instead", ReplaceWith("open(namespace, name)")) override fun getTable( namespace: String, name: String, @@ -53,7 +52,7 @@ class HBaseTestingStorageBackend( } } - @Deprecated("Use getBucket() instead", ReplaceWith("getBucket(uri)")) + @Deprecated("Use open() instead", ReplaceWith("open(uri)")) override fun getTable(uri: String): Mono { val (ns, name) = DatastoreUri.parse(uri) return getTable(ns, name) From 4356dea39450b0814b36b29edc18c6206a9de0e6 Mon Sep 17 00:00:00 2001 From: Minseok Kim Date: Mon, 9 Feb 2026 12:49:00 +0900 Subject: [PATCH 5/9] refactor(engine): rename open() to getStorageTable() in StorageBackend open() implies close() semantics. getStorageTable() is more explicit about what it does. URI overload becomes a default method. Removed deprecated getTable() methods from interface. Co-Authored-By: Claude Opus 4.6 --- .../v2/engine/storage/StorageBackend.kt | 24 ++++--------------- .../storage/memory/MemoryStorageBackend.kt | 18 +------------- .../storage/MemoryStorageBackendTest.kt | 16 ++++++------- 3 files changed, 14 insertions(+), 44 deletions(-) diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBackend.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBackend.kt index 1ff2aff0..0b106578 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBackend.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBackend.kt @@ -1,29 +1,15 @@ package com.kakao.actionbase.v2.engine.storage -import com.kakao.actionbase.v2.engine.storage.hbase.HBaseTables - import reactor.core.publisher.Mono interface StorageBackend : AutoCloseable { - fun open( + fun getStorageTable( namespace: String, name: String, ): Mono - fun open(uri: String): Mono - - /** - * Returns HBaseTables for backward compatibility with existing Label implementations. - */ - @Deprecated("Use open() instead", ReplaceWith("open(namespace, name)")) - fun getTable( - namespace: String, - name: String, - ): Mono - - /** - * Returns HBaseTables for backward compatibility with existing Label implementations. - */ - @Deprecated("Use open() instead", ReplaceWith("open(uri)")) - fun getTable(uri: String): Mono + fun getStorageTable(uri: String): Mono { + val (ns, name) = DatastoreUri.parse(uri) + return getStorageTable(ns, name) + } } diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageBackend.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageBackend.kt index 277c1232..7b831440 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageBackend.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageBackend.kt @@ -1,10 +1,8 @@ package com.kakao.actionbase.v2.engine.storage.memory import com.kakao.actionbase.engine.datastore.impl.ByteArrayStore -import com.kakao.actionbase.v2.engine.storage.DatastoreUri import com.kakao.actionbase.v2.engine.storage.StorageBackend import com.kakao.actionbase.v2.engine.storage.StorageTable -import com.kakao.actionbase.v2.engine.storage.hbase.HBaseTables import java.util.concurrent.ConcurrentHashMap @@ -21,7 +19,7 @@ class MemoryStorageBackend : StorageBackend { return stores.computeIfAbsent(key) { ByteArrayStore() } } - override fun open( + override fun getStorageTable( namespace: String, name: String, ): Mono { @@ -29,20 +27,6 @@ class MemoryStorageBackend : StorageBackend { return Mono.just(MemoryStorageTable(store)) } - override fun open(uri: String): Mono { - val (ns, name) = DatastoreUri.parse(uri) - return open(ns, name) - } - - @Deprecated("Use open() instead", ReplaceWith("open(namespace, name)")) - override fun getTable( - namespace: String, - name: String, - ): Mono = Mono.error(UnsupportedOperationException("MemoryStorageBackend does not support HBaseTables. Use open() instead.")) - - @Deprecated("Use open() instead", ReplaceWith("open(uri)")) - override fun getTable(uri: String): Mono = Mono.error(UnsupportedOperationException("MemoryStorageBackend does not support HBaseTables. Use open() instead.")) - override fun close() { // nothing to close } diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBackendTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBackendTest.kt index 3f6f95ac..15c3514c 100644 --- a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBackendTest.kt +++ b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBackendTest.kt @@ -22,26 +22,26 @@ class MemoryStorageBackendTest { } @Nested - @DisplayName("open") - inner class OpenTest { + @DisplayName("getStorageTable") + inner class GetStorageTableTest { @Test fun `returns StorageTable with namespace and name`() { - val table = backend.open("test-ns", "test-table").block()!! + val table = backend.getStorageTable("test-ns", "test-table").block()!! assert(table != null) } @Test fun `returns StorageTable with uri`() { - val table = backend.open("datastore://test-ns/test-table").block()!! + val table = backend.getStorageTable("datastore://test-ns/test-table").block()!! assert(table != null) } @Test fun `different tables are isolated from each other`() { - val table1 = backend.open("ns1", "table1").block()!! - val table2 = backend.open("ns2", "table2").block()!! + val table1 = backend.getStorageTable("ns1", "table1").block()!! + val table2 = backend.getStorageTable("ns2", "table2").block()!! val key = "same-key".toByteArray() val value1 = "value-from-table1".toByteArray() val value2 = "value-from-table2".toByteArray() @@ -66,8 +66,8 @@ class MemoryStorageBackendTest { @Test fun `same namespace and name returns same store`() { - val table1 = backend.open("ns", "table").block()!! - val table2 = backend.open("ns", "table").block()!! + val table1 = backend.getStorageTable("ns", "table").block()!! + val table2 = backend.getStorageTable("ns", "table").block()!! val key = "test-key".toByteArray() val value = "test-value".toByteArray() From 10347a1ffa5f365ae2139cd9809ed641ae091c36 Mon Sep 17 00:00:00 2001 From: Minseok Kim Date: Mon, 9 Feb 2026 12:55:12 +0900 Subject: [PATCH 6/9] fix(engine): restrict SAFE_NAME_PATTERN to lowercase, digits, underscore Removes uppercase letters and hyphens from allowed characters in datastore URI namespace and table names. Co-Authored-By: Claude Opus 4.6 --- .../v2/engine/storage/DatastoreUri.kt | 6 ++--- .../v2/engine/storage/DatastoreUriTest.kt | 26 ++++++++++++++++--- .../storage/MemoryStorageBackendTest.kt | 14 +++++----- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/DatastoreUri.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/DatastoreUri.kt index b58b7a8f..d73a3dc0 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/DatastoreUri.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/DatastoreUri.kt @@ -7,7 +7,7 @@ package com.kakao.actionbase.v2.engine.storage */ object DatastoreUri { private const val PREFIX = "datastore://" - private val SAFE_NAME_PATTERN = Regex("^[a-zA-Z0-9_-]+$") + private val SAFE_NAME_PATTERN = Regex("^[a-z0-9_]+$") /** * Parses a datastore URI and returns namespace and table name. @@ -26,10 +26,10 @@ object DatastoreUri { } val (namespace, tableName) = parts[0] to parts[1] require(namespace.isEmpty() || namespace.matches(SAFE_NAME_PATTERN)) { - "Invalid namespace: $namespace. Must contain only alphanumeric, underscore, or hyphen." + "Invalid namespace: $namespace. Must contain only lowercase letters, digits, or underscore." } require(tableName.matches(SAFE_NAME_PATTERN)) { - "Invalid table name: $tableName. Must contain only alphanumeric, underscore, or hyphen." + "Invalid table name: $tableName. Must contain only lowercase letters, digits, or underscore." } return namespace to tableName } diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/DatastoreUriTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/DatastoreUriTest.kt index aaf76509..0e3a307d 100644 --- a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/DatastoreUriTest.kt +++ b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/DatastoreUriTest.kt @@ -89,11 +89,29 @@ class DatastoreUriTest { } @Test - fun `accepts hyphen and underscore in names`() { - val (namespace, tableName) = DatastoreUri.parse("datastore://my-namespace_1/my_table-2") + fun `accepts underscore and digits in names`() { + val (namespace, tableName) = DatastoreUri.parse("datastore://my_namespace_1/my_table_2") - assertEquals("my-namespace_1", namespace) - assertEquals("my_table-2", tableName) + assertEquals("my_namespace_1", namespace) + assertEquals("my_table_2", tableName) + } + + @Test + fun `throws for uppercase characters`() { + assertThrows { + DatastoreUri.parse("datastore://MyNamespace/table") + }.also { + assert(it.message!!.contains("Invalid namespace")) + } + } + + @Test + fun `throws for hyphen in name`() { + assertThrows { + DatastoreUri.parse("datastore://namespace/my-table") + }.also { + assert(it.message!!.contains("Invalid table name")) + } } } } diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBackendTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBackendTest.kt index 15c3514c..22ae33de 100644 --- a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBackendTest.kt +++ b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBackendTest.kt @@ -26,14 +26,14 @@ class MemoryStorageBackendTest { inner class GetStorageTableTest { @Test fun `returns StorageTable with namespace and name`() { - val table = backend.getStorageTable("test-ns", "test-table").block()!! + val table = backend.getStorageTable("test_ns", "test_table").block()!! assert(table != null) } @Test fun `returns StorageTable with uri`() { - val table = backend.getStorageTable("datastore://test-ns/test-table").block()!! + val table = backend.getStorageTable("datastore://test_ns/test_table").block()!! assert(table != null) } @@ -42,9 +42,9 @@ class MemoryStorageBackendTest { fun `different tables are isolated from each other`() { val table1 = backend.getStorageTable("ns1", "table1").block()!! val table2 = backend.getStorageTable("ns2", "table2").block()!! - val key = "same-key".toByteArray() - val value1 = "value-from-table1".toByteArray() - val value2 = "value-from-table2".toByteArray() + val key = "same_key".toByteArray() + val value1 = "value_from_table1".toByteArray() + val value2 = "value_from_table2".toByteArray() table1.put(key, value1).block() table2.put(key, value2).block() @@ -68,8 +68,8 @@ class MemoryStorageBackendTest { fun `same namespace and name returns same store`() { val table1 = backend.getStorageTable("ns", "table").block()!! val table2 = backend.getStorageTable("ns", "table").block()!! - val key = "test-key".toByteArray() - val value = "test-value".toByteArray() + val key = "test_key".toByteArray() + val value = "test_value".toByteArray() table1.put(key, value).block() From 20e43837c11fc4b5b9a09b46cf60dffa9afc4038 Mon Sep 17 00:00:00 2001 From: Minseok Kim Date: Mon, 9 Feb 2026 12:58:06 +0900 Subject: [PATCH 7/9] refactor(engine): rename open() to getStorageTable() in HBase backends Align HBase implementations with updated StorageBackend interface. Remove deprecated getTable() methods and URI overloads (now default). Co-Authored-By: Claude Opus 4.6 --- .../storage/hbase/HBaseStorageBackend.kt | 25 +--------------- .../storage/hbase/MockHBaseStorageBackend.kt | 23 +-------------- .../test/hbase/HBaseTestingStorageBackend.kt | 29 +------------------ 3 files changed, 3 insertions(+), 74 deletions(-) diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageBackend.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageBackend.kt index f6f7e2c0..0c097398 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageBackend.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageBackend.kt @@ -1,6 +1,5 @@ package com.kakao.actionbase.v2.engine.storage.hbase -import com.kakao.actionbase.v2.engine.storage.DatastoreUri import com.kakao.actionbase.v2.engine.storage.StorageBackend import com.kakao.actionbase.v2.engine.storage.StorageTable @@ -28,7 +27,7 @@ class HBaseStorageBackend private constructor( */ fun getAdminMono(): Mono = connectionMono.map { it.admin }.cache() - override fun open( + override fun getStorageTable( namespace: String, name: String, ): Mono = @@ -38,28 +37,6 @@ class HBaseStorageBackend private constructor( HBaseStorageTable(hbaseTable) } - override fun open(uri: String): Mono { - val (ns, name) = DatastoreUri.parse(uri) - return open(ns, name) - } - - @Deprecated("Use open() instead", ReplaceWith("open(namespace, name)")) - override fun getTable( - namespace: String, - name: String, - ): Mono = - connectionMono.map { conn -> - val table = conn.getTable(TableName.valueOf(namespace, name)) - val hbaseTable = HBaseTable.create(table) - HBaseTables(hbaseTable, hbaseTable) - } - - @Deprecated("Use open() instead", ReplaceWith("open(uri)")) - override fun getTable(uri: String): Mono { - val (ns, name) = DatastoreUri.parse(uri) - return getTable(ns, name) - } - override fun close() { connectionMono.block()?.close() } diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/MockHBaseStorageBackend.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/MockHBaseStorageBackend.kt index 317f44e6..b5590a8e 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/MockHBaseStorageBackend.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/MockHBaseStorageBackend.kt @@ -1,6 +1,5 @@ package com.kakao.actionbase.v2.engine.storage.hbase -import com.kakao.actionbase.v2.engine.storage.DatastoreUri import com.kakao.actionbase.v2.engine.storage.StorageBackend import com.kakao.actionbase.v2.engine.storage.StorageTable import com.kakao.actionbase.v2.engine.storage.hbase.impl.NewMockTable @@ -17,7 +16,7 @@ import reactor.core.publisher.Mono * Each namespace + name combination gets its own isolated table. */ class MockHBaseStorageBackend : StorageBackend { - override fun open( + override fun getStorageTable( namespace: String, name: String, ): Mono { @@ -25,26 +24,6 @@ class MockHBaseStorageBackend : StorageBackend { return Mono.just(HBaseStorageTable(hbaseTable)) } - override fun open(uri: String): Mono { - val (ns, name) = DatastoreUri.parse(uri) - return open(ns, name) - } - - @Deprecated("Use open() instead", ReplaceWith("open(namespace, name)")) - override fun getTable( - namespace: String, - name: String, - ): Mono { - val hbaseTable = createMockHBaseTable(namespace, name) - return Mono.just(HBaseTables(hbaseTable, hbaseTable)) - } - - @Deprecated("Use open() instead", ReplaceWith("open(uri)")) - override fun getTable(uri: String): Mono { - val (ns, name) = DatastoreUri.parse(uri) - return getTable(ns, name) - } - override fun close() { // nothing to close } diff --git a/engine/src/testFixtures/kotlin/com/kakao/actionbase/test/hbase/HBaseTestingStorageBackend.kt b/engine/src/testFixtures/kotlin/com/kakao/actionbase/test/hbase/HBaseTestingStorageBackend.kt index 5107950e..03130b3f 100644 --- a/engine/src/testFixtures/kotlin/com/kakao/actionbase/test/hbase/HBaseTestingStorageBackend.kt +++ b/engine/src/testFixtures/kotlin/com/kakao/actionbase/test/hbase/HBaseTestingStorageBackend.kt @@ -1,11 +1,9 @@ package com.kakao.actionbase.test.hbase -import com.kakao.actionbase.v2.engine.storage.DatastoreUri import com.kakao.actionbase.v2.engine.storage.StorageBackend import com.kakao.actionbase.v2.engine.storage.StorageTable import com.kakao.actionbase.v2.engine.storage.hbase.HBaseStorageTable import com.kakao.actionbase.v2.engine.storage.hbase.HBaseTable -import com.kakao.actionbase.v2.engine.storage.hbase.HBaseTables import org.apache.hadoop.hbase.TableName import org.apache.hadoop.hbase.client.AsyncConnection @@ -20,7 +18,7 @@ class HBaseTestingStorageBackend( private val connectionMono: Mono, private val defaultNamespace: String, ) : StorageBackend { - override fun open( + override fun getStorageTable( namespace: String, name: String, ): Mono { @@ -33,31 +31,6 @@ class HBaseTestingStorageBackend( } } - override fun open(uri: String): Mono { - val (ns, name) = DatastoreUri.parse(uri) - return open(ns, name) - } - - @Deprecated("Use open() instead", ReplaceWith("open(namespace, name)")) - override fun getTable( - namespace: String, - name: String, - ): Mono { - val effectiveNs = namespace.ifEmpty { defaultNamespace } - return connectionMono.map { conn -> - val tableName = TableName.valueOf(effectiveNs, name) - val asyncTable = conn.getTable(tableName) - val hbaseTable = HBaseTable.create(asyncTable) - HBaseTables(hbaseTable, hbaseTable) - } - } - - @Deprecated("Use open() instead", ReplaceWith("open(uri)")) - override fun getTable(uri: String): Mono { - val (ns, name) = DatastoreUri.parse(uri) - return getTable(ns, name) - } - override fun close() { // Connection is managed by HBaseTestingCluster } From 41c530a2e1102ad3d93c6bfa0387bdb42ee866e1 Mon Sep 17 00:00:00 2001 From: Minseok Kim Date: Mon, 9 Feb 2026 13:09:10 +0900 Subject: [PATCH 8/9] refactor(engine): move to engine.storage package, convert tests to @ObjectSource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename package v2.engine.storage → engine.storage for new files - Convert DatastoreUriTest to @ObjectSource (valid/invalid URI cases) - Convert MemoryStorageBackendTest to @ObjectSource (getStorageTable cases) Co-Authored-By: Claude Opus 4.6 --- .../{v2 => }/engine/storage/DatastoreUri.kt | 2 +- .../{v2 => }/engine/storage/StorageBackend.kt | 2 +- .../{v2 => }/engine/storage/StorageTable.kt | 2 +- .../storage/memory/MemoryStorageBackend.kt | 6 +- .../storage/memory/MemoryStorageTable.kt | 4 +- .../engine/storage/DatastoreUriTest.kt | 86 +++++++++++++ .../storage/MemoryStorageBackendTest.kt | 95 ++++++++++++++ .../MemoryStorageTableCompatibilityTest.kt | 4 +- .../storage/StorageTableCompatibilityTest.kt | 2 +- .../v2/engine/storage/DatastoreUriTest.kt | 117 ------------------ .../storage/MemoryStorageBackendTest.kt | 95 -------------- 11 files changed, 192 insertions(+), 223 deletions(-) rename engine/src/main/kotlin/com/kakao/actionbase/{v2 => }/engine/storage/DatastoreUri.kt (96%) rename engine/src/main/kotlin/com/kakao/actionbase/{v2 => }/engine/storage/StorageBackend.kt (87%) rename engine/src/main/kotlin/com/kakao/actionbase/{v2 => }/engine/storage/StorageTable.kt (95%) rename engine/src/main/kotlin/com/kakao/actionbase/{v2 => }/engine/storage/memory/MemoryStorageBackend.kt (81%) rename engine/src/main/kotlin/com/kakao/actionbase/{v2 => }/engine/storage/memory/MemoryStorageTable.kt (96%) create mode 100644 engine/src/test/kotlin/com/kakao/actionbase/engine/storage/DatastoreUriTest.kt create mode 100644 engine/src/test/kotlin/com/kakao/actionbase/engine/storage/MemoryStorageBackendTest.kt rename engine/src/test/kotlin/com/kakao/actionbase/{v2 => }/engine/storage/MemoryStorageTableCompatibilityTest.kt (71%) rename engine/src/test/kotlin/com/kakao/actionbase/{v2 => }/engine/storage/StorageTableCompatibilityTest.kt (99%) delete mode 100644 engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/DatastoreUriTest.kt delete mode 100644 engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBackendTest.kt diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/DatastoreUri.kt b/engine/src/main/kotlin/com/kakao/actionbase/engine/storage/DatastoreUri.kt similarity index 96% rename from engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/DatastoreUri.kt rename to engine/src/main/kotlin/com/kakao/actionbase/engine/storage/DatastoreUri.kt index d73a3dc0..8bed3c36 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/DatastoreUri.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/engine/storage/DatastoreUri.kt @@ -1,4 +1,4 @@ -package com.kakao.actionbase.v2.engine.storage +package com.kakao.actionbase.engine.storage /** * Utility for parsing datastore URIs. diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBackend.kt b/engine/src/main/kotlin/com/kakao/actionbase/engine/storage/StorageBackend.kt similarity index 87% rename from engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBackend.kt rename to engine/src/main/kotlin/com/kakao/actionbase/engine/storage/StorageBackend.kt index 0b106578..4aee0d01 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageBackend.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/engine/storage/StorageBackend.kt @@ -1,4 +1,4 @@ -package com.kakao.actionbase.v2.engine.storage +package com.kakao.actionbase.engine.storage import reactor.core.publisher.Mono diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageTable.kt b/engine/src/main/kotlin/com/kakao/actionbase/engine/storage/StorageTable.kt similarity index 95% rename from engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageTable.kt rename to engine/src/main/kotlin/com/kakao/actionbase/engine/storage/StorageTable.kt index 96daf2ea..b1e00f40 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/StorageTable.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/engine/storage/StorageTable.kt @@ -1,4 +1,4 @@ -package com.kakao.actionbase.v2.engine.storage +package com.kakao.actionbase.engine.storage import com.kakao.actionbase.core.storage.HBaseRecord import com.kakao.actionbase.core.storage.MutationRequest diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageBackend.kt b/engine/src/main/kotlin/com/kakao/actionbase/engine/storage/memory/MemoryStorageBackend.kt similarity index 81% rename from engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageBackend.kt rename to engine/src/main/kotlin/com/kakao/actionbase/engine/storage/memory/MemoryStorageBackend.kt index 7b831440..61769f30 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageBackend.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/engine/storage/memory/MemoryStorageBackend.kt @@ -1,8 +1,8 @@ -package com.kakao.actionbase.v2.engine.storage.memory +package com.kakao.actionbase.engine.storage.memory import com.kakao.actionbase.engine.datastore.impl.ByteArrayStore -import com.kakao.actionbase.v2.engine.storage.StorageBackend -import com.kakao.actionbase.v2.engine.storage.StorageTable +import com.kakao.actionbase.engine.storage.StorageBackend +import com.kakao.actionbase.engine.storage.StorageTable import java.util.concurrent.ConcurrentHashMap diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageTable.kt b/engine/src/main/kotlin/com/kakao/actionbase/engine/storage/memory/MemoryStorageTable.kt similarity index 96% rename from engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageTable.kt rename to engine/src/main/kotlin/com/kakao/actionbase/engine/storage/memory/MemoryStorageTable.kt index 62afafc6..0e812ec9 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/memory/MemoryStorageTable.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/engine/storage/memory/MemoryStorageTable.kt @@ -1,9 +1,9 @@ -package com.kakao.actionbase.v2.engine.storage.memory +package com.kakao.actionbase.engine.storage.memory import com.kakao.actionbase.core.storage.HBaseRecord import com.kakao.actionbase.core.storage.MutationRequest import com.kakao.actionbase.engine.datastore.impl.ByteArrayStore -import com.kakao.actionbase.v2.engine.storage.StorageTable +import com.kakao.actionbase.engine.storage.StorageTable import reactor.core.publisher.Mono diff --git a/engine/src/test/kotlin/com/kakao/actionbase/engine/storage/DatastoreUriTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/engine/storage/DatastoreUriTest.kt new file mode 100644 index 00000000..960227e3 --- /dev/null +++ b/engine/src/test/kotlin/com/kakao/actionbase/engine/storage/DatastoreUriTest.kt @@ -0,0 +1,86 @@ +package com.kakao.actionbase.engine.storage + +import com.kakao.actionbase.test.documentations.params.ObjectSource +import com.kakao.actionbase.test.documentations.params.ObjectSourceParameterizedTest + +import kotlin.test.assertEquals + +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.assertThrows + +class DatastoreUriTest { + @Nested + @DisplayName("parse") + inner class ParseTest { + @ObjectSourceParameterizedTest + @ObjectSource( + """ + - uri: datastore://my_namespace/my_table + namespace: my_namespace + table: my_table + - uri: datastore:///my_table + namespace: "" + table: my_table + - uri: datastore://my_namespace_1/my_table_2 + namespace: my_namespace_1 + table: my_table_2 + - uri: datastore://ns/t + namespace: ns + table: t + """, + ) + fun `valid URI`( + uri: String, + namespace: String, + table: String, + ) { + val (ns, tbl) = DatastoreUri.parse(uri) + assertEquals(namespace, ns) + assertEquals(table, tbl) + } + + @ObjectSourceParameterizedTest + @ObjectSource( + """ + # missing or wrong prefix + - uri: "" + error: Must start with + - uri: invalid://ns/table + error: Must start with + - uri: ns/table + error: Must start with + + # wrong number of path segments + - uri: datastore://ns + error: Expected format + - uri: datastore://ns/table/extra + error: Expected format + + # invalid characters + - uri: datastore://name space/table + error: Invalid namespace + - uri: datastore://ns/table;drop + error: Invalid table name + + # uppercase not allowed + - uri: datastore://MyNamespace/table + error: Invalid namespace + + # hyphen not allowed + - uri: datastore://ns/my-table + error: Invalid table name + """, + ) + fun `invalid URI`( + uri: String, + error: String, + ) { + assertThrows { + DatastoreUri.parse(uri) + }.also { + assert(it.message!!.contains(error)) + } + } + } +} diff --git a/engine/src/test/kotlin/com/kakao/actionbase/engine/storage/MemoryStorageBackendTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/engine/storage/MemoryStorageBackendTest.kt new file mode 100644 index 00000000..53db5f3c --- /dev/null +++ b/engine/src/test/kotlin/com/kakao/actionbase/engine/storage/MemoryStorageBackendTest.kt @@ -0,0 +1,95 @@ +package com.kakao.actionbase.engine.storage + +import com.kakao.actionbase.engine.storage.memory.MemoryStorageBackend +import com.kakao.actionbase.test.documentations.params.ObjectSource +import com.kakao.actionbase.test.documentations.params.ObjectSourceParameterizedTest + +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class MemoryStorageBackendTest { + private lateinit var backend: MemoryStorageBackend + + @BeforeEach + fun setUp() { + backend = MemoryStorageBackend() + } + + @AfterEach + fun tearDown() { + backend.close() + } + + @Nested + @DisplayName("getStorageTable") + inner class GetStorageTableTest { + @ObjectSourceParameterizedTest + @ObjectSource( + """ + - namespace: test_ns + name: test_table + - namespace: ns1 + name: table1 + - namespace: "" + name: edges + """, + ) + fun `returns StorageTable`( + namespace: String, + name: String, + ) { + val table = backend.getStorageTable(namespace, name).block()!! + assert(table != null) + } + + @ObjectSourceParameterizedTest + @ObjectSource( + """ + - uri: datastore://test_ns/test_table + - uri: datastore://ns1/table1 + """, + ) + fun `returns StorageTable with uri`(uri: String) { + val table = backend.getStorageTable(uri).block()!! + assert(table != null) + } + + @Test + fun `different tables are isolated from each other`() { + val table1 = backend.getStorageTable("ns1", "table1").block()!! + val table2 = backend.getStorageTable("ns2", "table2").block()!! + val key = "same_key".toByteArray() + + table1.put(key, "v1".toByteArray()).block() + table2.put(key, "v2".toByteArray()).block() + + assert(String(table1.get(key).block()!!) == "v1") { "table1 should have v1" } + assert(String(table2.get(key).block()!!) == "v2") { "table2 should have v2" } + } + + @Test + fun `same namespace and name returns same store`() { + val table1 = backend.getStorageTable("ns", "table").block()!! + val table2 = backend.getStorageTable("ns", "table").block()!! + + table1.put("key".toByteArray(), "value".toByteArray()).block() + + assert(String(table2.get("key".toByteArray()).block()!!) == "value") { + "same namespace+name should share store" + } + } + } + + @Nested + @DisplayName("close") + inner class CloseTest { + @Test + fun `close is idempotent`() { + backend.close() + backend.close() // Should not throw + } + } +} diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageTableCompatibilityTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/engine/storage/MemoryStorageTableCompatibilityTest.kt similarity index 71% rename from engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageTableCompatibilityTest.kt rename to engine/src/test/kotlin/com/kakao/actionbase/engine/storage/MemoryStorageTableCompatibilityTest.kt index a29f4341..af86ed46 100644 --- a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageTableCompatibilityTest.kt +++ b/engine/src/test/kotlin/com/kakao/actionbase/engine/storage/MemoryStorageTableCompatibilityTest.kt @@ -1,7 +1,7 @@ -package com.kakao.actionbase.v2.engine.storage +package com.kakao.actionbase.engine.storage import com.kakao.actionbase.engine.datastore.impl.ByteArrayStore -import com.kakao.actionbase.v2.engine.storage.memory.MemoryStorageTable +import com.kakao.actionbase.engine.storage.memory.MemoryStorageTable /** Memory (ByteArrayStore) compatibility test for StorageTable. */ class MemoryStorageTableCompatibilityTest : StorageTableCompatibilityTest() { diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/StorageTableCompatibilityTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/engine/storage/StorageTableCompatibilityTest.kt similarity index 99% rename from engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/StorageTableCompatibilityTest.kt rename to engine/src/test/kotlin/com/kakao/actionbase/engine/storage/StorageTableCompatibilityTest.kt index 504b8627..24feea55 100644 --- a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/StorageTableCompatibilityTest.kt +++ b/engine/src/test/kotlin/com/kakao/actionbase/engine/storage/StorageTableCompatibilityTest.kt @@ -1,4 +1,4 @@ -package com.kakao.actionbase.v2.engine.storage +package com.kakao.actionbase.engine.storage import com.kakao.actionbase.core.storage.MutationRequest diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/DatastoreUriTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/DatastoreUriTest.kt deleted file mode 100644 index 0e3a307d..00000000 --- a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/DatastoreUriTest.kt +++ /dev/null @@ -1,117 +0,0 @@ -package com.kakao.actionbase.v2.engine.storage - -import kotlin.test.assertEquals - -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows - -class DatastoreUriTest { - @Nested - @DisplayName("parse") - inner class ParseTest { - @Test - fun `parses valid URI`() { - val (namespace, tableName) = DatastoreUri.parse("datastore://my_namespace/my_table") - - assertEquals("my_namespace", namespace) - assertEquals("my_table", tableName) - } - - @Test - fun `parses URI with empty namespace`() { - val (namespace, tableName) = DatastoreUri.parse("datastore:///my_table") - - assertEquals("", namespace) - assertEquals("my_table", tableName) - } - - @Test - fun `throws for invalid prefix`() { - assertThrows { - DatastoreUri.parse("invalid://namespace/table") - }.also { - assert(it.message!!.contains("Must start with")) - } - } - - @Test - fun `throws for missing prefix`() { - assertThrows { - DatastoreUri.parse("namespace/table") - }.also { - assert(it.message!!.contains("Must start with")) - } - } - - @Test - fun `throws for missing table name`() { - assertThrows { - DatastoreUri.parse("datastore://namespace") - }.also { - assert(it.message!!.contains("Expected format")) - } - } - - @Test - fun `throws for too many path segments`() { - assertThrows { - DatastoreUri.parse("datastore://namespace/table/extra") - }.also { - assert(it.message!!.contains("Expected format")) - } - } - - @Test - fun `throws for empty URI`() { - assertThrows { - DatastoreUri.parse("") - } - } - - @Test - fun `throws for invalid namespace characters`() { - assertThrows { - DatastoreUri.parse("datastore://name space/table") - }.also { - assert(it.message!!.contains("Invalid namespace")) - } - } - - @Test - fun `throws for invalid table name characters`() { - assertThrows { - DatastoreUri.parse("datastore://namespace/table;drop") - }.also { - assert(it.message!!.contains("Invalid table name")) - } - } - - @Test - fun `accepts underscore and digits in names`() { - val (namespace, tableName) = DatastoreUri.parse("datastore://my_namespace_1/my_table_2") - - assertEquals("my_namespace_1", namespace) - assertEquals("my_table_2", tableName) - } - - @Test - fun `throws for uppercase characters`() { - assertThrows { - DatastoreUri.parse("datastore://MyNamespace/table") - }.also { - assert(it.message!!.contains("Invalid namespace")) - } - } - - @Test - fun `throws for hyphen in name`() { - assertThrows { - DatastoreUri.parse("datastore://namespace/my-table") - }.also { - assert(it.message!!.contains("Invalid table name")) - } - } - } -} diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBackendTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBackendTest.kt deleted file mode 100644 index 22ae33de..00000000 --- a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/MemoryStorageBackendTest.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.kakao.actionbase.v2.engine.storage - -import com.kakao.actionbase.v2.engine.storage.memory.MemoryStorageBackend - -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test - -class MemoryStorageBackendTest { - private lateinit var backend: MemoryStorageBackend - - @BeforeEach - fun setUp() { - backend = MemoryStorageBackend() - } - - @AfterEach - fun tearDown() { - backend.close() - } - - @Nested - @DisplayName("getStorageTable") - inner class GetStorageTableTest { - @Test - fun `returns StorageTable with namespace and name`() { - val table = backend.getStorageTable("test_ns", "test_table").block()!! - - assert(table != null) - } - - @Test - fun `returns StorageTable with uri`() { - val table = backend.getStorageTable("datastore://test_ns/test_table").block()!! - - assert(table != null) - } - - @Test - fun `different tables are isolated from each other`() { - val table1 = backend.getStorageTable("ns1", "table1").block()!! - val table2 = backend.getStorageTable("ns2", "table2").block()!! - val key = "same_key".toByteArray() - val value1 = "value_from_table1".toByteArray() - val value2 = "value_from_table2".toByteArray() - - table1.put(key, value1).block() - table2.put(key, value2).block() - - // Each table should have its own value for the same key - assert( - table1 - .get(key) - .block() - ?.contentEquals(value1) == true, - ) { "table1 should have value1" } - assert( - table2 - .get(key) - .block() - ?.contentEquals(value2) == true, - ) { "table2 should have value2" } - } - - @Test - fun `same namespace and name returns same store`() { - val table1 = backend.getStorageTable("ns", "table").block()!! - val table2 = backend.getStorageTable("ns", "table").block()!! - val key = "test_key".toByteArray() - val value = "test_value".toByteArray() - - table1.put(key, value).block() - - // Second open with same namespace/name should see the data - assert( - table2 - .get(key) - .block() - ?.contentEquals(value) == true, - ) { "same namespace+name should share store" } - } - } - - @Nested - @DisplayName("close") - inner class CloseTest { - @Test - fun `close is idempotent`() { - backend.close() - backend.close() // Should not throw - } - } -} From 6d8b1e08267ea7884f60fa3830c06c586f3776b4 Mon Sep 17 00:00:00 2001 From: Minseok Kim Date: Mon, 9 Feb 2026 13:13:15 +0900 Subject: [PATCH 9/9] refactor(engine): move to engine.storage package, convert tests to @ObjectSource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename package v2.engine.storage → engine.storage for new files - Convert HBaseStorageBackendTest to @ObjectSource (validation cases) - Update all imports for package move Co-Authored-By: Claude Opus 4.6 --- .../storage/DefaultStorageBackendFactory.kt | 8 +- .../storage/hbase/HBaseStorageBackend.kt | 7 +- .../engine/storage/hbase/HBaseStorageTable.kt | 5 +- .../storage/hbase/MockHBaseStorageBackend.kt | 8 +- .../DefaultStorageBackendFactoryTest.kt | 2 +- .../engine/storage/HBaseStorageBackendTest.kt | 73 +++++++++++++++++++ .../HBaseStorageTableCompatibilityTest.kt | 4 +- .../engine/storage/HBaseStorageBackendTest.kt | 72 ------------------ .../hbase/HBaseTestingClusterExtension.kt | 2 +- .../test/hbase/HBaseTestingStorageBackend.kt | 6 +- 10 files changed, 96 insertions(+), 91 deletions(-) rename engine/src/main/kotlin/com/kakao/actionbase/{v2 => }/engine/storage/DefaultStorageBackendFactory.kt (92%) rename engine/src/main/kotlin/com/kakao/actionbase/{v2 => }/engine/storage/hbase/HBaseStorageBackend.kt (97%) rename engine/src/main/kotlin/com/kakao/actionbase/{v2 => }/engine/storage/hbase/HBaseStorageTable.kt (96%) rename engine/src/main/kotlin/com/kakao/actionbase/{v2 => }/engine/storage/hbase/MockHBaseStorageBackend.kt (80%) rename engine/src/test/kotlin/com/kakao/actionbase/{v2 => }/engine/storage/DefaultStorageBackendFactoryTest.kt (96%) create mode 100644 engine/src/test/kotlin/com/kakao/actionbase/engine/storage/HBaseStorageBackendTest.kt rename engine/src/test/kotlin/com/kakao/actionbase/{v2 => }/engine/storage/HBaseStorageTableCompatibilityTest.kt (96%) delete mode 100644 engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/HBaseStorageBackendTest.kt diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/DefaultStorageBackendFactory.kt b/engine/src/main/kotlin/com/kakao/actionbase/engine/storage/DefaultStorageBackendFactory.kt similarity index 92% rename from engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/DefaultStorageBackendFactory.kt rename to engine/src/main/kotlin/com/kakao/actionbase/engine/storage/DefaultStorageBackendFactory.kt index 9ea0ac9e..39fcc2f9 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/DefaultStorageBackendFactory.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/engine/storage/DefaultStorageBackendFactory.kt @@ -1,8 +1,8 @@ -package com.kakao.actionbase.v2.engine.storage +package com.kakao.actionbase.engine.storage -import com.kakao.actionbase.v2.engine.storage.hbase.HBaseStorageBackend -import com.kakao.actionbase.v2.engine.storage.hbase.MockHBaseStorageBackend -import com.kakao.actionbase.v2.engine.storage.memory.MemoryStorageBackend +import com.kakao.actionbase.engine.storage.hbase.HBaseStorageBackend +import com.kakao.actionbase.engine.storage.hbase.MockHBaseStorageBackend +import com.kakao.actionbase.engine.storage.memory.MemoryStorageBackend import org.slf4j.LoggerFactory diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageBackend.kt b/engine/src/main/kotlin/com/kakao/actionbase/engine/storage/hbase/HBaseStorageBackend.kt similarity index 97% rename from engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageBackend.kt rename to engine/src/main/kotlin/com/kakao/actionbase/engine/storage/hbase/HBaseStorageBackend.kt index 0c097398..3efdfb81 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageBackend.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/engine/storage/hbase/HBaseStorageBackend.kt @@ -1,7 +1,8 @@ -package com.kakao.actionbase.v2.engine.storage.hbase +package com.kakao.actionbase.engine.storage.hbase -import com.kakao.actionbase.v2.engine.storage.StorageBackend -import com.kakao.actionbase.v2.engine.storage.StorageTable +import com.kakao.actionbase.engine.storage.StorageBackend +import com.kakao.actionbase.engine.storage.StorageTable +import com.kakao.actionbase.v2.engine.storage.hbase.HBaseTable import org.apache.hadoop.conf.Configuration import org.apache.hadoop.hbase.HBaseConfiguration diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageTable.kt b/engine/src/main/kotlin/com/kakao/actionbase/engine/storage/hbase/HBaseStorageTable.kt similarity index 96% rename from engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageTable.kt rename to engine/src/main/kotlin/com/kakao/actionbase/engine/storage/hbase/HBaseStorageTable.kt index 28531965..d166e8e3 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/HBaseStorageTable.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/engine/storage/hbase/HBaseStorageTable.kt @@ -1,9 +1,10 @@ -package com.kakao.actionbase.v2.engine.storage.hbase +package com.kakao.actionbase.engine.storage.hbase import com.kakao.actionbase.core.Constants import com.kakao.actionbase.core.storage.HBaseRecord import com.kakao.actionbase.core.storage.MutationRequest -import com.kakao.actionbase.v2.engine.storage.StorageTable +import com.kakao.actionbase.engine.storage.StorageTable +import com.kakao.actionbase.v2.engine.storage.hbase.HBaseTable import org.apache.hadoop.hbase.client.CheckAndMutate import org.apache.hadoop.hbase.client.Delete diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/MockHBaseStorageBackend.kt b/engine/src/main/kotlin/com/kakao/actionbase/engine/storage/hbase/MockHBaseStorageBackend.kt similarity index 80% rename from engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/MockHBaseStorageBackend.kt rename to engine/src/main/kotlin/com/kakao/actionbase/engine/storage/hbase/MockHBaseStorageBackend.kt index b5590a8e..d0168823 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/storage/hbase/MockHBaseStorageBackend.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/engine/storage/hbase/MockHBaseStorageBackend.kt @@ -1,7 +1,9 @@ -package com.kakao.actionbase.v2.engine.storage.hbase +package com.kakao.actionbase.engine.storage.hbase -import com.kakao.actionbase.v2.engine.storage.StorageBackend -import com.kakao.actionbase.v2.engine.storage.StorageTable +import com.kakao.actionbase.engine.storage.StorageBackend +import com.kakao.actionbase.engine.storage.StorageTable +import com.kakao.actionbase.v2.engine.storage.hbase.HBaseConnections +import com.kakao.actionbase.v2.engine.storage.hbase.HBaseTable import com.kakao.actionbase.v2.engine.storage.hbase.impl.NewMockTable import org.apache.hadoop.hbase.TableName diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/DefaultStorageBackendFactoryTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/engine/storage/DefaultStorageBackendFactoryTest.kt similarity index 96% rename from engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/DefaultStorageBackendFactoryTest.kt rename to engine/src/test/kotlin/com/kakao/actionbase/engine/storage/DefaultStorageBackendFactoryTest.kt index 72a63a9e..fca43544 100644 --- a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/DefaultStorageBackendFactoryTest.kt +++ b/engine/src/test/kotlin/com/kakao/actionbase/engine/storage/DefaultStorageBackendFactoryTest.kt @@ -1,4 +1,4 @@ -package com.kakao.actionbase.v2.engine.storage +package com.kakao.actionbase.engine.storage import com.kakao.actionbase.test.hbase.HBaseTestingClusterExtension diff --git a/engine/src/test/kotlin/com/kakao/actionbase/engine/storage/HBaseStorageBackendTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/engine/storage/HBaseStorageBackendTest.kt new file mode 100644 index 00000000..40ea6fa3 --- /dev/null +++ b/engine/src/test/kotlin/com/kakao/actionbase/engine/storage/HBaseStorageBackendTest.kt @@ -0,0 +1,73 @@ +package com.kakao.actionbase.engine.storage + +import com.kakao.actionbase.engine.storage.hbase.HBaseStorageBackend +import com.kakao.actionbase.test.documentations.params.ObjectSource +import com.kakao.actionbase.test.documentations.params.ObjectSourceParameterizedTest + +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.assertThrows + +class HBaseStorageBackendTest { + @Nested + @DisplayName("create") + inner class CreateTest { + @ObjectSourceParameterizedTest + @ObjectSource( + """ + # namespace missing + - version: "2.4" + quorum: localhost:2181 + error: IllegalArgumentException + + # unsupported version + - namespace: test + version: "3.0" + error: IllegalArgumentException + + # zookeeper quorum missing for 2.4 + - namespace: test + version: "2.4" + error: IllegalStateException + + # bootstrap servers missing for 2.5 + - namespace: test + version: "2.5" + error: IllegalStateException + + # kerberos config incomplete + - namespace: test + version: "2.4" + quorum: localhost:2181 + secure: "true" + error: IllegalStateException + """, + ) + fun `invalid properties`( + namespace: String?, + version: String?, + quorum: String?, + secure: String?, + error: String, + ) { + val props = + buildMap { + namespace?.let { put("namespace", it) } + version?.let { put("version", it) } + quorum?.let { put("hbase.zookeeper.quorum", it) } + secure?.let { put("secure", it) } + } + + when (error) { + "IllegalArgumentException" -> + assertThrows { + HBaseStorageBackend.create(props) + } + "IllegalStateException" -> + assertThrows { + HBaseStorageBackend.create(props) + } + } + } + } +} diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/HBaseStorageTableCompatibilityTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/engine/storage/HBaseStorageTableCompatibilityTest.kt similarity index 96% rename from engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/HBaseStorageTableCompatibilityTest.kt rename to engine/src/test/kotlin/com/kakao/actionbase/engine/storage/HBaseStorageTableCompatibilityTest.kt index 3fecc95a..1144f1db 100644 --- a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/HBaseStorageTableCompatibilityTest.kt +++ b/engine/src/test/kotlin/com/kakao/actionbase/engine/storage/HBaseStorageTableCompatibilityTest.kt @@ -1,8 +1,8 @@ -package com.kakao.actionbase.v2.engine.storage +package com.kakao.actionbase.engine.storage +import com.kakao.actionbase.engine.storage.hbase.HBaseStorageTable import com.kakao.actionbase.test.hbase.HBaseTestingCluster import com.kakao.actionbase.v2.engine.storage.hbase.HBaseConnections -import com.kakao.actionbase.v2.engine.storage.hbase.HBaseStorageTable import com.kakao.actionbase.v2.engine.storage.hbase.HBaseTable import com.kakao.actionbase.v2.engine.storage.hbase.impl.HBaseSyncTable import com.kakao.actionbase.v2.engine.storage.hbase.impl.NewMockTable diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/HBaseStorageBackendTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/HBaseStorageBackendTest.kt deleted file mode 100644 index 135aee95..00000000 --- a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/storage/HBaseStorageBackendTest.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.kakao.actionbase.v2.engine.storage - -import com.kakao.actionbase.v2.engine.storage.hbase.HBaseStorageBackend - -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows - -class HBaseStorageBackendTest { - @Nested - @DisplayName("create") - inner class CreateTest { - @Test - fun `throws when namespace is missing`() { - val props = mapOf("version" to "2.4", "hbase.zookeeper.quorum" to "localhost:2181") - - assertThrows { - HBaseStorageBackend.create(props) - } - } - - @Test - fun `throws when version is unsupported`() { - val props = mapOf("namespace" to "test", "version" to "3.0") - - assertThrows { - HBaseStorageBackend.create(props) - } - } - - @Test - fun `throws when zookeeper quorum is missing for 2_4`() { - val props = mapOf("namespace" to "test", "version" to "2.4") - - assertThrows { - HBaseStorageBackend.create(props) - } - } - - @Test - fun `throws when bootstrap servers is missing for 2_5`() { - val props = mapOf("namespace" to "test", "version" to "2.5") - - assertThrows { - HBaseStorageBackend.create(props) - } - } - - @Test - fun `throws when kerberos config is incomplete`() { - val props = - mapOf( - "namespace" to "test", - "version" to "2.4", - "hbase.zookeeper.quorum" to "localhost:2181", - "secure" to "true", - ) - - assertThrows { - HBaseStorageBackend.create(props) - } - } - } - - @Nested - @DisplayName("parseDatastoreUri") - inner class ParseDatastoreUriTest { - // Note: parseDatastoreUri is private, so we test it through getBucket - // These are covered implicitly by integration tests - } -} diff --git a/engine/src/testFixtures/kotlin/com/kakao/actionbase/test/hbase/HBaseTestingClusterExtension.kt b/engine/src/testFixtures/kotlin/com/kakao/actionbase/test/hbase/HBaseTestingClusterExtension.kt index 8380125e..5716dba0 100644 --- a/engine/src/testFixtures/kotlin/com/kakao/actionbase/test/hbase/HBaseTestingClusterExtension.kt +++ b/engine/src/testFixtures/kotlin/com/kakao/actionbase/test/hbase/HBaseTestingClusterExtension.kt @@ -1,7 +1,7 @@ package com.kakao.actionbase.test.hbase +import com.kakao.actionbase.engine.storage.DefaultStorageBackendFactory import com.kakao.actionbase.v2.engine.compat.DefaultHBaseCluster -import com.kakao.actionbase.v2.engine.storage.DefaultStorageBackendFactory import org.apache.hadoop.hbase.client.AsyncConnection import org.apache.hadoop.hbase.client.AsyncTable diff --git a/engine/src/testFixtures/kotlin/com/kakao/actionbase/test/hbase/HBaseTestingStorageBackend.kt b/engine/src/testFixtures/kotlin/com/kakao/actionbase/test/hbase/HBaseTestingStorageBackend.kt index 03130b3f..87dd6dd6 100644 --- a/engine/src/testFixtures/kotlin/com/kakao/actionbase/test/hbase/HBaseTestingStorageBackend.kt +++ b/engine/src/testFixtures/kotlin/com/kakao/actionbase/test/hbase/HBaseTestingStorageBackend.kt @@ -1,8 +1,8 @@ package com.kakao.actionbase.test.hbase -import com.kakao.actionbase.v2.engine.storage.StorageBackend -import com.kakao.actionbase.v2.engine.storage.StorageTable -import com.kakao.actionbase.v2.engine.storage.hbase.HBaseStorageTable +import com.kakao.actionbase.engine.storage.StorageBackend +import com.kakao.actionbase.engine.storage.StorageTable +import com.kakao.actionbase.engine.storage.hbase.HBaseStorageTable import com.kakao.actionbase.v2.engine.storage.hbase.HBaseTable import org.apache.hadoop.hbase.TableName