Skip to content

Fix KQL join pitfalls and adopt lookup for dimension enrichment - #2225

Draft
RolandKrummenacher wants to merge 12 commits into
devfrom
fix/kql-join-lookup-best-practices
Draft

Fix KQL join pitfalls and adopt lookup for dimension enrichment#2225
RolandKrummenacher wants to merge 12 commits into
devfrom
fix/kql-join-lookup-best-practices

Conversation

@RolandKrummenacher

@RolandKrummenacher RolandKrummenacher commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

📝 Changes

Follow-up to a repo-wide review of KQL join vs lookup best 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, adopts lookup where it is the documented best practice, and adds a lint rule so bare joins cannot come back. One commit per surface:

  1. Workbooks (SavingsPlan, AHB ×2) — correctness fixes:
    • SavingsPlan summary/details: the final resourcecontainers join had no kind and defaulted to innerunique, deduplicating results by subscription and silently dropping all but one savings plan recommendation per subscription. Now kind=inner.
    • AHB "VM Latest Change Last 7 days": joined the resourcechanges record id against resources.id, which never match, so the tile was always empty. Now joins properties.targetResourceId (lowercased both sides) with kind=inner so mv-expanded license changes are preserved.
    • Get-SQL-AHB-Disabled/Enabled: bare join on VMName dropped SQL VMs with duplicate names (innerunique) and never matched uppercase names (left original case vs right tolower). Now joins properties.virtualMachineResourceId against the VM resource id.
  2. ARG recommendation queries + finops-alerts logic app — every bare | join ( made an explicit kind=inner; Recommendations-Microsoft-SQLVMsWithoutAHB gets the same virtualMachineResourceId join-key fix as above. docs/deploy/finops-alerts-*.json are generated and intentionally untouched; the next release build picks up the bicep change.
  3. Hub ingestion scripts — the v1_0/v1_2 EA transforms enriched cost rows from the open-data dimension tables (PricingUnits, Regions, ResourceTypes, Services) with join kind=leftouter; converted to lookup kind=leftouter (broadcasts the small dimension, no duplicated key columns; no downstream references to the suffixed columns existed). Also guards the Services enrichment against row fan-out with summarize take_any(...) by x_ResourceTypethis was an active bug: Services.csv has 30 duplicate resource-type keys (up to ×31 for microsoft.sql/locations), so cost rows for those types were being multiplied.
  4. Query catalogtagging-policy-compliance, storage-tier-distribution, macc-consumption-vs-commitment: fact-to-small-dimension joins converted to lookup.
  5. Docs — compute.md AHB examples (bare joins + the same VMName case bug, published as copy-paste guidance), commitment-coverage examples now teach lookup, invalid join ... on 1 == 1 percent-of-total examples rewritten with toscalar(), and fullouter examples now coalesce their join keys so baseline-only rows keep their dimension values.
  6. Lint rule — new Tests/Lint/KqlJoinKinds.Tests.ps1 scans every KQL-carrying surface (hub scripts, catalog, recommendation queries, dashboard, logic app, workbooks, optimization engine, docs examples) and fails on any bare | join without an explicit kind=. 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-preserving kind=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.

⚠️ Not addressed here (follow-ups)

  • Optimization Engine query quality: the review found the same patterns at larger scale (140 leftouter+isempty anti-join emulations in recommendations.json that can inflate counts, 38 subscription-dimension joins without dedup, a StorageReplication fan-out that double-counts cost). Separately owned surface; deserves its own PR. (Its two bare joins are fixed here so the lint starts clean.)
  • 48 baselined bare joins + ~75 explicit kind=innerunique tag-filter semi-joins across workbooks (style only; left keys unique today). The lint baseline ratchets these down as files are touched.
  • The SavingsPlan queries still contain 4 joins, above ARG''s documented 3-join guidance (pre-existing; ARG accepted them in testing).
  • open-data README region refresh query could use lookup (maintenance-only query, left as is).

🤖 Generated with Claude Code

Roland Krummenacher and others added 5 commits August 2, 2026 21:05
- 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>
Roland Krummenacher and others added 2 commits August 2, 2026 21:20
@RolandKrummenacher

Copy link
Copy Markdown
Collaborator Author

✅ Validation results (FTK test tenant + ftk-dev ADX cluster)

ARG semantics probe — confirmed ARG bare join defaults to innerunique: a 40-row left side with one distinct key returns 1 row bare vs 40 rows with kind=inner. The bug class this PR fixes is real.

Services open-data fan-out (commit 3) — confirmed against src/open-data/Services.csv: 30 resource types have duplicate rows, e.g. microsoft.apimanagement/service ×4 and microsoft.sql/locations ×31. Before this PR, the v1_0/v1_2 EA ingestion transforms multiplied every cost row for those resource types by that factor. The take_any dedup eliminates this.

Hub ingestion lookup chain (commit 3) — the exact edited fragment (PricingUnits → Regions → ResourceTypes → Services ×2) executed against the ftk-dev Ingestion database: compiles, returns the expected schema, no duplicated key columns, non-key conflicts suffixed identically to join (so downstream projections are unaffected).

Catalog queries (commit 4) — old vs new run back-to-back on ftk-dev Hub DB (1,162,170 Costs() rows):

  • macc-consumption-vs-commitment: byte-identical
  • storage-tier-distribution: identical rows (order differs; query has no order by)
  • tagging-policy-compliance: identical to 9 decimal places (float summation order)

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 innerunique happens to coincide — the probe above shows the general case). resourcechanges.properties.targetResourceId verified populated and resource-id-shaped on 376/376 rows, confirming the new VM Latest Change join key; the old key (resourcechanges record id) can never match a resource id.

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 + bicep build).

