From 20cc0e5e612fe169b193fc4a54ad979ae8f5e9b5 Mon Sep 17 00:00:00 2001 From: Minseok Kim Date: Mon, 9 Feb 2026 11:08:07 +0900 Subject: [PATCH 1/7] 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/7] 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 4356dea39450b0814b36b29edc18c6206a9de0e6 Mon Sep 17 00:00:00 2001 From: Minseok Kim Date: Mon, 9 Feb 2026 12:49:00 +0900 Subject: [PATCH 3/7] 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 4/7] 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 41c530a2e1102ad3d93c6bfa0387bdb42ee866e1 Mon Sep 17 00:00:00 2001 From: Minseok Kim Date: Mon, 9 Feb 2026 13:09:10 +0900 Subject: [PATCH 5/7] 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 a62c8aca1200498e28b21f73901445f6be7fcc6a Mon Sep 17 00:00:00 2001 From: Minseok Kim Date: Mon, 9 Feb 2026 13:19:44 +0900 Subject: [PATCH 6/7] refactor(engine): convert StorageTableCompatibilityTest to @ObjectSource Co-Authored-By: Claude Opus 4.6 --- .../storage/StorageTableCompatibilityTest.kt | 127 ++++++++++++------ 1 file changed, 87 insertions(+), 40 deletions(-) diff --git a/engine/src/test/kotlin/com/kakao/actionbase/engine/storage/StorageTableCompatibilityTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/engine/storage/StorageTableCompatibilityTest.kt index 24feea55..79638471 100644 --- a/engine/src/test/kotlin/com/kakao/actionbase/engine/storage/StorageTableCompatibilityTest.kt +++ b/engine/src/test/kotlin/com/kakao/actionbase/engine/storage/StorageTableCompatibilityTest.kt @@ -1,6 +1,8 @@ package com.kakao.actionbase.engine.storage import com.kakao.actionbase.core.storage.MutationRequest +import com.kakao.actionbase.test.documentations.params.ObjectSource +import com.kakao.actionbase.test.documentations.params.ObjectSourceParameterizedTest import java.nio.ByteBuffer import java.nio.ByteOrder @@ -38,10 +40,23 @@ abstract class StorageTableCompatibilityTest { @Nested @DisplayName("get") inner class GetTest { - @Test - fun `returns value when key exists`() { - table.put(b("key"), b("value")).block() - assert(table.get(b("key")).block()?.contentEquals(b("value")) == true) + @ObjectSourceParameterizedTest + @ObjectSource( + """ + - key: key1 + value: value1 + - key: k + value: v + - key: long_key_name + value: long_value + """, + ) + fun `returns stored value`( + key: String, + value: String, + ) { + table.put(b(key), b(value)).block() + assert(String(table.get(b(key)).block()!!) == value) } @Test @@ -49,17 +64,27 @@ abstract class StorageTableCompatibilityTest { assert(table.get(b("missing")).block() == null) } - @Test - fun `getAll returns matching records`() { - 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`() { - table.put(b("exists"), b("v")).block() - assert(table.get(listOf(b("exists"), b("missing"))).block()!!.size == 1) + @ObjectSourceParameterizedTest + @ObjectSource( + """ + # all keys exist + - keys: [k1, k2] + values: [v1, v2] + expected: 2 + + # some keys missing + - keys: [exists, missing] + values: [v] + expected: 1 + """, + ) + fun `getAll`( + keys: List, + values: List, + expected: Int, + ) { + keys.zip(values).forEach { (k, v) -> table.put(b(k), b(v)).block() } + assert(table.get(keys.map { b(it) }).block()!!.size == expected) } } @@ -73,16 +98,25 @@ abstract class StorageTableCompatibilityTest { } } - @Test - fun `returns matching prefix`() { - 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(table.scan(b("nonexistent"), 100, null, null).block()!!.isEmpty()) + @ObjectSourceParameterizedTest + @ObjectSource( + """ + - prefix: "user:001" + expected: 2 + - prefix: "user:" + expected: 3 + - prefix: "post:" + expected: 1 + - prefix: nonexistent + expected: 0 + """, + ) + fun `returns matching prefix`( + prefix: String, + expected: Int, + ) { + val results = table.scan(b(prefix), 100, null, null).block()!! + assert(results.size == expected) { "prefix=$prefix: expected $expected but got ${results.size}" } } @Test @@ -139,21 +173,34 @@ abstract class StorageTableCompatibilityTest { assumeTrue(supportsIncrement()) } - @Test - fun `creates counter if not exists`() { - assert(table.increment(b("cnt"), 10).block() == 10L) - } - - @Test - fun `updates existing counter`() { - table.put(b("cnt"), longToBytes(100)).block() - assert(table.increment(b("cnt"), 50).block() == 150L) - } - - @Test - fun `decrements with negative delta`() { - table.put(b("cnt"), longToBytes(100)).block() - assert(table.increment(b("cnt"), -30).block() == 70L) + @ObjectSourceParameterizedTest + @ObjectSource( + """ + # new counter + - initial: 0 + delta: 10 + expected: 10 + + # add to existing + - initial: 100 + delta: 50 + expected: 150 + + # decrement + - initial: 100 + delta: -30 + expected: 70 + """, + ) + fun `increment counter`( + initial: Long, + delta: Long, + expected: Long, + ) { + if (initial != 0L) { + table.put(b("cnt"), longToBytes(initial)).block() + } + assert(table.increment(b("cnt"), delta).block() == expected) } } From af9a798c892c9b14efc6f04048ea22e3cb410409 Mon Sep 17 00:00:00 2001 From: Minseok Kim Date: Mon, 9 Feb 2026 13:23:19 +0900 Subject: [PATCH 7/7] test(engine): replace assert with assertNotNull in MemoryStorageBackendTest - Simplify null checks in MemoryStorageBackendTest using `assertNotNull` for clarity. - Minor method renaming in StorageTableCompatibilityTest for consistency. --- .../actionbase/engine/storage/MemoryStorageBackendTest.kt | 6 ++++-- .../engine/storage/StorageTableCompatibilityTest.kt | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) 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 index 53db5f3c..a10ddc80 100644 --- a/engine/src/test/kotlin/com/kakao/actionbase/engine/storage/MemoryStorageBackendTest.kt +++ b/engine/src/test/kotlin/com/kakao/actionbase/engine/storage/MemoryStorageBackendTest.kt @@ -4,6 +4,8 @@ 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 kotlin.test.assertNotNull + import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName @@ -42,7 +44,7 @@ class MemoryStorageBackendTest { name: String, ) { val table = backend.getStorageTable(namespace, name).block()!! - assert(table != null) + assertNotNull(table) } @ObjectSourceParameterizedTest @@ -54,7 +56,7 @@ class MemoryStorageBackendTest { ) fun `returns StorageTable with uri`(uri: String) { val table = backend.getStorageTable(uri).block()!! - assert(table != null) + assertNotNull(table) } @Test diff --git a/engine/src/test/kotlin/com/kakao/actionbase/engine/storage/StorageTableCompatibilityTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/engine/storage/StorageTableCompatibilityTest.kt index 79638471..7c1812e3 100644 --- a/engine/src/test/kotlin/com/kakao/actionbase/engine/storage/StorageTableCompatibilityTest.kt +++ b/engine/src/test/kotlin/com/kakao/actionbase/engine/storage/StorageTableCompatibilityTest.kt @@ -78,7 +78,7 @@ abstract class StorageTableCompatibilityTest { expected: 1 """, ) - fun `getAll`( + fun `get all`( keys: List, values: List, expected: Int,