fix(hubs): stop price transform row fan-out and fix eligibility scoping - #2246
Draft
Michael Flanakin (flanakin) wants to merge 1 commit into
Draft
fix(hubs): stop price transform row fan-out and fix eligibility scoping#2246Michael Flanakin (flanakin) wants to merge 1 commit into
Michael Flanakin (flanakin) wants to merge 1 commit into
Conversation
Prices_transform_v1_0/v1_2 are Data Explorer update policy functions, so each parquet-snappy file in a pricesheet export triggers a SEPARATE invocation that only sees that file's rows of Prices_raw (#1625). Two independent bugs stem from this: - The savings plan price lookup deduped its Consumption dimension side with `distinct` over columns that vary by region/currency, so a meter with multiple regional prices sharing the same tmp_SavingsPlanKey fanned out every matching savings plan row (Prices_final row count exceeding Prices_raw, #1736). Switched to `summarize take_any(...) by tmp_SavingsPlanKey` for a guaranteed one-row-per-key dimension side, per the lookup/join guidance in docs-wiki/Coding-guidelines.md. - Commitment discount eligibility was derived from `riMeters`/ `spMeters` built from Prices_raw within the same invocation, so a meter's Reservation/SavingsPlan row and Consumption row could be split across invocations and evaluated against a partial view (#1625). Eligibility is now sourced from the CommitmentDiscountEligibility open-data table (already used for the commitment eligibility fetch in #2164), wired into ADX as a new reference table alongside PricingUnits/Regions/ResourceTypes/Services, which isn't affected by per-invocation partitioning. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Michael Flanakin (flanakin)
requested review from
Brett Wilson (MSBrett) and
Roland Krummenacher (RolandKrummenacher)
as code owners
August 12, 2026 17:21
Michael Flanakin (flanakin)
marked this pull request as draft
August 13, 2026 06:27
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Root-cause findings
Both issues trace back to a shared mechanism (per-invocation partial visibility of
Prices_raw) but manifest as two distinct bugs, both present inPrices_transform_v1_0()/Prices_transform_v1_2()(src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_0.kql,IngestionSetup_v1_2.kql):Prices_transform_v1_0/v1_2are Data Explorer update policy functions. When a pricesheet export lands as multipleparquet-snappyfiles, each file triggers a separate invocation of the update policy, soPrices_rawas seen inside the function during any one execution is only that file's subset of rows (confirmed root cause of #1625).#1736 (row inflation) — confirmed, distinct mechanism from the eligibility gap:
The savings plan enrichment step:
tmp_SavingsPlanKey = strcat(x_SkuMeterId, x_SkuProductId, x_SkuId, x_SkuTier, x_SkuOfferId)— it does not include region, billing profile, or currency. Azure meter prices vary by region/currency, so the same key can legitimately have multiple Consumption rows with differentListUnitPrice/ContractedUnitPrice/x_BaseUnitPrice.distinctover those columns does not guarantee one row per key, so the dimension side of thelookupcan have >1 row pertmp_SavingsPlanKey, andlookup kind=leftouterfans out every matching SavingsPlan row on the left — exactly the guideline this repo'sdocs-wiki/Coding-guidelines.mdalready documents (distinct Key, Col1, Col2fans out; usesummarize take_any(...) by Keyinstead). This reproduces within a single update-policy invocation whenever one file contains multi-region/multi-currency Consumption rows for the same meter+product+SKU — it doesn't strictly require the per-file scoping bug, though more files per invocation increases the chance of collisions.Fix:
summarize take_any(ListUnitPrice), take_any(ContractedUnitPrice), take_any(x_BaseUnitPrice) by tmp_SavingsPlanKeyon the dimension side, guaranteeing one row per key.#1625 (eligibility correctness) — confirmed and fixed, not just documented:
This self-references
prices(derived fromPrices_raw) to buildriMeters/spMeters, which only contain meters visible in the current invocation. If a meter's Reservation/SavingsPlan row lands in one file and its Consumption row lands in another, they're evaluated in separate invocations and eligibility comes out wrong — this is the literal mechanism discussed in #1625.Fix (option 3 from the #1625 discussion — already half-built):
src/open-data/CommitmentDiscountEligibility.csvalready exists (added in #2164, refreshed weekly from the Azure Retail Prices API,MeterIdconfirmed unique — 0 duplicates checked) with exactly the two eligibility columns the transform computes. It just wasn't wired into the hub database yet. This PR:CommitmentDiscountEligibilityADX table (IngestionSetup_HubInfra.kql), alongside the existingPricingUnits/Regions/ResourceTypes/Servicesreference tables..set-or-replace ... externaldata(...)pipeline activity inapp.bicepto load the CSV, following the exact same pattern as those tables (same dependency chain position, same command shape).riMeters/spMetersself-referencing logic withlookup kind=leftouter (CommitmentDiscountEligibility) on x_SkuMeterId, dimension side deduped withsummarize take_any(...) by MeterIdper the join/lookup guidelines. Eligibility is now global (not per-invocation-partial), so this closes the root cause of Prices_transform_v1_0 function does not work as intended #1625, not just a symptom.The RI-exclusion semantics (
x_SkuPriceType != 'ReservedInstance'— a Reservation-priced row is never itself "eligible for reservation") and the "unmatched meter defaults to Not Eligible" behavior are both preserved.What changed
src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_0.kql,IngestionSetup_v1_2.kql: fixed the savings-plan lookup fan-out; replaced self-referencing eligibility calc with a lookup againstCommitmentDiscountEligibility.src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_HubInfra.kql: added theCommitmentDiscountEligibilitytable schema.src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/app.bicep: added theUpdate CommitmentDiscountEligibility in ADXpipeline activity, spliced into the existing dependency chain (afterUpdate Services in ADX, beforeIngestion Complete).docs-mslearn/toolkit/changelog.md: unreleased entries for both fixes.Verification
bicep buildon the modifiedapp.bicep— compiles cleanly.Invoke-Pester -Path src/powershell/Tests/Lint/KqlJoinKinds.Tests.ps1— 436/436 passed (no bare joins, no ARG-rejected operators introduced).Invoke-Pester -Path src/powershell/Tests/Unit/HubsKqlOperators.Tests.ps1— 142/142 passed.Invoke-Pester -Path src/powershell/Tests/Unit/HubsIngestionQueries.Tests.ps1,src/powershell/Tests/Unit/HubsContractedCostGuard.Tests.ps1,src/powershell/Tests/Unit/HubsAdfTriggerTimeZones.Tests.ps1— no failures.ingestion_VersionedScripts(which deploys the transform functions)dependsOningestion_InitScripts(which deploysIngestionSetup_HubInfra.kql, creatingCommitmentDiscountEligibilitybefore it's referenced).Open questions / risks / scope not covered
.updatecommand) — the open-data lookup is less invasive and matches RolandKrummenacher's preferred direction.Prices_transform_v1_0is markedDEPRECATEDin its own docstring ("Use Prices_transform_v1_2() instead"), but issue [Hubs] Price ingestion creates extra rows #1736's reported counts were specifically againstPrices_transform_v1_0, so I fixed both versions for consistency; happy to drop the v1_0 change if it's considered out of scope for a deprecated path.CommitmentDiscountEligibilityopen-data CSV depends on the Azure Retail Prices API weekly refresh (Update-CommitmentDiscountEligibility.ps1) being current; a brand-new meter that hasn't been picked up by that refresh yet would default to "Not Eligible" until the next weekly run, versus the old logic which (when it worked) reflected same-day eligibility from the pricesheet export itself. This is a minor freshness trade-off in exchange for correctness/completeness.Prices_transform_v1_2()output) — no existing test harness runs the KQL transform logic itself (only lint/static checks), so this would be new test infrastructure; flagging as a possible follow-up rather than in scope here.Fixes #1736
Refs #1625