diff --git a/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/cats/sql/SqlProviderCache.kt b/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/cats/sql/SqlProviderCache.kt index bf4d29a7f5e..7cf9062a844 100644 --- a/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/cats/sql/SqlProviderCache.kt +++ b/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/cats/sql/SqlProviderCache.kt @@ -112,8 +112,8 @@ class SqlProviderCache(private val backingStore: WriteableCache) : ProviderCache * @param identifiers the identifiers * @return the items matching the type and identifiers */ - override fun getAll(type: String, vararg identifiers: String?): MutableCollection { - return getAll(type, identifiers.filterNotNull().toMutableList()) + override fun getAll(type: String, vararg identifiers: String): MutableCollection { + return getAll(type, identifiers.toMutableList()) } /** diff --git a/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/cats/sql/cache/SqlAdminCommandsRepository.kt b/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/cats/sql/cache/SqlAdminCommandsRepository.kt deleted file mode 100644 index 1d508f4f0fb..00000000000 --- a/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/cats/sql/cache/SqlAdminCommandsRepository.kt +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright 2020 Netflix, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.netflix.spinnaker.cats.sql.cache - -import com.netflix.spinnaker.kork.sql.config.SqlProperties -import java.sql.Connection -import java.sql.DriverManager -import org.jooq.SQLDialect -import org.jooq.impl.DSL -import org.slf4j.LoggerFactory - -class SqlAdminCommandsRepository(private val properties: SqlProperties) { - - companion object { - private val log by lazy { LoggerFactory.getLogger(SqlAdminCommandsRepository::class.java) } - } - - /** - * Truncates CATS tables of the given namespace for the current schema version. - * - * @param truncateNamespace namespace of tables to truncate. - * @return String collection of the tables truncated. - */ - fun truncateTablesByNamespace(truncateNamespace: String): Collection { - val conn = getConnection() - val tablesTruncated = mutableListOf() - - conn.use { c -> - val jooq = DSL.using(c, SQLDialect.MYSQL) - val rs = - jooq.fetch( - "show tables like ?", - "cats_v${SqlSchemaVersion.current()}_${truncateNamespace}_%" - ).intoResultSet() - - while (rs.next()) { - val table = rs.getString(1) - val truncateSql = "truncate table `$table`" - log.info("Truncating $table") - - jooq.query(truncateSql).execute() - tablesTruncated.add(table) - } - } - - return tablesTruncated - } - - /** - * Drops CATS tables of the given namespace for the current schema version. - * - * @param dropNamespace namespace of tables to drop. - * @return String collection of the tables dropped. - */ - fun dropTablesByNamespace(dropNamespace: String): Collection { - val conn = getConnection() - val tablesDropped = mutableListOf() - - conn.use { c -> - val jooq = DSL.using(c, SQLDialect.MYSQL) - val rs = - jooq.fetch("show tables like ?", "cats_v${SqlSchemaVersion.current()}_${dropNamespace}_%") - .intoResultSet() - - while (rs.next()) { - val table = rs.getString(1) - val dropSql = "drop table `$table`" - log.info("Dropping $table") - - jooq.query(dropSql).execute() - tablesDropped.add(table) - } - } - - return tablesDropped - } - - /** - * Drops CATS tables of the given schema version. - * - * @param dropVersion the schema version of tables to drop. - * @return String collection of the tables dropped. - */ - fun dropTablesByVersion(dropVersion: SqlSchemaVersion): Collection { - val conn = getConnection() - val tablesDropped = mutableListOf() - - conn.use { c -> - val jooq = DSL.using(c, SQLDialect.MYSQL) - val rs = jooq.fetch("show tables like ?", "cats_v${dropVersion.version}_%").intoResultSet() - - while (rs.next()) { - val table = rs.getString(1) - val dropSql = "drop table `$table`" - log.info("Dropping $table") - - jooq.query(dropSql).execute() - tablesDropped.add(table) - } - } - - return tablesDropped - } - - private fun getConnection(): Connection { - return DriverManager.getConnection( - properties.migration.jdbcUrl, - properties.migration.user, - properties.migration.password - ) - } -} diff --git a/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/cats/sql/cache/SqlCache.kt b/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/cats/sql/cache/SqlCache.kt index 9a69f79a952..15726c14f34 100644 --- a/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/cats/sql/cache/SqlCache.kt +++ b/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/cats/sql/cache/SqlCache.kt @@ -1,7 +1,6 @@ package com.netflix.spinnaker.cats.sql.cache import com.fasterxml.jackson.databind.ObjectMapper -import com.google.common.hash.Hashing import com.netflix.spinnaker.cats.cache.CacheData import com.netflix.spinnaker.cats.cache.CacheFilter import com.netflix.spinnaker.cats.cache.DefaultJsonCacheData @@ -16,11 +15,13 @@ import de.huxhorn.sulky.ulid.ULID import io.github.resilience4j.retry.Retry import io.github.resilience4j.retry.RetryConfig import io.vavr.control.Try +import java.security.MessageDigest import java.sql.ResultSet import java.sql.SQLException import java.sql.SQLSyntaxErrorException import java.time.Clock import java.time.Duration +import java.util.Arrays import java.util.concurrent.ConcurrentSkipListSet import java.util.concurrent.atomic.AtomicInteger import javax.annotation.PreDestroy @@ -60,7 +61,6 @@ class SqlCache( companion object { private const val onDemandType = "onDemand" - private const val PLACEHOLDER_ID = "invalidId" private val schemaVersion = SqlSchemaVersion.current() private val useRegexp = @@ -80,14 +80,38 @@ class SqlCache( } /** - * Evicts cache records, but does not evict relationship rows. - * - * @param type the type of the records that will be removed. - * @param ids the ids of the records that will be removed. + * Only evicts cache records but not relationship rows */ - override fun evictAll(type: String, ids: Collection) { - val hashedIds = ids.asSequence().filterNotNull().map { getIdHash(it) }.toList() - evictAllInternal(type, hashedIds) + override fun evictAll(type: String, ids: Collection) { + if (ids.isEmpty()) { + return + } + + log.info("evicting ${ids.size} $type records") + + var deletedCount = 0 + var opCount = 0 + try { + ids.chunked(dynamicConfigService.getConfig(Int::class.java, "sql.cache.read-batch-size", 500)) { chunk -> + withRetry(RetryCategory.WRITE) { + jooq.deleteFrom(table(sqlNames.resourceTableName(type))) + .where(field("id").`in`(*chunk.toTypedArray())) + .execute() + } + deletedCount += chunk.size + opCount += 1 + } + } catch (e: Exception) { + log.error("error evicting records", e) + } + + cacheMetrics.evict( + prefix = name, + type = type, + itemCount = ids.size, + itemsDeleted = deletedCount, + deleteOperations = opCount + ) } fun mergeAll( @@ -187,17 +211,16 @@ class SqlCache( * @param ids the ids * @return the items matching the type and ids */ - override fun getAll(type: String, ids: MutableCollection?): MutableCollection { + override fun getAll(type: String, ids: MutableCollection?): MutableCollection { return getAll(type, ids, null as CacheFilter?) } override fun getAll( type: String, - ids: MutableCollection?, + ids: MutableCollection?, cacheFilter: CacheFilter? ): MutableCollection { - val nonnullIds = ids?.filterNotNull() - if (nonnullIds.isNullOrEmpty()) { + if (ids.isNullOrEmpty()) { cacheMetrics.get( prefix = name, type = type, @@ -209,13 +232,12 @@ class SqlCache( return mutableListOf() } - val hashedIds = nonnullIds.asSequence().map { getIdHash(it) }.toList() val relationshipPrefixes = getRelationshipFilterPrefixes(cacheFilter) val result = if (relationshipPrefixes.isEmpty()) { - getDataWithoutRelationships(type, hashedIds) + getDataWithoutRelationships(type, ids) } else { - getDataWithRelationships(type, hashedIds, relationshipPrefixes) + getDataWithRelationships(type, ids, relationshipPrefixes) } if (result.selectQueries > -1) { @@ -241,8 +263,8 @@ class SqlCache( * @return the items matching the type and identifiers */ override fun getAll(type: String, vararg identifiers: String?): MutableCollection { - val ids = mutableListOf() - identifiers.forEach { ids.add(it) } + val ids = mutableListOf() + identifiers.forEach { ids.add(it!!) } return getAll(type, ids) } @@ -275,13 +297,7 @@ class SqlCache( ) } - return mapOf( - type to mergeDataAndRelationships( - result.data, - result.relPointers, - relationshipPrefixes - ) - ) + return mapOf(type to mergeDataAndRelationships(result.data, result.relPointers, relationshipPrefixes)) } override fun getAllByApplication( @@ -294,13 +310,7 @@ class SqlCache( if (coroutineContext.useAsync(this::asyncEnabled)) { val scope = CatsCoroutineScope(coroutineContext) - types.chunked( - dynamicConfigService.getConfig( - Int::class.java, - "sql.cache.max-query-concurrency", - 4 - ) - ) { batch -> + types.chunked(dynamicConfigService.getConfig(Int::class.java, "sql.cache.max-query-concurrency", 4)) { batch -> val deferred = batch.map { type -> scope.async { getAllByApplication(type, application, cacheFilters[type]) } } @@ -323,10 +333,10 @@ class SqlCache( } /** - * Retrieves all the identifiers for a type. + * Retrieves all the identifiers for a type * - * @param type the type for which to retrieve identifiers. - * @return the identifiers for the type. + * @param type the type for which to retrieve identifiers + * @return the identifiers for the type */ override fun getIdentifiers(type: String): MutableCollection { val ids = try { @@ -356,31 +366,26 @@ class SqlCache( /** * Filters the supplied list of identifiers to only those that exist in the cache. * - * @param type the type of the item. - * @param identifiers the identifiers for the items. - * @return the list of identifiers that are present in the cache from the provided identifiers. + * @param type the type of the item + * @param identifiers the identifiers for the items + * @return the list of identifiers that are present in the cache from the provided identifiers */ - override fun existingIdentifiers( - type: String, - identifiers: MutableCollection - ): MutableCollection { + override fun existingIdentifiers(type: String, identifiers: MutableCollection): MutableCollection { var selects = 0 var withAsync = false val existing = mutableListOf() - val batchSize = - dynamicConfigService.getConfig(Int::class.java, "sql.cache.read-batch-size", 500) - val hashedIds = identifiers.asSequence().filterNotNull().map { getIdHash(it) }.toList() + val batchSize = dynamicConfigService.getConfig(Int::class.java, "sql.cache.read-batch-size", 500) - if (coroutineContext.useAsync(hashedIds.size, this::useAsync)) { + if (coroutineContext.useAsync(identifiers.size, this::useAsync)) { withAsync = true val scope = CatsCoroutineScope(coroutineContext) - hashedIds.chunked(batchSize).chunked( + identifiers.chunked(batchSize).chunked( dynamicConfigService.getConfig(Int::class.java, "sql.cache.max-query-concurrency", 4) ) { batch -> - val deferred = batch.map { idHashes -> + val deferred = batch.map { ids -> scope.async { - selectIdentifiers(type, idHashes) + selectIdentifiers(type, ids) } } runBlocking { @@ -389,7 +394,7 @@ class SqlCache( selects += deferred.size } } else { - hashedIds.chunked(batchSize) { chunk -> + identifiers.chunked(batchSize) { chunk -> existing.addAll(selectIdentifiers(type, chunk)) selects += 1 } @@ -411,9 +416,9 @@ class SqlCache( /** * Returns the identifiers for the specified type that match the provided glob. * - * @param type The type for which to retrieve identifiers. - * @param glob The glob to match against the identifiers. - * @return the identifiers for the type that match the glob. + * @param type The type for which to retrieve identifiers + * @param glob The glob to match against the identifiers + * @return the identifiers for the type that match the glob */ override fun filterIdentifiers(type: String, glob: String?): MutableCollection { if (glob == null) { @@ -439,10 +444,7 @@ class SqlCache( .fetch(field("id"), String::class.java) } } catch (e: Exception) { - suppressedLog( - "Failed searching for identifiers type: $type glob: $glob reason: ${e.message}", - e - ) + suppressedLog("Failed searching for identifiers type: $type glob: $glob reason: ${e.message}", e) mutableSetOf() } @@ -470,44 +472,28 @@ class SqlCache( } override fun get(type: String, id: String?, cacheFilter: CacheFilter?): CacheData? { - if (id == null) { - return null - } - - val result = getAll(type, mutableListOf(id) as MutableCollection, cacheFilter) + val result = getAll(type, Arrays.asList(id), cacheFilter) return if (result.isEmpty()) { null } else result.iterator().next() } - /** - * Evicts a cache record, but does not evict relationships. - * - * @param type the type of the record that will be removed. - * @param id the id of the record that will be removed. - */ - override fun evict(type: String, id: String?) { + override fun evict(type: String, id: String) { evictAll(type, listOf(id)) } - /** - * Evicts on demand cache records that are older than the given time. - * - * @param maxAgeMs deletes records before this time. - * @return the number of records removed. - */ fun cleanOnDemand(maxAgeMs: Long): Int { - val hashedIdsToClean = withRetry(RetryCategory.READ) { - jooq.select(field("id_hash")) + val toClean = withRetry(RetryCategory.READ) { + jooq.select(field("id")) .from(table(sqlNames.resourceTableName(onDemandType))) .where(field("last_updated").lt(clock.millis() - maxAgeMs)) .fetch() .into(String::class.java) } - evictAllInternal(onDemandType, hashedIdsToClean) + evictAll(onDemandType, toClean) - return hashedIdsToClean.size + return toClean.size } private fun storeAuthoritative( @@ -519,43 +505,48 @@ class SqlCache( val result = StoreResult() result.itemCount.addAndGet(items.size) - // onDemand keys aren't initially written by the agents that update and expire them. Since agent - // is part of the primary key, we need to ensure a consistent value across initial and - // subsequent writes. - val agent = if (type == ON_DEMAND.ns) ON_DEMAND.ns else agentHint ?: "unknown" - val agentHash = getAgentHash(agent) + val agent = if (type == ON_DEMAND.ns) { + // onDemand keys aren't initially written by the agents that update and expire them. since agent is + // part of the primary key, we need to ensure a consistent value across initial and subsequent writes + ON_DEMAND.ns + } else { + agentHint ?: "unknown" + } - val existingBodyHashesAndIdHashes = getBodyHashesAndIdHashes(type, agentHash) + val existingHashIds = getHashIds(type, agent) result.selectQueries.incrementAndGet() - val existingBodyHashes = existingBodyHashesAndIdHashes // body hashes previously stored. + val existingHashes = existingHashIds // ids previously store by the calling caching agent .asSequence() .map { it.body_hash } - .filterNotNull() .toSet() - val existingIdHashes = existingBodyHashesAndIdHashes // id hashes previously stored. + val existingIds = existingHashIds .asSequence() - .map { it.id_hash } - .filterNotNull() + .map { it.id } .toSet() - val currentIdHashes = mutableSetOf() // current ids from the caching agent hashed. - val resourceEntriesToStore = mutableListOf() - val now = clock.millis() + val currentIds = mutableSetOf() // current ids from the caching agent + val toStore = mutableListOf() // ids that are new or changed + val bodies = mutableMapOf() // id to body + val hashes = mutableMapOf() // id to sha256(body) + val apps = mutableMapOf() - items - .filter { it.id != "_ALL_" } + items.filter { it.id.length > sqlConstraints.maxIdLength } .forEach { - val idHash = getIdHash(it.id) - currentIdHashes.add(idHash) + log.error("Dropping ${it.id} - character length exceeds MAX_ID_LENGTH ($sqlConstraints.maxIdLength)") + } + items + .filter { it.id != "_ALL_" && it.id.length <= sqlConstraints.maxIdLength } + .forEach { + currentIds.add(it.id) val nullKeys = it.attributes .filter { e -> e.value == null } .keys nullKeys.forEach { na -> it.attributes.remove(na) } - val application: String? = - if (it.attributes.containsKey("application")) it.attributes["application"] as String - else null + if (it.attributes.containsKey("application")) { + apps[it.id] = it.attributes["application"] as String + } val keysToNormalize = it.relationships.keys.filter { k -> k.contains(':') } if (keysToNormalize.isNotEmpty()) { @@ -565,37 +556,22 @@ class SqlCache( } val body: String? = mapper.writeValueAsString(it) - val bodyHash = getBodyHash(body) - - if (body != null && bodyHash != null && !existingBodyHashes.contains(bodyHash)) { - resourceEntriesToStore.add( - ResourceEntry( - idHash, - it.id, - agentHash, - agent, - application, - bodyHash, - body, - now - ) - ) + val bodyHash = getHash(body) + + if (body != null && bodyHash != null && !existingHashes.contains(bodyHash)) { + toStore.add(it.id) + bodies[it.id] = body + hashes[it.id] = bodyHash } } - resourceEntriesToStore.chunked( - dynamicConfigService.getConfig( - Int::class.java, - "sql.cache.write-batch-size", - 100 - ) - ) { chunk -> + val now = clock.millis() + + toStore.chunked(dynamicConfigService.getConfig(Int::class.java, "sql.cache.write-batch-size", 100)) { chunk -> try { val insert = jooq.insertInto( table(sqlNames.resourceTableName(type)), - field("id_hash"), field("id"), - field("agent_hash"), field("agent"), field("application"), field("body_hash"), @@ -605,16 +581,7 @@ class SqlCache( insert.apply { chunk.forEach { - values( - it.id_hash, - it.id, - it.agent_hash, - it.agent, - it.application, - it.body_hash, - it.body, - it.last_updated - ) + values(it, sqlNames.checkAgentName(agent), apps[it], hashes[it], bodies[it], now) } onDuplicateKeyUpdate() @@ -637,7 +604,7 @@ class SqlCache( jooq.fetchExists( jooq.select() .from(sqlNames.resourceTableName(type)) - .where(field("id_hash").eq(it.id_hash), field("agent_hash").eq(it.agent_hash)) + .where(field("id").eq(it), field("agent").eq(sqlNames.checkAgentName(agent))) .forUpdate() ) } @@ -645,11 +612,11 @@ class SqlCache( if (exists) { withRetry(RetryCategory.WRITE) { jooq.update(table(sqlNames.resourceTableName(type))) - .set(field("application"), it.application) - .set(field("body_hash"), it.body_hash) - .set(field("body"), it.body) + .set(field("application"), apps[it]) + .set(field("body_hash"), hashes[it]) + .set(field("body"), bodies[it]) .set(field("last_updated"), clock.millis()) - .where(field("id_hash").eq(it.id_hash), field("agent_hash").eq(it.agent_hash)) + .where(field("id").eq(it), field("agent").eq(sqlNames.checkAgentName(agent))) .execute() } result.writeQueries.incrementAndGet() @@ -658,22 +625,18 @@ class SqlCache( withRetry(RetryCategory.WRITE) { jooq.insertInto( table(sqlNames.resourceTableName(type)), - field("id_hash"), field("id"), - field("agent_hash"), field("agent"), field("application"), field("body_hash"), field("body"), field("last_updated") ).values( - it.id_hash, - it.id, - it.agent_hash, - it.agent, - it.application, - it.body_hash, - it.body, + it, + sqlNames.checkAgentName(agent), + apps[it], + hashes[it], + bodies[it], clock.millis() ).execute() } @@ -688,177 +651,166 @@ class SqlCache( return result } - val idHashesToDelete = existingIdHashes.filter { !currentIdHashes.contains(it) } + val toDelete = existingIds + .asSequence() + .filter { !currentIds.contains(it) } + .toSet() - evictAllInternal(type, idHashesToDelete) + evictAll(type, toDelete) return result } - private fun storeInformative( - type: String, - items: MutableCollection, - cleanup: Boolean - ): StoreResult { + private fun storeInformative(type: String, items: MutableCollection, cleanup: Boolean): StoreResult { val result = StoreResult() - val relAgentHashes = items.asSequence() - .filter { it.relationships.isNotEmpty() } + val sourceAgents = items.filter { it.relationships.isNotEmpty() } .map { it.relationships.keys } .flatten() - .map { getAgentHash(it) } .toSet() - if (relAgentHashes.isEmpty()) { + if (sourceAgents.isEmpty()) { log.warn("no relationships found for type $type") return result } - val existingFwdRelKeys = relAgentHashes + val existingFwdRelIds = sourceAgents .map { result.selectQueries.incrementAndGet() getRelationshipKeys(type, it) } .flatten() - // The current reverse relationship types provided from the calling caching agent. - val currentRevRelTypes = mutableSetOf() + val existingRevRelTypes = mutableSetOf() items .filter { it.id != "_ALL_" } .forEach { cacheData -> cacheData.relationships.entries.forEach { rels -> val relType = rels.key.substringBefore(delimiter = ":", missingDelimiterValue = "") - currentRevRelTypes.add(relType) + existingRevRelTypes.add(relType) } } - val existingRevRelKeys = currentRevRelTypes // Reverse relationships previously stored. - .filter { createdTables.contains(it) } + existingRevRelTypes.filter { !createdTables.contains(it) } + .forEach { createTables(it) } + + val existingRevRelIds = existingRevRelTypes .map { relType -> - relAgentHashes - .map { relAgentHash -> + sourceAgents + .map { agent -> result.selectQueries.incrementAndGet() - getRelationshipKeysAndAgent(relType, type, relAgentHash) + getRelationshipKeys(relType, type, agent) } .flatten() } .flatten() - // Create tables for the reverse relationship types that have not been previously stored. - currentRevRelTypes.filter { !createdTables.contains(it) } - .forEach { createTables(it) } - - val oldFwdIds: Map = existingFwdRelKeys + val oldFwdIds: Map = existingFwdRelIds .asSequence() - .map { - it.key() to it.uuid - } + .map { it.key() to it.uuid } .toMap() val oldRevIds = mutableMapOf() val oldRevIdsToType = mutableMapOf() - existingRevRelKeys + existingRevRelIds .forEach { oldRevIds[it.key()] = it.uuid - oldRevIdsToType[it.key()] = it.rel_agent.substringBefore( - delimiter = ":", - missingDelimiterValue = "" - ) + oldRevIdsToType[it.key()] = it.rel_agent.substringBefore(delimiter = ":", missingDelimiterValue = "") } val currentIds = mutableSetOf() - // Use map for reverse relationships vs set for forward so that we can do batch inserts for - // reverse relationships by the rel type. This isn't necessary for forward because there is only - // one type and it's provided by the calling caching agent. - val newFwdRelEntries = mutableSetOf() - val newRevTypeToRelEntries = mutableMapOf>() - - val ulid = ULID() + val newFwdRelPointers = mutableMapOf>() + val newRevRelIds = mutableSetOf() items - .filter { it.id != "_ALL_" } + .filter { it.id != "_ALL_" && it.id.length <= sqlConstraints.maxIdLength } .forEach { cacheData -> cacheData.relationships.entries.forEach { rels -> - val relAgent = rels.key - val relAgentHash = getAgentHash(relAgent) - val relType = relAgent.substringBefore(delimiter = ":", missingDelimiterValue = "") - - rels.value.forEach { relId -> - val idHash = getIdHash(cacheData.id) - val relIdHash = getIdHash(relId) - - val fwdKey = "$idHash|$relIdHash" - val revKey = "$relIdHash|$idHash" - currentIds.add(fwdKey) - currentIds.add(revKey) - - result.relationshipCount.incrementAndGet() - - if (!oldFwdIds.containsKey(fwdKey)) { - newFwdRelEntries.add( - RelEntry( - uuid = PLACEHOLDER_ID, - id_hash = idHash, - id = cacheData.id, - rel_id_hash = relIdHash, - rel_id = relId, - rel_agent_hash = relAgentHash, - rel_agent = relAgent, - rel_type = relType, - last_updated = null - ) - ) - } + val relType = rels.key.substringBefore(delimiter = ":", missingDelimiterValue = "") + rels.value.filter { it.length <= sqlConstraints.maxIdLength } + .forEach { r -> + val fwdKey = "${cacheData.id}|$r" + val revKey = "$r|${cacheData.id}" + currentIds.add(fwdKey) + currentIds.add(revKey) + + result.relationshipCount.incrementAndGet() + + if (!oldFwdIds.contains(fwdKey)) { + newFwdRelPointers.getOrPut(relType) { mutableListOf() } + .add(RelPointer(cacheData.id, r, rels.key)) + } - if (!oldRevIds.containsKey(revKey)) { - newRevTypeToRelEntries.getOrPut(relType) { mutableSetOf() } - .add( - RelEntry( - uuid = PLACEHOLDER_ID, - id_hash = relIdHash, - id = relId, - rel_id_hash = idHash, - rel_id = cacheData.id, - rel_agent_hash = relAgentHash, - rel_agent = relAgent, - rel_type = type, - last_updated = null - ) - ) + if (!oldRevIds.containsKey(revKey)) { + newRevRelIds.add(revKey) + } } - } } } - newFwdRelEntries.chunked( - dynamicConfigService.getConfig( - Int::class.java, - "sql.cache.write-batch-size", - 100 - ) - ) { chunk -> - try { - writeRelTable(sqlNames.relTableName(type), chunk, ulid, result) - } catch (e: Exception) { - log.error("Error inserting forward relationships for $type", e) - } - } + newFwdRelPointers.forEach { (relType, pointers) -> + val now = clock.millis() + var ulid = ULID().nextValue() - newRevTypeToRelEntries.forEach { (revType, relEntries) -> - relEntries.chunked( - dynamicConfigService.getConfig( - Int::class.java, - "sql.cache.write-batch-size", - 100 - ) - ) { chunk -> + pointers.chunked(dynamicConfigService.getConfig(Int::class.java, "sql.cache.write-batch-size", 100)) { chunk -> try { - writeRelTable(sqlNames.relTableName(revType), chunk, ulid, result) + val insert = jooq.insertInto( + table(sqlNames.relTableName(type)), + field("uuid"), + field("id"), + field("rel_id"), + field("rel_agent"), + field("rel_type"), + field("last_updated") + ) + + insert.apply { + chunk.forEach { + values(ulid.toString(), it.id, it.rel_id, sqlNames.checkAgentName(it.rel_type), relType, now) + ulid = ULID().nextMonotonicValue(ulid) + } + } + + withRetry(RetryCategory.WRITE) { + insert.execute() + } + result.writeQueries.incrementAndGet() + result.relationshipsStored.addAndGet(chunk.size) } catch (e: Exception) { - log.error("Error inserting reverse relationships for $revType -> $type", e) + log.error("Error inserting forward relationships for $type -> $relType", e) } } + + pointers.asSequence().filter { newRevRelIds.contains("${it.rel_id}|${it.id}") } + .chunked(dynamicConfigService.getConfig(Int::class.java, "sql.cache.write-batch-size", 100)) { chunk -> + try { + val insert = jooq.insertInto( + table(sqlNames.relTableName(relType)), + field("uuid"), + field("id"), + field("rel_id"), + field("rel_agent"), + field("rel_type"), + field("last_updated") + ) + + insert.apply { + chunk.forEach { + values(ulid.toString(), it.rel_id, it.id, sqlNames.checkAgentName(it.rel_type), type, now) + ulid = ULID().nextMonotonicValue(ulid) + } + } + + withRetry(RetryCategory.WRITE) { + insert.execute() + } + result.writeQueries.incrementAndGet() + result.relationshipsStored.addAndGet(chunk.size) + } catch (e: Exception) { + log.error("Error inserting reverse relationships for $relType -> $type", e) + } + }.toList() } if (!cleanup) { @@ -879,7 +831,7 @@ class SqlCache( result.deleteQueries.incrementAndGet() } revToDelete.forEach { - if (oldRevIdsToType.getOrDefault(it.key, "").isNotEmpty()) { + if (oldRevIdsToType.getOrDefault(it.key, "").isNotBlank()) { withRetry(RetryCategory.WRITE) { jooq.deleteFrom(table(sqlNames.relTableName(oldRevIdsToType[it.key]!!))) .where(field("uuid").eq(it.value)) @@ -898,45 +850,6 @@ class SqlCache( return result } - private fun writeRelTable(table: String, rels: List, ulid: ULID, result: StoreResult) { - val insert = jooq.insertInto( - table(table), - field("uuid"), - field("id_hash"), - field("id"), - field("rel_id_hash"), - field("rel_id"), - field("rel_agent_hash"), - field("rel_agent"), - field("rel_type"), - field("last_updated") - ) - - var ulidValue = ulid.nextValue() - insert.apply { - rels.forEach { - values( - ulidValue.toString(), - it.id_hash, - it.id, - it.rel_id_hash, - it.rel_id, - it.rel_agent_hash, - it.rel_agent, - it.rel_type, - clock.millis() - ) - ulidValue = ulid.nextMonotonicValue(ulidValue) - } - } - - withRetry(RetryCategory.WRITE) { - insert.execute() - } - result.writeQueries.incrementAndGet() - result.relationshipsStored.addAndGet(rels.size) - } - private fun createTables(type: String) { if (!createdTables.contains(type)) { try { @@ -990,121 +903,57 @@ class SqlCache( } } - /** - * Returns a hash of the body given if not null or blank, otherwise returns null. - * - * @param body the body to hash. - * @return hash of the body. - */ - private fun getBodyHash(body: String?): String? { + private fun getHash(body: String?): String? { if (body.isNullOrBlank()) { return null } - return getMurmur3Hash(body) - } - - /** - * Returns a hash of the id given. - * - * @param id The id to hash. - * @return hash of the id. - */ - private fun getIdHash(id: String): String { - return getMurmur3Hash(id) - } - - /** - * Returns a hash of the agent given. - * - * @param agent the agent to hash. - * @return hash of the agent. - */ - private fun getAgentHash(agent: String): String { - return getMurmur3Hash(agent) - } - - /** - * Gets a Murmur3 hash value for a given String. - * - * @param str the String to hash. - * @return the hashed value. - */ - private fun getMurmur3Hash(str: String): String { - return Hashing.murmur3_128().hashUnencodedChars(str).toString() + return try { + val digest = MessageDigest.getInstance("SHA-256") + .digest(body.toByteArray()) + digest.fold("") { str, it -> + str + "%02x".format(it) + } + } catch (e: Exception) { + log.error("error calculating hash for body: $body", e) + null + } } - /** - * Returns the body hashes and id hashes stored for the given type and agent hash. - * - * @param type the type of the records to retrieve. - * @param agentHash the agent hash of the records to retrieve. - * @return list of resource entries with body hash and id hash populated. - */ - private fun getBodyHashesAndIdHashes(type: String, agentHash: String?): List { + private fun getHashIds(type: String, agent: String?): List { return withRetry(RetryCategory.READ) { jooq - .select(field("body_hash"), field("id_hash")) + .select(field("body_hash"), field("id")) .from(table(sqlNames.resourceTableName(type))) .where( - field("agent_hash").eq(agentHash) + field("agent").eq(sqlNames.checkAgentName(agent)) ) .fetch() - .into(HashedIdAndBody::class.java) + .into(HashId::class.java) } } - /** - * Returns the following identifying fields of records matching the given rel agent hash from the - * relationship table of the given type: uuid, id hash, rel id hash, and rel agent hash. - * - * @param type the type of the relationship table. - * @param relAgentHash the rel agent hash of the records whose identifying fields are retrieved. - * @return list of relationship entries with uuid, id hash, rel id hash, and rel agent hash - * populated. - */ - private fun getRelationshipKeys(type: String, relAgentHash: String): MutableList { + private fun getRelationshipKeys(type: String, sourceAgent: String): MutableList { return withRetry(RetryCategory.READ) { jooq - .select(field("uuid"), field("id_hash"), field("rel_id_hash"), field("rel_agent_hash")) + .select(field("uuid"), field("id"), field("rel_id"), field("rel_agent")) .from(table(sqlNames.relTableName(type))) - .where(field("rel_agent_hash").eq(relAgentHash)) + .where(field("rel_agent").eq(sqlNames.checkAgentName(sourceAgent))) .fetch() - .into(RelEntryHashes::class.java) + .into(RelId::class.java) } } - /** - * Returns the rel agent and the following identifying fields of records matching the given rel - * agent hash and rel type from the relationship table of the given type: uuid, id hash, rel id - * hash, and rel agent hash. - * - * @param type the type of the relationship table. - * @param origType the rel type of the records whose identifying fields are retrieved. - * @param relAgentHash the rel agent hash of the records whose identifying fields are retrieved. - * @return list of relationship entries with uuid, id hash, rel id hash, and rel agent hash - * populated. - */ - private fun getRelationshipKeysAndAgent( - type: String, - origType: String, - relAgentHash: String - ): MutableList { + private fun getRelationshipKeys(type: String, origType: String, sourceAgent: String): MutableList { return withRetry(RetryCategory.READ) { jooq - .select( - field("uuid"), - field("id_hash"), - field("rel_id_hash"), - field("rel_agent_hash"), - field("rel_agent") - ) + .select(field("uuid"), field("id"), field("rel_id"), field("rel_agent")) .from(table(sqlNames.relTableName(type))) .where( - field("rel_agent_hash").eq(relAgentHash), + field("rel_agent").eq(sqlNames.checkAgentName(sourceAgent)), field("rel_type").eq(origType) ) .fetch() - .into(RelEntryHashesAndAgent::class.java) + .into(RelId::class.java) } } @@ -1114,16 +963,16 @@ class SqlCache( private fun getDataWithoutRelationships( type: String, - hashedIds: Collection + ids: Collection ): DataWithRelationshipPointersResult { val cacheData = mutableListOf() - val batchSize = - dynamicConfigService.getConfig(Int::class.java, "sql.cache.read-batch-size", 500) + val relPointers = mutableSetOf() + val batchSize = dynamicConfigService.getConfig(Int::class.java, "sql.cache.read-batch-size", 500) var selectQueries = 0 var withAsync = false try { - if (hashedIds.isEmpty()) { + if (ids.isEmpty()) { withRetry(RetryCategory.READ) { cacheData.addAll( jooq.select(field("body")) @@ -1137,15 +986,15 @@ class SqlCache( } selectQueries += 1 } else { - if (coroutineContext.useAsync(hashedIds.size, this::useAsync)) { + if (coroutineContext.useAsync(ids.size, this::useAsync)) { withAsync = true val scope = CatsCoroutineScope(coroutineContext) - hashedIds.chunked(batchSize).chunked( + ids.chunked(batchSize).chunked( dynamicConfigService.getConfig(Int::class.java, "sql.cache.max-query-concurrency", 4) ) { batch -> - val deferred = batch.map { hashedIds -> - scope.async { selectBodies(type, hashedIds) } + val deferred = batch.map { ids -> + scope.async { selectBodies(type, ids) } } runBlocking { cacheData.addAll(deferred.awaitAll().flatten()) @@ -1153,14 +1002,14 @@ class SqlCache( selectQueries += deferred.size } } else { - hashedIds.chunked(batchSize) { chunk -> + ids.chunked(batchSize) { chunk -> cacheData.addAll(selectBodies(type, chunk)) selectQueries += 1 } } } - return DataWithRelationshipPointersResult(cacheData, mutableSetOf(), selectQueries, withAsync) + return DataWithRelationshipPointersResult(cacheData, relPointers, selectQueries, withAsync) } catch (e: Exception) { suppressedLog("Failed selecting ids for type $type", e) @@ -1176,20 +1025,13 @@ class SqlCache( selectQueries = -1 - return DataWithRelationshipPointersResult( - mutableListOf(), - mutableSetOf(), - selectQueries, - withAsync - ) + return DataWithRelationshipPointersResult(mutableListOf(), mutableSetOf(), selectQueries, withAsync) } } - private fun getDataWithoutRelationshipsByApp( - type: String, - application: String - ): DataWithRelationshipPointersResult { + private fun getDataWithoutRelationshipsByApp(type: String, application: String): DataWithRelationshipPointersResult { val cacheData = mutableListOf() + val relPointers = mutableSetOf() var selectQueries = 0 try { @@ -1206,7 +1048,7 @@ class SqlCache( ) } selectQueries += 1 - return DataWithRelationshipPointersResult(cacheData, mutableSetOf(), selectQueries, false) + return DataWithRelationshipPointersResult(cacheData, relPointers, selectQueries, false) } catch (e: Exception) { suppressedLog("Failed selecting resources of type $type for application $application", e) @@ -1222,12 +1064,7 @@ class SqlCache( selectQueries = -1 - return DataWithRelationshipPointersResult( - mutableListOf(), - mutableSetOf(), - selectQueries, - false - ) + return DataWithRelationshipPointersResult(mutableListOf(), mutableSetOf(), selectQueries, false) } } @@ -1300,12 +1137,7 @@ class SqlCache( selectQueries = -1 - return DataWithRelationshipPointersResult( - mutableListOf(), - mutableSetOf(), - selectQueries, - false - ) + return DataWithRelationshipPointersResult(mutableListOf(), mutableSetOf(), selectQueries, false) } } @@ -1319,15 +1151,14 @@ class SqlCache( private fun getDataWithRelationships( type: String, - hashedIds: Collection, + ids: Collection, relationshipPrefixes: List ): DataWithRelationshipPointersResult { val cacheData = mutableListOf() val relPointers = mutableSetOf() var selectQueries = 0 var withAsync = false - val batchSize = - dynamicConfigService.getConfig(Int::class.java, "sql.cache.read-batch-size", 500) + val batchSize = dynamicConfigService.getConfig(Int::class.java, "sql.cache.read-batch-size", 500) /* Approximating the following query pattern in jooq: @@ -1339,7 +1170,7 @@ class SqlCache( */ try { - if (hashedIds.isEmpty()) { + if (ids.isEmpty()) { val relWhere = getRelWhere(relationshipPrefixes) @@ -1369,10 +1200,10 @@ class SqlCache( parseCacheRelResultSet(type, resultSet, cacheData, relPointers) selectQueries += 1 } else { - if (coroutineContext.useAsync(hashedIds.size, this::useAsync)) { + if (coroutineContext.useAsync(ids.size, this::useAsync)) { withAsync = true - hashedIds.chunked(batchSize).chunked( + ids.chunked(batchSize).chunked( dynamicConfigService.getConfig(Int::class.java, "sql.cache.max-query-concurrency", 4) ) { batch -> val scope = CatsCoroutineScope(coroutineContext) @@ -1391,7 +1222,7 @@ class SqlCache( } } } else { - hashedIds.chunked(batchSize) { chunk -> + ids.chunked(batchSize) { chunk -> val resultSet = selectBodiesWithRelationships(type, relationshipPrefixes, chunk) parseCacheRelResultSet(type, resultSet, cacheData, relPointers) @@ -1415,28 +1246,15 @@ class SqlCache( selectQueries = -1 - return DataWithRelationshipPointersResult( - mutableListOf(), - mutableSetOf(), - selectQueries, - withAsync - ) + return DataWithRelationshipPointersResult(mutableListOf(), mutableSetOf(), selectQueries, withAsync) } } - /** - * Returns collection of cache data constructed from the bodies stored for the given type and id - * hashes. - * - * @param type the type of the records to retrieve. - * @param idHashes list of id hashes of the records to retrieve. - * @return collection of cache data constructed from stored bodies. - */ - private fun selectBodies(type: String, idHashes: List): Collection { + private fun selectBodies(type: String, ids: List): Collection { return withRetry(RetryCategory.READ) { jooq.select(field("body")) .from(table(sqlNames.resourceTableName(type))) - .where(field("id_hash").`in`(*idHashes.toTypedArray())) + .where(field("ID").`in`(*ids.toTypedArray())) .fetch() .getValues(0) .map { mapper.readValue(it as String, DefaultJsonCacheData::class.java) } @@ -1444,21 +1262,12 @@ class SqlCache( } } - /** - * Returns a result set where each row has the following columns in the order they are written: - * body, id, rel id, and rel type. - * - * @param type the type of the records to retrieve. - * @param relationshipPrefixes prefixes to determine the rel types of the records to retrieve. - * @param hashedIds list of id hashes of the records to retrieve. - * @return result set with rows containing body, id, rel id, and rel type. - */ private fun selectBodiesWithRelationships( type: String, relationshipPrefixes: List, - hashedIds: List + ids: List ): ResultSet { - val where = field("id_hash").`in`(*hashedIds.toTypedArray()) + val where = field("ID").`in`(*ids.toTypedArray()) val relWhere = getRelWhere(relationshipPrefixes, where) @@ -1487,79 +1296,16 @@ class SqlCache( } } - /** - * Returns the ids stored for the given type and hashed ids. - * - * @param type the type of the ids to retrieve. - * @param hashedIds the hashed ids of the ids to retrieve. - * @return list of ids stored in the cache that matched the type and hashed ids provided. - */ - private fun selectIdentifiers(type: String, hashedIds: List): MutableCollection { + private fun selectIdentifiers(type: String, ids: List): MutableCollection { return withRetry(RetryCategory.READ) { jooq.select(field("id")) .from(table(sqlNames.resourceTableName(type))) - .where(field("id_hash").`in`(*hashedIds.toTypedArray())) + .where(field("id").`in`(*ids.toTypedArray())) .fetch() .intoSet(field("id"), String::class.java) } } - /** - * Evicts cache records, but does not evict relationship rows. Difference between internal and - * public function is that the internal function takes in hashed ids rather than the ids. - * - * @param type the type of the records that will be removed. - * @param hashedIds collection of hashed ids to be removed. - */ - private fun evictAllInternal(type: String, hashedIds: Collection) { - if (hashedIds.isEmpty()) { - return - } - - log.info("evicting ${hashedIds.size} $type records") - - var deletedCount = 0 - var opCount = 0 - try { - hashedIds.chunked( - dynamicConfigService.getConfig( - Int::class.java, - "sql.cache.write-batch-size", - 300 - ) - ) { chunk -> - withRetry(RetryCategory.WRITE) { - jooq.deleteFrom(table(sqlNames.resourceTableName(type))) - .where(field("id_hash").`in`(*chunk.toTypedArray())) - .execute() - } - deletedCount += chunk.size - opCount += 1 - } - } catch (e: Exception) { - log.error("error evicting records", e) - } - - cacheMetrics.evict( - prefix = name, - type = type, - itemCount = hashedIds.size, - itemsDeleted = deletedCount, - deleteOperations = opCount - ) - } - - /** - * Adds entries into the provided cache data and rel pointer lists based on the given result set. - * In order to properly add entries into cache data and rel pointer lists, the result set rows - * must have the following columns in the order they are written: body, id, rel id, and rel type. - * - * @param type the type of the cached records. - * @param resultSet the result set with columns body, id, rel id, and rel type used to populate - * cache data and rel pointer lists. - * @param cacheData cache data list populated by result set. - * @param relPointers rel pointer list populated by result set - */ private fun parseCacheRelResultSet( type: String, resultSet: ResultSet, @@ -1571,20 +1317,11 @@ class SqlCache( try { cacheData.add(mapper.readValue(resultSet.getString(1), DefaultJsonCacheData::class.java)) } catch (e: Exception) { - log.error( - "Failed to deserialize cached value: type $type, body ${resultSet.getString(1)}", - e - ) + log.error("Failed to deserialize cached value: type $type, body ${resultSet.getString(1)}", e) } } else { try { - relPointers.add( - RelPointer( - resultSet.getString(2), - resultSet.getString(3), - resultSet.getString(4) - ) - ) + relPointers.add(RelPointer(resultSet.getString(2), resultSet.getString(3), resultSet.getString(4))) } catch (e: SQLException) { log.error("Error reading relationship of type $type", e) } @@ -1610,12 +1347,7 @@ class SqlCache( // to prevent fetching more. TODO: update the test? if (relationshipPrefixes.isNotEmpty()) { if (item.relationships.any { it.key.contains(':') }) { - data[item.id]!!.relationships.putAll( - normalizeRelationships( - item.relationships, - relationshipPrefixes - ) - ) + data[item.id]!!.relationships.putAll(normalizeRelationships(item.relationships, relationshipPrefixes)) } } else { relKeysToRemove.getOrPut(item.id) { mutableSetOf() } @@ -1705,10 +1437,7 @@ class SqlCache( return relationships } - private fun getRelWhere( - relationshipPrefixes: List, - prefix: Condition? = null - ): Condition { + private fun getRelWhere(relationshipPrefixes: List, prefix: Condition? = null): Condition { var relWhere: Condition = noCondition() if (relationshipPrefixes.isNotEmpty() && !relationshipPrefixes.contains("ALL")) { @@ -1758,16 +1487,8 @@ class SqlCache( @ExperimentalContracts private fun useAsync(items: Int): Boolean { - return dynamicConfigService.getConfig( - Int::class.java, - "sql.cache.max-query-concurrency", - 4 - ) > 1 && - items > dynamicConfigService.getConfig( - Int::class.java, - "sql.cache.read-batch-size", - 500 - ) * 2 + return dynamicConfigService.getConfig(Int::class.java, "sql.cache.max-query-concurrency", 4) > 1 && + items > dynamicConfigService.getConfig(Int::class.java, "sql.cache.read-batch-size", 500) * 2 } @ExperimentalContracts @@ -1801,59 +1522,27 @@ class SqlCache( return Thread.currentThread().name.startsWith(coroutineThreadPrefix) } - private data class HashedIdAndBody( - val id_hash: String, - val body_hash: String - ) - - private interface HashedRelEntry { - val uuid: String - val id_hash: String - val rel_id_hash: String - val rel_agent_hash: String - - fun key(): String { - return "$id_hash|$rel_id_hash" - } + // Assists with unit testing + fun clearCreatedTables() { + val tables = createdTables.toList() + createdTables.removeAll(tables) } - private data class RelEntryHashes( - override val uuid: String, - override val id_hash: String, - override val rel_id_hash: String, - override val rel_agent_hash: String - ) : HashedRelEntry - - private data class RelEntryHashesAndAgent( - override val uuid: String, - override val id_hash: String, - override val rel_id_hash: String, - override val rel_agent_hash: String, - val rel_agent: String - ) : HashedRelEntry - - data class ResourceEntry( - val id_hash: String, - val id: String, - val agent_hash: String, - val agent: String, - val application: String?, + data class HashId( val body_hash: String, - val body: String, - val last_updated: Long? + val id: String ) - data class RelEntry( - override val uuid: String, - override val id_hash: String, + data class RelId( + val uuid: String, val id: String, - override val rel_id_hash: String, val rel_id: String, - override val rel_agent_hash: String, - val rel_agent: String, - val rel_type: String, - val last_updated: Long? - ) : HashedRelEntry + val rel_agent: String + ) { + fun key(): String { + return "$id|$rel_id" + } + } data class RelPointer( val id: String, diff --git a/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/cats/sql/cache/SqlNames.kt b/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/cats/sql/cache/SqlNames.kt index 5b0416d6892..a9e2c53da7f 100644 --- a/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/cats/sql/cache/SqlNames.kt +++ b/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/cats/sql/cache/SqlNames.kt @@ -75,6 +75,34 @@ class SqlNames( throw IllegalArgumentException("property sql.table-namespace $tableNamespace is too long") } + /** + * Truncates the agent string if too long to fit in @SqlConstraints.maxAgentLength + * @return agent string to store + */ + @VisibleForTesting + internal fun checkAgentName(agent: String?): String? { + if (agent == null) { + return null + } + if (agent.length <= sqlConstraints.maxAgentLength) { + return agent + } + + val hash = Hashing.murmur3_128().hashBytes((agent).toByteArray()).toString() + val colIdx = agent.indexOf(':') + + // We want to store at least : + if (colIdx > sqlConstraints.maxAgentLength - 2) { + throw IllegalArgumentException("Type ${agent.substring(0, colIdx)} is too long, record cannot be stored") + } + + // How much we can keep of the agent string, we need to preserve the colon + val available = Math.max(sqlConstraints.maxAgentLength - hash.length - 1, colIdx) + // How much of the hash will fit if we want to preserve the colon + val hashLength = Math.min(hash.length, sqlConstraints.maxAgentLength - colIdx - 1) + return agent.substring(0..available) + hash.substring(0, hashLength) + } + companion object { private val schemaVersion = SqlSchemaVersion.current() private val typeSanitization = diff --git a/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/cats/sql/cache/SqlSchemaVersion.kt b/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/cats/sql/cache/SqlSchemaVersion.kt index 04dd0b9135c..de6721e8a42 100644 --- a/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/cats/sql/cache/SqlSchemaVersion.kt +++ b/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/cats/sql/cache/SqlSchemaVersion.kt @@ -1,10 +1,9 @@ package com.netflix.spinnaker.cats.sql.cache enum class SqlSchemaVersion(val version: Int) { - V1(1), - V2(2); + V1(1); companion object { - fun current(): Int = V2.version + fun current(): Int = V1.version } } diff --git a/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/cats/sql/cache/SqlUnknownAgentCleanupAgent.kt b/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/cats/sql/cache/SqlUnknownAgentCleanupAgent.kt index f306b44c3a0..7981cce3f8c 100644 --- a/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/cats/sql/cache/SqlUnknownAgentCleanupAgent.kt +++ b/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/cats/sql/cache/SqlUnknownAgentCleanupAgent.kt @@ -175,7 +175,7 @@ class SqlUnknownAgentCleanupAgent( .map { it.typeName } .toSet() - result = Pair(agents.mapNotNull { it.agentType }.toSet(), dataTypes) + result = Pair(agents.mapNotNull { sqlNames.checkAgentName(it.agentType) }.toSet(), dataTypes) } return result } diff --git a/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/cats/sql/controllers/CatsSqlAdminController.kt b/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/cats/sql/controllers/CatsSqlAdminController.kt index edc25fc496c..39d2dd4636d 100644 --- a/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/cats/sql/controllers/CatsSqlAdminController.kt +++ b/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/cats/sql/controllers/CatsSqlAdminController.kt @@ -1,12 +1,16 @@ package com.netflix.spinnaker.cats.sql.controllers -import com.netflix.spinnaker.cats.sql.cache.SqlAdminCommandsRepository import com.netflix.spinnaker.cats.sql.cache.SqlSchemaVersion import com.netflix.spinnaker.fiat.shared.FiatPermissionEvaluator +import com.netflix.spinnaker.kork.sql.config.SqlProperties import com.netflix.spinnaker.security.AuthenticatedRequest +import java.sql.DriverManager +import org.jooq.SQLDialect +import org.jooq.impl.DSL import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.security.authentication.BadCredentialsException import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PutMapping @@ -16,11 +20,12 @@ import org.springframework.web.bind.annotation.RestController // TODO: Replace validatePermissions() with a to-be-implemented fiat isAdmin() decorator @ConditionalOnProperty("sql.cache.enabled") +@EnableConfigurationProperties(SqlProperties::class) @RestController @RequestMapping("/admin/db") class CatsSqlAdminController( private val fiat: FiatPermissionEvaluator, - private val adminCommands: SqlAdminCommandsRepository + private val properties: SqlProperties ) { companion object { @@ -34,9 +39,31 @@ class CatsSqlAdminController( ): CleanTablesResult { validatePermissions() - validateNamespaceParams(currentNamespace, truncateNamespace) + validateParams(currentNamespace, truncateNamespace) + + val conn = DriverManager.getConnection( + properties.migration.jdbcUrl, + properties.migration.user, + properties.migration.password + ) + + val tablesTruncated = mutableListOf() + val sql = "show tables like 'cats_v${SqlSchemaVersion.current()}_${truncateNamespace}_%'" + + conn.use { c -> + val jooq = DSL.using(c, SQLDialect.MYSQL) + val rs = jooq.fetch(sql).intoResultSet() + + while (rs.next()) { + val table = rs.getString(1) + val truncateSql = "truncate table `$table`" + log.info("Truncating $table") + + jooq.query(truncateSql).execute() + tablesTruncated.add(table) + } + } - val tablesTruncated = adminCommands.truncateTablesByNamespace(truncateNamespace) return CleanTablesResult(tableCount = tablesTruncated.size, tables = tablesTruncated) } @@ -47,24 +74,35 @@ class CatsSqlAdminController( ): CleanTablesResult { validatePermissions() - validateNamespaceParams(currentNamespace, dropNamespace) + validateParams(currentNamespace, dropNamespace) - val tablesDropped = adminCommands.dropTablesByNamespace(dropNamespace) - return CleanTablesResult(tableCount = tablesDropped.size, tables = tablesDropped) - } + val conn = DriverManager.getConnection( + properties.migration.jdbcUrl, + properties.migration.user, + properties.migration.password + ) - @PutMapping(path = ["drop_version/{version}"]) - fun dropTablesByVersion( - @PathVariable("version") dropVersion: SqlSchemaVersion - ): CleanTablesResult { + val tablesDropped = mutableListOf() + val sql = "show tables like 'cats_v${SqlSchemaVersion.current()}_${dropNamespace}_%'" - validatePermissions() + conn.use { c -> + val jooq = DSL.using(c, SQLDialect.MYSQL) + val rs = jooq.fetch(sql).intoResultSet() + + while (rs.next()) { + val table = rs.getString(1) + val dropSql = "drop table `$table`" + log.info("Dropping $table") + + jooq.query(dropSql).execute() + tablesDropped.add(table) + } + } - val tablesDropped = adminCommands.dropTablesByVersion(dropVersion) return CleanTablesResult(tableCount = tablesDropped.size, tables = tablesDropped) } - private fun validateNamespaceParams(currentNamespace: String?, targetNamespace: String) { + private fun validateParams(currentNamespace: String?, targetNamespace: String) { if (currentNamespace == null) { throw IllegalStateException("truncate can only be called when sql.tableNamespace is set") } diff --git a/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/config/SqlCacheConfiguration.kt b/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/config/SqlCacheConfiguration.kt index 3f19330a926..fa6b3cdcc9a 100644 --- a/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/config/SqlCacheConfiguration.kt +++ b/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/config/SqlCacheConfiguration.kt @@ -12,7 +12,6 @@ import com.netflix.spinnaker.cats.provider.Provider import com.netflix.spinnaker.cats.provider.ProviderRegistry import com.netflix.spinnaker.cats.sql.SqlProviderRegistry import com.netflix.spinnaker.cats.sql.cache.SpectatorSqlCacheMetrics -import com.netflix.spinnaker.cats.sql.cache.SqlAdminCommandsRepository import com.netflix.spinnaker.cats.sql.cache.SqlCacheMetrics import com.netflix.spinnaker.cats.sql.cache.SqlCleanupStaleOnDemandCachesAgent import com.netflix.spinnaker.cats.sql.cache.SqlNamedCacheFactory @@ -52,11 +51,7 @@ const val coroutineThreadPrefix = "catsSql" @Configuration @ConditionalOnProperty("sql.cache.enabled") @Import(DefaultSqlConfiguration::class) -@EnableConfigurationProperties( - SqlAgentProperties::class, - SqlConstraints::class, - SqlProperties::class -) +@EnableConfigurationProperties(SqlAgentProperties::class, SqlConstraints::class) @ComponentScan("com.netflix.spinnaker.cats.sql.controllers") class SqlCacheConfiguration { @@ -178,12 +173,7 @@ class SqlCacheConfiguration { sqlConstraints: SqlConstraints, @Value("\${sql.table-namespace:#{null}}") tableNamespace: String? ): SqlUnknownAgentCleanupAgent = - SqlUnknownAgentCleanupAgent( - providerRegistry, - jooq, - registry, - SqlNames(tableNamespace, sqlConstraints) - ) + SqlUnknownAgentCleanupAgent(providerRegistry, jooq, registry, SqlNames(tableNamespace, sqlConstraints)) @Bean @ConditionalOnExpression("\${sql.read-only:false} == false") @@ -194,9 +184,4 @@ class SqlCacheConfiguration { fun nodeStatusProvider(discoveryStatusListener: DiscoveryStatusListener): NodeStatusProvider { return DiscoveryStatusNodeStatusProvider(discoveryStatusListener) } - - @Bean - fun sqlAdminCommandRepo(sqlProperties: SqlProperties): SqlAdminCommandsRepository { - return SqlAdminCommandsRepository(sqlProperties) - } } diff --git a/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/config/SqlConstraints.kt b/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/config/SqlConstraints.kt index f34456e572f..ac752f8ceb2 100644 --- a/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/config/SqlConstraints.kt +++ b/cats/cats-sql/src/main/kotlin/com/netflix/spinnaker/config/SqlConstraints.kt @@ -21,4 +21,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties @ConfigurationProperties("sql.constraints") class SqlConstraints { var maxTableNameLength: Int = 64 + // 352 * 2 + 64 (max rel_type length) == 768; 768 * 4 (utf8mb4) == 3072 == Aurora's max index length + var maxIdLength: Int = 352 + var maxAgentLength: Int = 127 } diff --git a/cats/cats-sql/src/test/kotlin/com/netflix/spinnaker/cats/sql/cache/SqlNamesTest.kt b/cats/cats-sql/src/test/kotlin/com/netflix/spinnaker/cats/sql/cache/SqlNamesTest.kt index 00023b1d173..bbeeacca430 100644 --- a/cats/cats-sql/src/test/kotlin/com/netflix/spinnaker/cats/sql/cache/SqlNamesTest.kt +++ b/cats/cats-sql/src/test/kotlin/com/netflix/spinnaker/cats/sql/cache/SqlNamesTest.kt @@ -17,7 +17,10 @@ package com.netflix.spinnaker.cats.sql.cache import dev.minutest.junit.JUnit5Minutests import dev.minutest.rootContext +import java.lang.IllegalArgumentException +import strikt.api.expect import strikt.api.expectThat +import strikt.assertions.isA import strikt.assertions.isEqualTo class SqlNamesTest : JUnit5Minutests { @@ -41,6 +44,42 @@ class SqlNamesTest : JUnit5Minutests { } } + fun agentTests() = rootContext { + fixture { + SqlNames() + } + listOf( + Pair(null, null), + Pair("myagent", "myagent"), + Pair( + "abcdefghij".repeat(20), + "abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdebb43b982e477772faa2e899f65d0a86b" + ), + Pair( + "abcdefghij".repeat(10) + ":" + "abcdefghij".repeat(10), + "abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghij:20f5a9d8d3f4f18cfec8a40eda" + ), + Pair( + "abcdefghij:" + "abcdefghij".repeat(20), + "abcdefghij:abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcd5bfa5c163877f247769cd6b488dff339" + ) + ).forEach { test -> + test("max length of table name is checked: ${test.first}") { + expectThat(checkAgentName(test.first)) + .isEqualTo(test.second) + } + } + + test("do not accept types that are too long") { + expect { + that( + kotlin.runCatching { checkAgentName("abcdefghij".repeat(20) + ":abcdefghij") } + .exceptionOrNull() + ).isA() + } + } + } + private inner class TableName( val name: String, val suffix: String, diff --git a/cats/cats-sql/src/test/kotlin/com/netflix/spinnaker/cats/sql/cache/SqlUnknownAgentCleanupAgentTest.kt b/cats/cats-sql/src/test/kotlin/com/netflix/spinnaker/cats/sql/cache/SqlUnknownAgentCleanupAgentTest.kt index ce45ff112ce..bdd9740c3da 100644 --- a/cats/cats-sql/src/test/kotlin/com/netflix/spinnaker/cats/sql/cache/SqlUnknownAgentCleanupAgentTest.kt +++ b/cats/cats-sql/src/test/kotlin/com/netflix/spinnaker/cats/sql/cache/SqlUnknownAgentCleanupAgentTest.kt @@ -15,7 +15,6 @@ */ package com.netflix.spinnaker.cats.sql.cache -import com.google.common.hash.Hashing import com.netflix.spectator.api.NoopRegistry import com.netflix.spinnaker.cats.agent.CachingAgent import com.netflix.spinnaker.cats.cache.DefaultCacheData @@ -42,17 +41,6 @@ import strikt.assertions.isEqualTo class SqlUnknownAgentCleanupAgentTest : JUnit5Minutests { - companion object { - private const val idProd = "aws:instances:prod:us-east-1:i-abcd1234" - private const val idTest = "aws:instances:test:us-east-1:i-abcd1234" - private const val agentProd = "prod/TestAgent" - private const val agentTest = "test/TestAgent" - private const val relIdProd = "aws:serverGroups:myapp-prod:prod:us-east-1:myapp-prod-v000" - private const val relIdTest = "aws:serverGroups:myapp-test:test:us-east-1:myapp-test-v000" - private const val relAgentProd = "serverGroups:prod/TestAgent" - private const val relAgentTest = "serverGroups:test/TestAgent" - } - fun tests() = rootContext { fixture { Fixture() } @@ -96,12 +84,12 @@ class SqlUnknownAgentCleanupAgentTest : JUnit5Minutests { test("relationships referencing old data are deleted") { expectThat(selectAllResources()) - .hasSize(1)[0].isEqualTo(idProd) + .hasSize(1)[0].isEqualTo("aws:instances:prod:us-east-1:i-abcd1234") } test("resources referencing old data are deleted") { expectThat(selectAllRels()) - .hasSize(1)[0].isEqualTo(relIdProd) + .hasSize(1)[0].isEqualTo("aws:serverGroups:myapp-prod:prod:us-east-1:myapp-prod-v000") } } } @@ -130,41 +118,26 @@ class SqlUnknownAgentCleanupAgentTest : JUnit5Minutests { ) val registry = NoopRegistry() - val subject = - SqlUnknownAgentCleanupAgent( - StaticObjectProvider(providerRegistry), - dslContext, - registry, - SqlNames() - ) + val subject = SqlUnknownAgentCleanupAgent(StaticObjectProvider(providerRegistry), dslContext, registry, SqlNames()) fun seedDatabase(includeTestAccount: Boolean, includeProdAccount: Boolean) { SqlNames().run { val resource = resourceTableName("instances") val rel = relTableName("instances") - dslContext.execute("CREATE TABLE IF NOT EXISTS $resource LIKE cats_v2_resource_template") - dslContext.execute("CREATE TABLE IF NOT EXISTS $rel LIKE cats_v2_rel_template") + dslContext.execute("CREATE TABLE IF NOT EXISTS $resource LIKE cats_v1_resource_template") + dslContext.execute("CREATE TABLE IF NOT EXISTS $rel LIKE cats_v1_rel_template") } - dslContext.insertInto(table("cats_v2_instances")) + dslContext.insertInto(table("cats_v1_instances")) .columns( - field("id_hash"), - field("id"), - field("agent_hash"), - field("agent"), - field("application"), - field("body_hash"), - field("body"), - field("last_updated") + field("id"), field("agent"), field("application"), field("body_hash"), field("body"), field("last_updated") ) .let { if (includeProdAccount) { it .values( - getSha256Hash(idProd), - idProd, - getSha256Hash(agentProd), - agentProd, + "aws:instances:prod:us-east-1:i-abcd1234", + "prod/TestAgent", "myapp", "", "", @@ -178,10 +151,8 @@ class SqlUnknownAgentCleanupAgentTest : JUnit5Minutests { if (includeTestAccount) { it .values( - getSha256Hash(idTest), - idTest, - getSha256Hash(agentTest), - agentTest, + "aws:instances:test:us-east-1:i-abcd1234", + "test/TestAgent", "myapp", "", "", @@ -193,14 +164,11 @@ class SqlUnknownAgentCleanupAgentTest : JUnit5Minutests { } .execute() - dslContext.insertInto(table("cats_v2_instances_rel")) + dslContext.insertInto(table("cats_v1_instances_rel")) .columns( field("uuid"), - field("id_hash"), field("id"), - field("rel_id_hash"), field("rel_id"), - field("rel_agent_hash"), field("rel_agent"), field("rel_type"), field("last_updated") @@ -210,12 +178,9 @@ class SqlUnknownAgentCleanupAgentTest : JUnit5Minutests { it .values( ULID().nextULID(), - getSha256Hash(idProd), - idProd, - getSha256Hash(relIdProd), - relIdProd, - getSha256Hash(relAgentProd), - relAgentProd, + "aws:instances:prod:us-east-1:i-abcd1234", + "aws:serverGroups:myapp-prod:prod:us-east-1:myapp-prod-v000", + "serverGroups:prod/TestAgent", "serverGroups", System.currentTimeMillis() ) @@ -228,12 +193,9 @@ class SqlUnknownAgentCleanupAgentTest : JUnit5Minutests { it .values( ULID().nextULID(), - getSha256Hash(idTest), - idTest, - getSha256Hash(relIdTest), - relIdTest, - getSha256Hash(relAgentTest), - relAgentTest, + "aws:instances:test:us-east-1:i-abcd1234", + "aws:serverGroups:myapp-test:test:us-east-1:myapp-test-v000", + "serverGroups:test/TestAgent", "serverGroups", System.currentTimeMillis() ) @@ -252,10 +214,12 @@ class SqlUnknownAgentCleanupAgentTest : JUnit5Minutests { it.results = mapOf( INSTANCES.ns to listOf( DefaultCacheData( - idTest, + "aws:instances:test:us-east-1:i-abcd1234", mapOf(), mapOf( - SERVER_GROUPS.ns to listOf(relIdTest) + SERVER_GROUPS.ns to listOf( + "aws:serverGroups:myapp-test:test:us-east-1:myapp-test-v000" + ) ) ) ) @@ -270,10 +234,12 @@ class SqlUnknownAgentCleanupAgentTest : JUnit5Minutests { it.results = mapOf( INSTANCES.ns to listOf( DefaultCacheData( - idProd, + "aws:instances:prod:us-east-1:i-abcd1234", mapOf(), mapOf( - SERVER_GROUPS.ns to listOf(relIdProd) + SERVER_GROUPS.ns to listOf( + "aws:serverGroups:myapp-prod:prod:us-east-1:myapp-prod-v000" + ) ) ) ) @@ -296,13 +262,9 @@ class SqlUnknownAgentCleanupAgentTest : JUnit5Minutests { dslContext.select(field("rel_id")) .from(table(SqlNames().relTableName("instances"))) .fetch(0, String::class.java) - - fun getSha256Hash(str: String): String = - Hashing.sha256().hashBytes(str.toByteArray()).toString() } - private inner class StaticObjectProvider(val obj: ProviderRegistry) : - ObjectProvider { + private inner class StaticObjectProvider(val obj: ProviderRegistry) : ObjectProvider { override fun getIfUnique(): ProviderRegistry? = obj override fun getObject(vararg args: Any?): ProviderRegistry = obj override fun getObject(): ProviderRegistry = obj diff --git a/clouddriver-sql/src/main/resources/db/changelog-master.yml b/clouddriver-sql/src/main/resources/db/changelog-master.yml index 22fa1769f5c..9e91c9eac71 100644 --- a/clouddriver-sql/src/main/resources/db/changelog-master.yml +++ b/clouddriver-sql/src/main/resources/db/changelog-master.yml @@ -14,6 +14,3 @@ databaseChangeLog: - include: file: changelog/20190913-task-sagaids.yml relativeToChangelogFile: true -- include: - file: changelog/20200709-cats-v2.yml - relativeToChangelogFile: true diff --git a/clouddriver-sql/src/main/resources/db/changelog/20200709-cats-v2.yml b/clouddriver-sql/src/main/resources/db/changelog/20200709-cats-v2.yml deleted file mode 100644 index 86c8d9ccd22..00000000000 --- a/clouddriver-sql/src/main/resources/db/changelog/20200709-cats-v2.yml +++ /dev/null @@ -1,204 +0,0 @@ -databaseChangeLog: -- changeSet: - id: create-cats-v2-resource-template-table - author: mattsanta - changes: - - createTable: - tableName: cats_v2_resource_template - columns: - - column: - name: id_hash - type: char(64) - constraints: - primaryKey: true - nullable: false - - column: - name: id - type: varchar(768) - constraints: - nullable: false - - column: - name: agent_hash - type: char(64) - constraints: - primaryKey: true - nullable: false - - column: - name: agent - type: varchar(400) - constraints: - nullable: false - - column: - name: application - type: varchar(255) - - column: - name: body_hash - type: char(64) - constraints: - nullable: false - - column: - name: body - type: longtext - constraints: - nullable: false - - column: - name: last_updated - type: bigint - constraints: - nullable: false - - modifySql: - dbms: mysql - append: - value: " engine innodb" - rollback: - - dropTable: - tableName: cats_v2_resource_template - -- changeSet: - id: create-cats-v2-resource-template-table-indices - author: mattsanta - changes: - - createIndex: - indexName: agent_hash_body_hash_idx - tableName: cats_v2_resource_template - columns: - - column: - name: agent_hash - - column: - name: body_hash - - createIndex: - indexName: resource_last_updated_idx - tableName: cats_v2_resource_template - columns: - - column: - name: last_updated - - createIndex: - indexName: application_idx - tableName: cats_v2_resource_template - columns: - - column: - name: application - - createIndex: - indexName: id_idx - tableName: cats_v2_resource_template - columns: - - column: - name: id - rollback: - - dropIndex: - indexName: agent_hash_body_hash_idx - tableName: cats_v2_resource_template - - dropIndex: - indexName: resource_last_updated_idx - tableName: cats_v2_resource_template - - dropIndex: - indexName: application_idx - tableName: cats_v2_resource_template - - dropIndex: - indexName: id_idx - tableName: cats_v2_resource_template - -- changeSet: - id: create-cats-v2-rel-template-table - author: mattsanta - changes: - - createTable: - tableName: cats_v2_rel_template - columns: - - column: - name: uuid - type: char(26) - constraints: - primaryKey: true - nullable: false - - column: - name: id_hash - type: char(64) - constraints: - nullable: false - - column: - name: id - type: varchar(768) - constraints: - nullable: false - - column: - name: rel_id_hash - type: char(64) - constraints: - nullable: false - - column: - name: rel_id - type: varchar(768) - constraints: - nullable: false - - column: - name: rel_agent_hash - type: char(64) - constraints: - nullable: false - - column: - name: rel_agent - type: varchar(400) - constraints: - nullable: false - - column: - name: rel_type - type: varchar(64) - - column: - name: last_updated - type: bigint - - modifySql: - dbms: mysql - append: - value: " engine innodb" - rollback: - - dropTable: - tableName: cats_v2_rel_template - -- changeSet: - id: create-cats-v2-rel-template-table-indices - author: mattsanta - changes: - - createIndex: - indexName: id_hash_idx - tableName: cats_v2_rel_template - columns: - - column: - name: id_hash - - createIndex: - indexName: rel_agent_hash_rel_type_idx - tableName: cats_v2_rel_template - columns: - - column: - name: rel_agent_hash - - column: - name: rel_type - - createIndex: - indexName: rel_type_idx - tableName: cats_v2_rel_template - columns: - - column: - name: rel_type - - createIndex: - indexName: rel_ids_hash_type_idx - tableName: cats_v2_rel_template - columns: - - column: - name: id_hash - - column: - name: rel_type - - column: - name: rel_id_hash - rollback: - - dropIndex: - indexName: id_hash_idx - tableName: cats_v2_rel_template - - dropIndex: - indexName: rel_agent_hash_rel_type_idx - tableName: cats_v2_rel_template - - dropIndex: - indexName: rel_type_idx - tableName: cats_v2_rel_template - - dropIndex: - indexName: rel_ids_hash_type_idx - tableName: cats_v2_rel_template