Fix KQL join pitfalls and adopt lookup for dimension enrichment - #2225
Fix KQL join pitfalls and adopt lookup for dimension enrichment#2225RolandKrummenacher wants to merge 12 commits into
Conversation
- SavingsPlan summary/details: the final resourcecontainers join had no kind and defaulted to innerunique, deduplicating the left side by subscription and silently dropping all but one savings plan recommendation per subscription. Now kind=inner. - AHB "VM Latest Change Last 7 days": joined resourcechanges record id against resources id, which never match, so the tile was always empty. Now joins on properties.targetResourceId (lowercased both sides) and uses kind=inner so mv-expanded license change rows are not collapsed. - Get-SQL-AHB-Disabled/Enabled: bare join on VMName dropped SQL VMs with duplicate names across resource groups/subscriptions (innerunique) and never matched VMs with uppercase names (left was original-case name, right tolower(name)). Now joins on the SQL VM properties.virtualMachineResourceId against the VM resource id with kind=inner, and the tag-filter semi-join states kind=inner explicitly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Azure Resource Graph joins without an explicit kind default to innerunique, which deduplicates the left side on the join key and can silently drop rows. Make every bare join an explicit kind=inner in the hub recommendation queries and the finops-alerts logic app. Recommendations-Microsoft-SQLVMsWithoutAHB additionally joined SQL VMs to compute VMs on VMName with mismatched casing (left original case, right tolower), so VMs with uppercase names never matched, and duplicate VM names across resource groups collapsed. It now joins the SQL VM properties.virtualMachineResourceId against the VM resource id. docs/deploy/finops-alerts-*.json are generated from logicApp.bicep and intentionally not hand-edited; the next release build picks this up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The legacy EA transform functions in IngestionSetup_v1_0/v1_2 enriched cost rows with the open-data dimension tables (PricingUnits, Regions, ResourceTypes, Services) via join kind=leftouter. That shape is exactly what the lookup operator is built for: the large fact table stays on the left, the small dimension table is broadcast, and the duplicated join key columns (x_PricingUnitDescription1, ResourceLocation1, ...) are not emitted. No downstream code referenced the suffixed columns, so output is unchanged aside from dropping them before the final project. Also guard the Services enrichment against row fan-out: Services is not unique per x_ResourceType (a resource type can map to multiple consumed services), so joining its raw projection could duplicate cost rows. Dedupe with summarize take_any(...) by x_ResourceType, matching the pattern already used for the x_ConsumedService fallback, and apply the same dedup to the existing distinct-based lookups in the FOCUS transforms and HubSetup_v1_2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…talog tagging-policy-compliance, storage-tier-distribution, and macc-consumption-vs-commitment all join a large fact stream to a small, key-unique aggregate. Switch those joins to lookup so the small side is broadcast and the duplicated join key columns are not emitted. The biggest win is tagging-policy-compliance, where the full Costs() row set was previously the left side of a hash join against the distinct-tags dimension. Output schemas are unchanged; the suffixed key columns were never referenced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- compute.md AHB queries: bare ARG joins defaulted to innerunique; the SQL VM example additionally joined on VMName with mismatched casing (left original case vs right tolower), so VMs with uppercase names never matched and duplicate names across resource groups were silently dropped. Joins are now explicit kind=inner and the SQL VM example joins properties.virtualMachineResourceId to the VM id. - compute.md commitment coverage queries: switch the Prices dimension join to lookup kind=leftouter, the recommended pattern for enriching the large Costs table from a small key-unique aggregate. - finops-hub-database-guide.md / ftk-database-query.md: "on 1 == 1" is not a valid KQL join predicate; rewrite the percent-of-total examples with toscalar(), which is also cheaper (no second full-table join). - cost-spike/service-cost skill references: coalesce the join keys after kind=fullouter so baseline-only rows keep their dimension values instead of rendering with empty names. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
✅ Validation results (FTK test tenant + ftk-dev ADX cluster)ARG semantics probe — confirmed ARG bare Services open-data fan-out (commit 3) — confirmed against Hub ingestion lookup chain (commit 3) — the exact edited fragment (PricingUnits → Regions → ResourceTypes → Services ×2) executed against the Catalog queries (commit 4) — old vs new run back-to-back on
Recommendation queries + AHB/SavingsPlan workbook queries (commits 1-2) — all executed via the ARG REST API with parameters substituted: parse and run cleanly, incl. the 4-join SavingsPlan queries (ARG accepted 4 joins). Old vs new SavingsPlan counts match in this tenant (1 recommendation per subscription, so Not validated: row-level semantics of the SQL VM / public IP / app gateway queries (test tenant has no IaaS resources — all returned 0 rows, syntax-only), and the logic app queries (validated indirectly via identical shapes + 🤖 Generated with Claude Code |
Adds Tests/Lint/KqlJoinKinds.Tests.ps1, which scans every KQL-carrying surface (hub scripts, query catalog, ARG recommendation queries, ADX dashboard, finops-alerts logic app, workbooks, optimization engine runbooks and views, docs-mslearn best-practices examples) and fails on any bare "| join" without an explicit kind, since the innerunique default deduplicates the left side and silently drops rows. Remaining pre-existing bare joins (48 across 4 workbook files, all with unique left keys today) are baselined per file as a ratchet: counts can only go down, and lowering is enforced when a file is cleaned up. Also brings two surfaces to zero so they need no baseline: the SQL DB optimization runbook (2 bare joins, left side unique per ResourceId, so kind=inner preserves behavior) and the networking.md doc examples (2 bare joins in the backendless app gateway and idle public IP queries, published as copy-paste guidance). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live validation of the networking.md idle public IP query failed with DisallowedLogicalTableName: the joined subquery referenced "resource" instead of "resources", so the published example never ran. Found while verifying the explicit join kinds added in this PR; the corrected query now executes against Azure Resource Graph. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
✅ Validation round 2 — full coverage of everything testableCompleting the earlier validation, every remaining testable change has now been run against live services (ARG REST API on the FTK test tenant; ARG (now all executed):
Hub ADX cluster (old vs new,
Transform functions (compile-checked against real schemas):
Mirrors: the three edited queries in Remaining untestable in this environment: row-level semantics of the SQL VM / public IP / app gateway ARG queries (test tenant has no IaaS — all validated as executing with 0 rows). 🤖 Generated with Claude Code |
📝 Changes
Follow-up to a repo-wide review of KQL
joinvslookupbest practices (~880 join/lookup usages examined across hub database scripts, workbooks, ARG recommendation queries, the query catalog, and docs). This PR fixes the high-severity correctness bugs, adoptslookupwhere it is the documented best practice, and adds a lint rule so bare joins cannot come back. One commit per surface:resourcecontainersjoin had nokindand defaulted toinnerunique, deduplicating results by subscription and silently dropping all but one savings plan recommendation per subscription. Nowkind=inner.resourcechangesrecord id againstresources.id, which never match, so the tile was always empty. Now joinsproperties.targetResourceId(lowercased both sides) withkind=innerso mv-expanded license changes are preserved.VMNamedropped SQL VMs with duplicate names (innerunique) and never matched uppercase names (left original case vs righttolower). Now joinsproperties.virtualMachineResourceIdagainst the VM resource id.| join (made an explicitkind=inner;Recommendations-Microsoft-SQLVMsWithoutAHBgets the samevirtualMachineResourceIdjoin-key fix as above.docs/deploy/finops-alerts-*.jsonare generated and intentionally untouched; the next release build picks up the bicep change.PricingUnits,Regions,ResourceTypes,Services) withjoin kind=leftouter; converted tolookup kind=leftouter(broadcasts the small dimension, no duplicated key columns; no downstream references to the suffixed columns existed). Also guards theServicesenrichment against row fan-out withsummarize take_any(...) by x_ResourceType— this was an active bug: Services.csv has 30 duplicate resource-type keys (up to ×31 formicrosoft.sql/locations), so cost rows for those types were being multiplied.tagging-policy-compliance,storage-tier-distribution,macc-consumption-vs-commitment: fact-to-small-dimension joins converted tolookup.lookup, invalidjoin ... on 1 == 1percent-of-total examples rewritten withtoscalar(), andfullouterexamples now coalesce their join keys so baseline-only rows keep their dimension values.Tests/Lint/KqlJoinKinds.Tests.ps1scans every KQL-carrying surface (hub scripts, catalog, recommendation queries, dashboard, logic app, workbooks, optimization engine, docs examples) and fails on any bare| joinwithout an explicitkind=. The 48 remaining pre-existing bare joins (4 workbook files, all with unique left keys today) are baselined per file as a ratchet: counts can only go down. Two more surfaces were brought to zero in this commit: the SQL DB optimization runbook (2 bare joins, behavior-preservingkind=inner) and networking.md doc examples (2 bare joins the original review sweep missed).✅ Validation
See the validation comment for live-environment results: ARG innerunique default proven with a controlled probe, Services fan-out confirmed from open data, ingestion lookup chain executed against a hub ADX cluster, and old-vs-new catalog queries verified equivalent on 1.16M cost rows. The new lint suite passes: 161/161.
leftouter+isemptyanti-join emulations in recommendations.json that can inflate counts, 38 subscription-dimension joins without dedup, aStorageReplicationfan-out that double-counts cost). Separately owned surface; deserves its own PR. (Its two bare joins are fixed here so the lint starts clean.)kind=inneruniquetag-filter semi-joins across workbooks (style only; left keys unique today). The lint baseline ratchets these down as files are touched.lookup(maintenance-only query, left as is).🤖 Generated with Claude Code