From 72b1de91aad2085e0973ec0732ebdee66c63433a Mon Sep 17 00:00:00 2001 From: sriram-atlan Date: Fri, 22 May 2026 11:54:17 +0530 Subject: [PATCH] test: log+skip anchorless orphan terms in TermCache instead of throwing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TermCache.refreshCache() scans every active GlossaryTerm in the tenant and calls getIdentityForAsset(term) on each. That method throws IllegalStateException("Term found with no anchor: ...") if the term's anchor relationship can't be resolved to a glossary name. The throw is unconditional — a single inconsistent term aborts the entire cache refresh, and every downstream test that depends on the cache being initialised then fails with the same stack trace. This is exactly what happened on the leangraph-test daily workflow run 26269147160: another nightly job (atlas-metastore dev-support/test-harness suite test_glossary_qn_moves.py, cron 04:30 UTC) created `move-*` terms, moved them between glossaries, and a partially-failed cleanup pass left 6 ACTIVE terms whose anchor edge had relationshipStatus=DELETED. The atlan-java workflow (dispatched 22 min later) then crashed every asset-import chunk with: java.lang.IllegalStateException: Term found with no anchor: { ... } The anchor inconsistency is real data and there are deeper fixes warranted elsewhere (test harness should fully PURGE its residue, workflow schedules shouldn't overlap on the shared tenant). But the SDK should not blow up everyone else's tests when it encounters a single tenant-side anomaly. Wrap the call in identityForAssetOrLog() — catch IllegalStateException, log a structured warning identifying the offending term's guid + name, return null. Both call sites (refreshCache + lookupById) treat null as "skip this term, continue with the rest of the cache." getIdentityForAsset itself still throws (it's the right contract — the data IS inconsistent and code that depends on a resolved identity should fail loudly); the new safe variant is opt-in at the call sites that perform bulk scans. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../kotlin/com/atlan/pkg/cache/TermCache.kt | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/package-toolkit/runtime/src/main/kotlin/com/atlan/pkg/cache/TermCache.kt b/package-toolkit/runtime/src/main/kotlin/com/atlan/pkg/cache/TermCache.kt index 7cde09fa99..dd8fa4ff63 100644 --- a/package-toolkit/runtime/src/main/kotlin/com/atlan/pkg/cache/TermCache.kt +++ b/package-toolkit/runtime/src/main/kotlin/com/atlan/pkg/cache/TermCache.kt @@ -65,7 +65,10 @@ class TermCache( /** {@inheritDoc} */ override fun lookupById(id: String?) { val result = lookupById(id, 0, ctx.client.maxNetworkRetries) - if (result != null) cache(result.guid, getIdentityForAsset(result), result) + if (result != null) { + val identity = identityForAssetOrLog(result) ?: return + cache(result.guid, identity, result) + } } /** {@inheritDoc} */ @@ -108,6 +111,28 @@ class TermCache( "${asset.name}${GlossaryXformer.GLOSSARY_DELIMITER}$glossaryName" } ?: throw IllegalStateException("Term found with no anchor: ${asset.toJson(client)})") + /** + * Safe variant of [getIdentityForAsset] for tenant-wide scans. Catches the "term has no + * anchor" inconsistency that can result from soft-deleted anchor edges left behind by + * test residue or partially-failed move operations, logs it, and returns null so the + * caller can skip caching this one term rather than aborting the entire refresh. + * + * Without this guard a single orphan term causes [refreshCache] to throw, which in turn + * fails downstream asset-import tests that depend on the term cache being initialised — + * unrelated tests blow up because of unrelated data anomalies elsewhere in the tenant. + */ + private fun identityForAssetOrLog(asset: GlossaryTerm): String? = + try { + getIdentityForAsset(asset) + } catch (e: IllegalStateException) { + logger.warn { + "Skipping term ${asset.guid} (name='${asset.name}') with no resolvable anchor — likely an orphan " + + "from a soft-deleted anchor edge. The term is still ACTIVE but its anchor relationship has " + + "no ACTIVE edge to a glossary. Cache will continue without this entry." + } + null + } + /** {@inheritDoc} */ override fun refreshCache() { val count = GlossaryTerm.select(client).count() @@ -119,7 +144,8 @@ class TermCache( .stream(true) .forEach { term -> term as GlossaryTerm - cache(term.guid, getIdentityForAsset(term), term) + val identity = identityForAssetOrLog(term) ?: return@forEach + cache(term.guid, identity, term) } } }