🤖 Generated with Claude Code

Roland Krummenacher and others added 3 commits August 2, 2026 22:13
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>
@RolandKrummenacher

Copy link
Copy Markdown
Collaborator Author

✅ Validation round 2 — full coverage of everything testable

Completing the earlier validation, every remaining testable change has now been run against live services (ARG REST API on the FTK test tenant; ftk-dev ADX cluster for hub KQL):

ARG (now all executed):

  • finops-alerts logicApp.bicep: both edited queries extracted from the bicep, variables('resourcesTable') substituted, executed — PASS.
  • compute.md Windows-AHB + SQL-AHB examples — PASS.
  • networking.md backendless app gateway + idle public IP examples — the idle-IP query failed on first run and exposed a pre-existing bug: the published example referenced table resource instead of resources (DisallowedLogicalTableName), so it never ran at all. Fixed in a follow-up commit; now PASS.

Hub ADX cluster (old vs new, Hub DB, 1.16M cost rows):

  • compute.md commitment-coverage queries: join→lookup versions return identical row counts (3 / 100).
  • on 1 == 1 rewrites (finops-hub-database-guide.md, ftk-database-query.md): old versions fail with General_BadRequest (confirming the invalid predicate), new toscalar() versions pass with real data (6 / 44 rows).
  • fullouter key-coalesce fixes: same row counts old vs new, but old had 1 (cost-spike) and 3 (service-deep-dive) rows with an empty key column; new versions have 0 — exactly the fix intent.

Transform functions (compile-checked against real schemas):

  • All 6 edited ingestion transforms (ActualCosts/AmortizedCosts/Costs_transform_v1_0/v1_2) extracted from the scripts and executed with | take 0 against the Ingestion DB (raw tables present) — all compile.
  • Both edited HubSetup_v1_2 functions (CommitmentDiscountUsage_v1_2, Costs_v1_2) compile against the Hub DB.

Mirrors: the three edited queries in Compute/AHB.workbook verified byte-identical to the live-tested AHB/AHB.workbook copies.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Needs: Review 👀 PR that is ready to be reviewed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants