diff --git a/core/src/main/kotlin/com/kakao/actionbase/core/edge/payload/AggregationSweepRequest.kt b/core/src/main/kotlin/com/kakao/actionbase/core/edge/payload/AggregationSweepRequest.kt new file mode 100644 index 00000000..102c2081 --- /dev/null +++ b/core/src/main/kotlin/com/kakao/actionbase/core/edge/payload/AggregationSweepRequest.kt @@ -0,0 +1,40 @@ +package com.kakao.actionbase.core.edge.payload + +import com.kakao.actionbase.core.metadata.common.AggregationType + +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo + +data class AggregationSweepRequest( + val items: List, +) + +data class SweepItem( + val type: AggregationType, + @field:JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXTERNAL_PROPERTY, + property = "type", + ) + @field:JsonSubTypes( + JsonSubTypes.Type(value = TopkSweepItem::class, name = "TOPK"), + ) + val item: SweepItemPayload, +) + +sealed interface SweepItemPayload + +data class TopkSweepItem( + val database: String, + val table: String, + val topk: String, + val source: String, + val target: String, + val direction: String, + val ranges: String = "", + val entity: String, + val topkDimensionValue: String, + val dimensionValues: String = "", + val properties: Map = emptyMap(), + val refreshAt: Long = -1, +) : SweepItemPayload diff --git a/core/src/main/kotlin/com/kakao/actionbase/core/edge/payload/AggregationSweepResult.kt b/core/src/main/kotlin/com/kakao/actionbase/core/edge/payload/AggregationSweepResult.kt new file mode 100644 index 00000000..3f3d1fa2 --- /dev/null +++ b/core/src/main/kotlin/com/kakao/actionbase/core/edge/payload/AggregationSweepResult.kt @@ -0,0 +1,10 @@ +package com.kakao.actionbase.core.edge.payload + +data class AggregationSweepResult( + val database: String, + val table: String, + val topk: String, + val entity: String, + val status: String, + val error: String?, +) diff --git a/core/src/main/kotlin/com/kakao/actionbase/core/edge/payload/AggregationsSweepResponse.kt b/core/src/main/kotlin/com/kakao/actionbase/core/edge/payload/AggregationsSweepResponse.kt new file mode 100644 index 00000000..c37467aa --- /dev/null +++ b/core/src/main/kotlin/com/kakao/actionbase/core/edge/payload/AggregationsSweepResponse.kt @@ -0,0 +1,31 @@ +package com.kakao.actionbase.core.edge.payload + +data class AggregationsSweepResponse( + val items: List, +) { + data class Item( + val database: String, + val table: String, + val topk: String, + val entity: String, + val status: String, + val error: String?, + ) + + companion object { + fun from(sweepResults: List): AggregationsSweepResponse = + AggregationsSweepResponse( + items = + sweepResults.map { result -> + Item( + database = result.database, + table = result.table, + topk = result.topk, + entity = result.entity, + status = result.status, + error = result.error, + ) + }, + ) + } +} diff --git a/engine/src/main/kotlin/com/kakao/actionbase/engine/service/AggregationService.kt b/engine/src/main/kotlin/com/kakao/actionbase/engine/service/AggregationService.kt index d8cfaad7..1bc00fbd 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/engine/service/AggregationService.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/engine/service/AggregationService.kt @@ -2,6 +2,8 @@ package com.kakao.actionbase.engine.service import com.kakao.actionbase.core.edge.payload.AggregationItemPayload import com.kakao.actionbase.core.edge.payload.AggregationResult +import com.kakao.actionbase.core.edge.payload.AggregationSweepResult +import com.kakao.actionbase.core.edge.payload.SweepItem import com.kakao.actionbase.core.metadata.QualifiedAggregations import com.kakao.actionbase.core.metadata.common.AggregationType import com.kakao.actionbase.engine.AggregationEngine @@ -23,4 +25,12 @@ class AggregationService( .fromIterable(items) .flatMap { item -> Flux.merge(handlersByType.values.map { it.aggregate(item) }) } .collectList() + + fun sweep(items: List): Mono> = + Flux + .fromIterable(items) + .flatMap { item -> handler(item.type).sweep(item.item) } + .collectList() + + private fun handler(type: AggregationType): AggregationHandler = handlersByType[type] ?: error("No aggregation handler for type $type") } diff --git a/engine/src/main/kotlin/com/kakao/actionbase/engine/service/aggregation/AggregationHandler.kt b/engine/src/main/kotlin/com/kakao/actionbase/engine/service/aggregation/AggregationHandler.kt index 036ab667..f2103935 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/engine/service/aggregation/AggregationHandler.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/engine/service/aggregation/AggregationHandler.kt @@ -2,9 +2,12 @@ package com.kakao.actionbase.engine.service.aggregation import com.kakao.actionbase.core.edge.payload.AggregationItemPayload import com.kakao.actionbase.core.edge.payload.AggregationResult +import com.kakao.actionbase.core.edge.payload.AggregationSweepResult +import com.kakao.actionbase.core.edge.payload.SweepItemPayload import com.kakao.actionbase.core.metadata.common.AggregationType import reactor.core.publisher.Flux +import reactor.core.publisher.Mono /** * One aggregation kind's write path. [AggregationService] dispatches to the handler @@ -16,4 +19,7 @@ interface AggregationHandler { /** Aggregates a single edge event and writes its result rows. */ fun aggregate(item: AggregationItemPayload): Flux + + /** Recomputes a single refreshed event and re-writes its result row. */ + fun sweep(item: SweepItemPayload): Mono } diff --git a/engine/src/main/kotlin/com/kakao/actionbase/engine/service/aggregation/TopkAggregationHandler.kt b/engine/src/main/kotlin/com/kakao/actionbase/engine/service/aggregation/TopkAggregationHandler.kt index 2fee08b4..8aac8846 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/engine/service/aggregation/TopkAggregationHandler.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/engine/service/aggregation/TopkAggregationHandler.kt @@ -5,8 +5,11 @@ import com.kakao.actionbase.v2.core.metadata.Direction as V2Direction import com.kakao.actionbase.core.edge.Edge import com.kakao.actionbase.core.edge.payload.AggregationItemPayload import com.kakao.actionbase.core.edge.payload.AggregationResult +import com.kakao.actionbase.core.edge.payload.AggregationSweepResult import com.kakao.actionbase.core.edge.payload.EdgeBulkMutationRequest.MutationItem import com.kakao.actionbase.core.edge.payload.MutationResult +import com.kakao.actionbase.core.edge.payload.SweepItemPayload +import com.kakao.actionbase.core.edge.payload.TopkSweepItem import com.kakao.actionbase.core.metadata.common.AggregationConstants import com.kakao.actionbase.core.metadata.common.AggregationType import com.kakao.actionbase.core.metadata.common.Aggregations @@ -54,6 +57,58 @@ class TopkAggregationHandler( }, ).flatMap { (event, direction, topk) -> processTopk(event, direction, topk) } + override fun sweep(item: SweepItemPayload): Mono { + require(item is TopkSweepItem) { "TopkAggregationHandler handles TopkSweepItem, got ${item::class.simpleName}" } + + fun result( + status: String, + error: String? = null, + ) = AggregationSweepResult( + database = item.database, + table = item.table, + topk = item.topk, + entity = item.entity, + status = status, + error = error, + ) + + val tb = engine.getTableBinding(database = item.database, alias = item.table) + val group = + tb.schema.groups + .firstOrNull { g -> g.aggregations.topk.any { it.topk == item.topk } } + ?: return Mono.just(result(SKIPPED)) + + val topk = group.aggregations.topk.first { it.topk == item.topk } + val direction = Direction.valueOf(item.direction) + val directedSource = if (direction == Direction.IN) item.target else item.source + val dimensionValues = AggregationConstants.Topk.splitValues(item.dimensionValues) + val (rankDatabase, rankTable) = parseFqn(topk.rank) + + return writeRank( + sourceDatabase = item.database, + sourceTable = tb.table, + group = group.group, + start = directedSource, + direction = direction, + ranges = item.ranges.takeIf { it.isNotEmpty() }, + ranking = + Ranking( + database = rankDatabase, + table = rankTable, + topk = item.topk, + entity = item.entity, + topkDimensionValue = item.topkDimensionValue, + dimensionValues = dimensionValues, + properties = item.properties, + ), + ).map { results -> + result(status = if (results.any { it.status == ERROR }) ERROR else SUCCESS) + }.onErrorResume { err -> + logger.error("topk sweep failed for {}.{} topk={}", item.database, item.table, item.topk, err) + Mono.just(result(status = ERROR, error = err.message)) + } + } + private fun createTopkEvent(item: AggregationItemPayload): List { val tb = engine.getTableBinding(database = item.database, alias = item.table) @@ -122,7 +177,7 @@ class TopkAggregationHandler( /** * Runs the aggregation query for one ranking and writes its rank row. - * Used by the aggregate flow, which then enqueues a refresh message + * Shared by the aggregate flow (which then enqueues a refresh message) and the sweep flow * (which recomputes from an already-resolved [Ranking]). */ private fun writeRank( diff --git a/engine/src/test/kotlin/com/kakao/actionbase/engine/service/AggregationServiceTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/engine/service/AggregationServiceTest.kt index 2ca54cdc..0da93ae1 100644 --- a/engine/src/test/kotlin/com/kakao/actionbase/engine/service/AggregationServiceTest.kt +++ b/engine/src/test/kotlin/com/kakao/actionbase/engine/service/AggregationServiceTest.kt @@ -1,5 +1,7 @@ package com.kakao.actionbase.engine.service +import com.kakao.actionbase.core.edge.payload.SweepItem +import com.kakao.actionbase.core.edge.payload.TopkSweepItem import com.kakao.actionbase.core.metadata.QualifiedAggregations import com.kakao.actionbase.core.metadata.common.AggregationType import com.kakao.actionbase.engine.AggregationEngine @@ -10,10 +12,11 @@ import org.junit.jupiter.api.Test import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder import io.mockk.every import io.mockk.mockk +import reactor.test.StepVerifier /** * `AggregationService` is a thin dispatcher: it forwards metadata lookups to the engine and routes - * each item to the handler registered for its type. Per-type behavior (top-K ranking) lives + * each item to the handler registered for its type. Per-type behavior (top-K ranking, refresh) lives * in the handlers, and dispatch through a real handler is exercised end-to-end by * `MetadataAggQueryControllerE2ETest` — so here we only pin what the dispatcher itself owns. */ @@ -39,4 +42,32 @@ class AggregationServiceTest { service.getAggregations(AggregationType.TOPK) shouldContainExactlyInAnyOrder listOf(entry) } } + + @Nested + inner class Sweep { + @Test + fun `errors when no handler is registered for the type`() { + val service = AggregationService(engine, emptyList()) + + StepVerifier + .create(service.sweep(listOf(sweepItem()))) + .verifyError(IllegalStateException::class.java) + } + } } + +private fun sweepItem(): SweepItem = + SweepItem( + type = AggregationType.TOPK, + item = + TopkSweepItem( + database = "commerce", + table = "orders", + topk = "top_purchased", + source = "user1", + target = "item1", + direction = "OUT", + entity = "user1", + topkDimensionValue = "item1", + ), + ) diff --git a/engine/src/test/kotlin/com/kakao/actionbase/engine/service/aggregation/TopkAggregationHandlerTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/engine/service/aggregation/TopkAggregationHandlerTest.kt index 8bc77482..2150673d 100644 --- a/engine/src/test/kotlin/com/kakao/actionbase/engine/service/aggregation/TopkAggregationHandlerTest.kt +++ b/engine/src/test/kotlin/com/kakao/actionbase/engine/service/aggregation/TopkAggregationHandlerTest.kt @@ -6,6 +6,7 @@ import com.kakao.actionbase.core.edge.payload.DataFrameEdgeAggPayload import com.kakao.actionbase.core.edge.payload.EdgeBulkMutationRequest.MutationItem import com.kakao.actionbase.core.edge.payload.EdgePayload import com.kakao.actionbase.core.edge.payload.MutationResult +import com.kakao.actionbase.core.edge.payload.TopkSweepItem import com.kakao.actionbase.core.metadata.common.AggregationConstants import com.kakao.actionbase.core.metadata.common.AggregationType import com.kakao.actionbase.core.metadata.common.Aggregations @@ -386,6 +387,98 @@ class TopkAggregationHandlerTest { }.verifyComplete() } } + + @Nested + inner class Sweep { + @Test + fun `recomputes the ranking and rewrites the rank row`() { + stubTopkBinding(engine = engine, topk = topkConfig(name = "top_purchased")) + + every { + queryService.agg(any(), any(), any(), any(), any(), any(), any(), any()) + } returns Mono.just(aggPayload(count = 9)) + + val mutations = slot>() + every { + mutationService.mutate("commerce", "orders__topk", capture(mutations), any(), any(), any(), any()) + } returns Mono.just(listOf(mutationResult(status = "CREATED"))) + + StepVerifier + .create(handler.sweep(sweepTarget(topk = "top_purchased"))) + .assertNext { result -> + result.status shouldBe "SUCCESS" + result.topk shouldBe "top_purchased" + result.entity shouldBe "user1" + }.verifyComplete() + + val edge = mutations.captured.single().edge + edge.source shouldBe "commerce|orders|top_purchased|user1" + edge.target shouldBe "item1" + } + + @Test + fun `rewrites the carried properties onto the rank row`() { + stubTopkBinding(engine = engine, topk = topkConfig(name = "top_purchased")) + + every { + queryService.agg(any(), any(), any(), any(), any(), any(), any(), any()) + } returns Mono.just(aggPayload(count = 9)) + + val mutations = slot>() + every { + mutationService.mutate("commerce", "orders__topk", capture(mutations), any(), any(), any(), any()) + } returns Mono.just(listOf(mutationResult(status = "CREATED"))) + + StepVerifier + .create(handler.sweep(sweepTarget(topk = "top_purchased", properties = mapOf("category" to "fruit")))) + .assertNext { it.status shouldBe "SUCCESS" } + .verifyComplete() + + val edge = mutations.captured.single().edge + edge.properties.containsKey("metric") shouldBe true + edge.properties["additionalProperties"] shouldBe """{"category":"fruit"}""" + } + + @Test + fun `is skipped when the topk is not defined on the table`() { + stubTopkBinding(engine = engine, topk = topkConfig(name = "top_purchased")) + + StepVerifier + .create(handler.sweep(sweepTarget(topk = "unknown_topk"))) + .assertNext { it.status shouldBe "SKIPPED" } + .verifyComplete() + + verify(exactly = 0) { mutationService.mutate(any(), any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `IN aggregates from the target endpoint`() { + stubTopkBinding( + engine = engine, + topk = topkConfig(name = "top_purchased_by", entity = "target", dimension = "source"), + directionType = DirectionType.IN, + ) + + val starts = slot>() + every { + queryService.agg(any(), any(), any(), capture(starts), any(), any(), any(), any()) + } returns Mono.just(aggPayload(count = 4)) + + every { + mutationService.mutate("commerce", "orders__topk", any(), any(), any(), any(), any()) + } returns Mono.just(listOf(mutationResult(status = "CREATED"))) + + StepVerifier + .create( + handler.sweep( + sweepTarget(topk = "top_purchased_by", direction = "IN", entity = "item1", topkDimensionValue = "user1"), + ), + ).assertNext { it.status shouldBe "SUCCESS" } + .verifyComplete() + + starts.captured shouldBe listOf("item1") + } + } } // region test fixtures @@ -416,6 +509,34 @@ private fun topkConfig( ranges = ranges, ) +private fun sweepTarget( + topk: String, + database: String = "commerce", + table: String = "orders", + source: String = "user1", + target: String = "item1", + direction: String = "OUT", + ranges: String = "_target:eq:item1", + entity: String = "user1", + topkDimensionValue: String = "item1", + dimensionValues: String = "", + properties: Map = emptyMap(), +): TopkSweepItem = + TopkSweepItem( + database = database, + table = table, + topk = topk, + source = source, + target = target, + direction = direction, + ranges = ranges, + entity = entity, + topkDimensionValue = topkDimensionValue, + dimensionValues = dimensionValues, + properties = properties, + refreshAt = 123L, + ) + private fun stubTopkBinding( engine: AggregationEngine, topk: Topk, diff --git a/server/src/main/kotlin/com/kakao/actionbase/server/api/graph/v3/metadata/MetadataAggController.kt b/server/src/main/kotlin/com/kakao/actionbase/server/api/graph/v3/metadata/MetadataAggController.kt index 6ad9a11b..a26dcc77 100644 --- a/server/src/main/kotlin/com/kakao/actionbase/server/api/graph/v3/metadata/MetadataAggController.kt +++ b/server/src/main/kotlin/com/kakao/actionbase/server/api/graph/v3/metadata/MetadataAggController.kt @@ -1,7 +1,9 @@ package com.kakao.actionbase.server.api.graph.v3.metadata import com.kakao.actionbase.core.edge.payload.AggregationItemRequest +import com.kakao.actionbase.core.edge.payload.AggregationSweepRequest import com.kakao.actionbase.core.edge.payload.AggregationsItemResponse +import com.kakao.actionbase.core.edge.payload.AggregationsSweepResponse import com.kakao.actionbase.core.metadata.common.AggregationType import com.kakao.actionbase.core.metadata.payload.AggregationsListResponse import com.kakao.actionbase.engine.service.AggregationService @@ -35,4 +37,12 @@ class MetadataAggController( aggregationService .aggregate(items = request.items) .map { results -> ResponseEntity.ok(AggregationsItemResponse.from(results)) } + + @PostMapping("/aggregations/v1/sweep") + fun sweep( + @RequestBody request: AggregationSweepRequest, + ): Mono> = + aggregationService + .sweep(items = request.items) + .map { results -> ResponseEntity.ok(AggregationsSweepResponse.from(results)) } } diff --git a/server/src/test/kotlin/com/kakao/actionbase/server/api/graph/v3/metadata/MetadataAggControllerE2ETest.kt b/server/src/test/kotlin/com/kakao/actionbase/server/api/graph/v3/metadata/MetadataAggControllerE2ETest.kt index ecc6457f..4f0d6c3c 100644 --- a/server/src/test/kotlin/com/kakao/actionbase/server/api/graph/v3/metadata/MetadataAggControllerE2ETest.kt +++ b/server/src/test/kotlin/com/kakao/actionbase/server/api/graph/v3/metadata/MetadataAggControllerE2ETest.kt @@ -1,5 +1,7 @@ package com.kakao.actionbase.server.api.graph.v3.metadata +import com.kakao.actionbase.core.edge.payload.AggregationsSweepResponse +import com.kakao.actionbase.core.edge.payload.DataFrameEdgePayload import com.kakao.actionbase.core.metadata.common.AggregationConstants import com.kakao.actionbase.core.metadata.common.AggregationConstants.Topk.DATABASE import com.kakao.actionbase.core.metadata.common.AggregationConstants.Topk.REFRESH_TABLE @@ -156,6 +158,180 @@ class MetadataAggControllerE2ETest : E2ETestBase() { assertEquals(listOf("source", "_target"), source?.dedupeFields) } + /** + * `POST /aggregations/v1/sweep` recomputes a ranking on demand from an already-resolved + * refresh target and re-writes its rank row. + * + * 1. Create a rank table and a source table under `commerce_sweep`. + * 2. Record one edge user1 -> item1. + * 3. Sweep the target. The response reports the recomputed ranking: + * + * commerce_sweep.orders__topk (rank) + * | row key | target | metric | + * |-----------------------|--------|--------| + * | top_purchased | user1 | item1 | 1 | + * + * 4. Scan `metric_desc` from the entity prefix -> the top row's target is `item1`. + */ + @Test + fun `sweep recomputes the ranking and re-writes the rank row`() { + val sweepDb = "commerce_sweep" + val sweepTable = "orders" + val sweepRank = "orders__topk" + val sweepRankFqn = "$sweepDb.$sweepRank" + val topkName = "top_purchased" + + client + .post() + .uri("/graph/v3/databases") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue("""{"database": "$sweepDb", "comment": "test"}""") + .exchange() + + client + .post() + .uri("/graph/v3/databases/$sweepDb/tables") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue( + """ + { + "table": "$sweepRank", + "schema": { + "type": "EDGE", + "source": {"type": "string", "comment": "topk|entity|dimensionValues"}, + "target": {"type": "string", "comment": "topkDimensionValue"}, + "properties": [ + {"name": "metric", "type": "long", "comment": "metric", "nullable": false} + ], + "direction": "OUT", + "indexes": [ + {"index": "metric_desc", "fields": [{"field": "metric", "order": "DESC"}]} + ], + "groups": [], + "caches": [] + }, + "storage": "datastore://test_namespace/$sweepRank", + "mode": "SYNC", + "comment": "topk rank rows" + } + """.trimIndent(), + ).exchange() + .expectStatus() + .isOk + + client + .post() + .uri("/graph/v3/databases/$sweepDb/tables") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue( + """ + { + "table": "$sweepTable", + "schema": { + "type": "MULTI_EDGE", + "id": {"type": "long", "comment": "order id"}, + "source": {"type": "string", "comment": "user"}, + "target": {"type": "string", "comment": "item"}, + "properties": [], + "direction": "OUT", + "indexes": [], + "groups": [{ + "group": "purchased_count", + "type": "COUNT", + "fields": [{"name": "_target"}], + "directionType": "OUT", + "aggregations": { + "topk": [{ + "topk": "$topkName", + "entity": "source", + "ranges": "_target:eq:{_target}", + "dimension": "target", + "rank": "$sweepRankFqn" + }] + } + }], + "caches": [] + }, + "storage": "datastore://test_namespace/$sweepTable", + "mode": "SYNC", + "comment": "orders" + } + """.trimIndent(), + ).exchange() + .expectStatus() + .isOk + + client + .post() + .uri("/graph/v3/databases/$sweepDb/tables/$sweepTable/multi-edges") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue( + """ + { + "mutations": [ + {"type": "INSERT", "edge": {"version": 1, "id": 1, "source": "user1", "target": "item1", "properties": {}}} + ] + } + """.trimIndent(), + ).exchange() + .expectStatus() + .isOk + + val response = + client + .post() + .uri("/aggregations/v1/sweep") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue( + """ + { + "items": [ + { + "type": "TOPK", + "item": {"database": "$sweepDb", "table": "$sweepTable", "topk": "$topkName", + "source": "user1", "target": "item1", "direction": "OUT", "ranges": "_target:eq:item1", + "entity": "user1", "topkDimensionValue": "item1", "dimensionValues": "", "refreshAt": 123} + } + ] + } + """.trimIndent(), + ).exchange() + .expectStatus() + .isOk + .expectBody() + .returnResult() + .responseBody!! + + assertEquals(1, response.items.size) + assertEquals(sweepDb, response.items[0].database) + assertEquals(sweepTable, response.items[0].table) + assertEquals(topkName, response.items[0].topk) + assertEquals("user1", response.items[0].entity) + assertEquals("SUCCESS", response.items[0].status) + + client + .get() + .uri( + "/graph/v3/databases/$sweepDb/tables/$sweepRank/edges/scan/metric_desc" + + "?start=$sweepDb|$sweepTable|$topkName|user1&direction=OUT", + ).exchange() + .expectStatus() + .isOk + .expectBody() + .returnResult() + .responseBody!! + .let { payload -> + assertEquals(1, payload.count) + assertEquals( + "item1", + payload.edges + .single() + .target + .toString(), + ) + } + } + /** * The `commerce.purchases` top-K enables `refreshAfterMillis`, so an aggregation also enqueues a * refresh message. diff --git a/server/src/test/kotlin/com/kakao/actionbase/server/filter/ReadOnlyRequestFilterTest.kt b/server/src/test/kotlin/com/kakao/actionbase/server/filter/ReadOnlyRequestFilterTest.kt index eca7b347..c2798620 100644 --- a/server/src/test/kotlin/com/kakao/actionbase/server/filter/ReadOnlyRequestFilterTest.kt +++ b/server/src/test/kotlin/com/kakao/actionbase/server/filter/ReadOnlyRequestFilterTest.kt @@ -229,6 +229,7 @@ class ReadOnlyRequestFilterTest { "DELETE /graph/v3/databases/{database}/tables/{table}", "DELETE /graph/v3/databases/{database}/tables/{table}/edges/scan/{index}", "POST /aggregations/v1/aggregate", + "POST /aggregations/v1/sweep", "POST /graph/v3/databases", "POST /graph/v3/databases/{database}/aliases", "POST /graph/v3/databases/{database}/tables",