From 6d16d1d5c2525f2108d4a05167afcf434e129fbe Mon Sep 17 00:00:00 2001 From: AtMtun Date: Thu, 5 Nov 2026 10:51:15 +0530 Subject: [PATCH 1/2] test(domain-cache): cover a sub-domain looked up on a cold cache Preload the data-domain cache in AssetExporter before streaming, as MeshExporter already does, and add a seam so the single-domain fetch can be served from memory in a unit test. The new test asserts a sub-domain whose parent is not yet cached resolves to Parent@Child; it is expected to fail until the cache resolves missing parents by GUID. Co-Authored-By: Claude Opus 5.5 --- .../com/atlan/pkg/cache/DataDomainCache.kt | 12 ++- .../cache/DataDomainCacheSubDomainTest.kt | 94 +++++++++++++++++++ .../src/main/kotlin/AssetExporter.kt | 8 ++ 3 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 package-toolkit/runtime/src/test/kotlin/cache/DataDomainCacheSubDomainTest.kt diff --git a/package-toolkit/runtime/src/main/kotlin/com/atlan/pkg/cache/DataDomainCache.kt b/package-toolkit/runtime/src/main/kotlin/com/atlan/pkg/cache/DataDomainCache.kt index 7d3276b418..8e8df5b0aa 100644 --- a/package-toolkit/runtime/src/main/kotlin/com/atlan/pkg/cache/DataDomainCache.kt +++ b/package-toolkit/runtime/src/main/kotlin/com/atlan/pkg/cache/DataDomainCache.kt @@ -11,7 +11,7 @@ import com.atlan.pkg.PackageContext import com.atlan.pkg.Utils import com.atlan.pkg.serde.cell.DataDomainXformer -class DataDomainCache( +open class DataDomainCache( val ctx: PackageContext<*>, ) : AssetCache(ctx, "domain") { private val logger = Utils.getLogger(this.javaClass.name) @@ -25,10 +25,18 @@ class DataDomainCache( /** {@inheritDoc} */ override fun lookupById(id: String?) { - val result = lookupById(id, 0, ctx.client.maxNetworkRetries) + val result = id?.let { fetchById(it) } if (result != null) cache(result.guid, getIdentityForAsset(result), result) } + /** + * Retrieve a single data domain from Atlan by its GUID. + * + * @param guid unique identifier (GUID) of the data domain + * @return the data domain, or null if it could not be found + */ + protected open fun fetchById(guid: String): DataDomain? = lookupById(guid, 0, ctx.client.maxNetworkRetries) + /** {@inheritDoc} */ private fun lookupById( guid: String?, diff --git a/package-toolkit/runtime/src/test/kotlin/cache/DataDomainCacheSubDomainTest.kt b/package-toolkit/runtime/src/test/kotlin/cache/DataDomainCacheSubDomainTest.kt new file mode 100644 index 0000000000..0c00e86be9 --- /dev/null +++ b/package-toolkit/runtime/src/test/kotlin/cache/DataDomainCacheSubDomainTest.kt @@ -0,0 +1,94 @@ +/* SPDX-License-Identifier: Apache-2.0 + Copyright 2023 Atlan Pte. Ltd. */ +package cache + +import com.atlan.mock.MockAtlanTenant +import com.atlan.mock.MockConfig +import com.atlan.model.assets.DataDomain +import com.atlan.model.enums.AtlanStatus +import com.atlan.pkg.PackageContext +import com.atlan.pkg.Utils +import com.atlan.pkg.cache.DataDomainCache +import com.atlan.pkg.serde.cell.DataDomainXformer.DATA_DOMAIN_DELIMITER +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * A sub-domain looked up by GUID on a cold (not preloaded) cache must still resolve to its + * full path. If the parent is read cache-only, the sub-domain's identity collapses to just its + * own name -- and an import, whose preloaded cache keys domains by full path, can then no + * longer find that domain. + */ +class DataDomainCacheSubDomainTest { + companion object { + init { + MockAtlanTenant.initializeClient() + } + + private val config = Utils.parseConfig("{}", "{}") + private val ctx = PackageContext(config, MockAtlanTenant.client, true) + + private const val GRANDPARENT_GUID = "0b9f5a52-0000-4000-8000-000000000001" + private const val PARENT_GUID = "0b9f5a52-0000-4000-8000-000000000002" + private const val CHILD_GUID = "0b9f5a52-0000-4000-8000-000000000003" + } + + /** Serves domains from memory instead of searching a tenant. */ + private class InMemoryDomainCache( + ctx: PackageContext<*>, + domains: List, + ) : DataDomainCache(ctx) { + private val byGuid = domains.associateBy { it.guid } + + override fun fetchById(guid: String): DataDomain? = byGuid[guid] + } + + private fun domain( + guid: String, + name: String, + parentGuid: String? = null, + ): DataDomain { + val builder = + DataDomain + ._internal() + .guid(guid) + .qualifiedName("default/domain/$name/super") + .name(name) + .status(AtlanStatus.ACTIVE) + if (parentGuid != null) builder.parentDomain(DataDomain.refByGuid(parentGuid)) + return builder.build() + } + + @Test + fun subDomainOnColdCacheResolvesToFullPath() { + val cache = + InMemoryDomainCache( + ctx, + listOf( + domain(PARENT_GUID, "Parent"), + domain(CHILD_GUID, "Child", PARENT_GUID), + ), + ) + // Same two calls DataDomainXformer.encodeFromGuid makes. + cache.getByGuid(CHILD_GUID) + assertEquals("Parent${DATA_DOMAIN_DELIMITER}Child", cache.getIdentity(CHILD_GUID)) + } + + @Test + fun nestedSubDomainOnColdCacheResolvesEveryAncestor() { + val cache = + InMemoryDomainCache( + ctx, + listOf( + domain(GRANDPARENT_GUID, "Grandparent"), + domain(PARENT_GUID, "Parent", GRANDPARENT_GUID), + domain(CHILD_GUID, "Child", PARENT_GUID), + ), + ) + cache.getByGuid(CHILD_GUID) + assertEquals( + "Grandparent${DATA_DOMAIN_DELIMITER}Parent${DATA_DOMAIN_DELIMITER}Child", + cache.getIdentity(CHILD_GUID), + ) + } +} diff --git a/samples/packages/asset-export-basic/src/main/kotlin/AssetExporter.kt b/samples/packages/asset-export-basic/src/main/kotlin/AssetExporter.kt index f6cecacff2..2d6714c188 100644 --- a/samples/packages/asset-export-basic/src/main/kotlin/AssetExporter.kt +++ b/samples/packages/asset-export-basic/src/main/kotlin/AssetExporter.kt @@ -55,6 +55,14 @@ class AssetExporter( CSVWriter(filename).use { csv -> csv.writeHeader(headerNames) val start = System.currentTimeMillis() + + // Preload the domain cache up-front so identities resolve in level-order + // (parents-before-children). Assets are streamed in parallel, so without this + // a sub-domain can be looked up before its parent is cached and get exported + // as just its own name rather than its full path -- which the import side + // then cannot resolve. + ctx.dataDomainCache.preload() + csv.streamAssets(assets.stream(true), this, assets.count(), batchSize, logger) logger.info { "Total time taken: ${System.currentTimeMillis() - start} ms" } } From 8a014bc1eb6a7813c45e75be416c036c20e55078 Mon Sep 17 00:00:00 2001 From: AtMtun Date: Thu, 5 Nov 2026 11:02:11 +0530 Subject: [PATCH 2/2] fix(domain-cache): resolve a sub-domain's missing parent by GUID getIdentityForAsset reads the parent's identity cache-only. On a cache that was not preloaded, a sub-domain fetched before its parent was cached as just its own name, so exports wrote Child instead of Parent@Child and the import could not resolve the domain. Fetch the missing parent first when looking a domain up by GUID. Co-Authored-By: Claude Opus 5.5 --- .../kotlin/com/atlan/pkg/cache/DataDomainCache.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/package-toolkit/runtime/src/main/kotlin/com/atlan/pkg/cache/DataDomainCache.kt b/package-toolkit/runtime/src/main/kotlin/com/atlan/pkg/cache/DataDomainCache.kt index 8e8df5b0aa..0aa4cd6374 100644 --- a/package-toolkit/runtime/src/main/kotlin/com/atlan/pkg/cache/DataDomainCache.kt +++ b/package-toolkit/runtime/src/main/kotlin/com/atlan/pkg/cache/DataDomainCache.kt @@ -26,7 +26,17 @@ open class DataDomainCache( /** {@inheritDoc} */ override fun lookupById(id: String?) { val result = id?.let { fetchById(it) } - if (result != null) cache(result.guid, getIdentityForAsset(result), result) + if (result != null) { + // getIdentityForAsset reads the parent's identity from the cache only. On a cache + // that was not preloaded, a sub-domain fetched before its parent would otherwise be + // cached under just its own name instead of its full path, so fetch (and cache) the + // missing parent first. This recurses up the hierarchy, one level per ancestor. + val parentGuid = result.parentDomain?.guid + if (parentGuid != null && getIdentity(parentGuid, true) == null) { + getByGuid(parentGuid) + } + cache(result.guid, getIdentityForAsset(result), result) + } } /**