Skip to content

fix(hubs): stop price transform row fan-out and fix eligibility scoping - #2246

Draft
Michael Flanakin (flanakin) wants to merge 1 commit into
flanakin/v15-prepfrom
flanakin/1736-1625-prices-transform
Draft

fix(hubs): stop price transform row fan-out and fix eligibility scoping#2246
Michael Flanakin (flanakin) wants to merge 1 commit into
flanakin/v15-prepfrom
flanakin/1736-1625-prices-transform

Conversation

@flanakin

Copy link
Copy Markdown
Collaborator

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 in Prices_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_2 are Data Explorer update policy functions. When a pricesheet export lands as multiple parquet-snappy files, each file triggers a separate invocation of the update policy, so Prices_raw as 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:

| lookup kind=leftouter (prices | where x_SkuPriceType == 'Consumption' | where x_SkuMeterId in (spMeters) | distinct tmp_SavingsPlanKey, ListUnitPrice, ContractedUnitPrice, x_BaseUnitPrice) on tmp_SavingsPlanKey

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 different ListUnitPrice/ContractedUnitPrice/x_BaseUnitPrice. distinct over those columns does not guarantee one row per key, so the dimension side of the lookup can have >1 row per tmp_SavingsPlanKey, and lookup kind=leftouter fans out every matching SavingsPlan row on the left — exactly the guideline this repo's docs-wiki/Coding-guidelines.md already documents (distinct Key, Col1, Col2 fans out; use summarize take_any(...) by Key instead). 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_SavingsPlanKey on the dimension side, guaranteeing one row per key.

#1625 (eligibility correctness) — confirmed and fixed, not just documented:

let riMeters = prices | where x_SkuPriceType == 'ReservedInstance' | distinct x_SkuMeterId;
let spMeters = prices | where x_SkuPriceType == 'SavingsPlan' | distinct x_SkuMeterId;
...
| extend x_CommitmentDiscountSpendEligibility = iff(x_SkuMeterId in (riMeters) and x_SkuPriceType != 'ReservedInstance', 'Eligible', 'Not Eligible')
| extend x_CommitmentDiscountUsageEligibility = iff(x_SkuMeterId in (spMeters), 'Eligible', 'Not Eligible')

This self-references prices (derived from Prices_raw) to build riMeters/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.csv already exists (added in #2164, refreshed weekly from the Azure Retail Prices API, MeterId confirmed 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:

  • Adds a CommitmentDiscountEligibility ADX table (IngestionSetup_HubInfra.kql), alongside the existing PricingUnits/Regions/ResourceTypes/Services reference tables.
  • Adds an .set-or-replace ... externaldata(...) pipeline activity in app.bicep to load the CSV, following the exact same pattern as those tables (same dependency chain position, same command shape).
  • Replaces the riMeters/spMeters self-referencing logic with lookup kind=leftouter (CommitmentDiscountEligibility) on x_SkuMeterId, dimension side deduped with summarize take_any(...) by MeterId per 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 against CommitmentDiscountEligibility.
  • src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_HubInfra.kql: added the CommitmentDiscountEligibility table schema.
  • src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/app.bicep: added the Update CommitmentDiscountEligibility in ADX pipeline activity, spliced into the existing dependency chain (after Update Services in ADX, before Ingestion Complete).
  • docs-mslearn/toolkit/changelog.md: unreleased entries for both fixes.

Verification

  • bicep build on the modified app.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.
  • Manually confirmed table-creation ordering: ingestion_VersionedScripts (which deploys the transform functions) dependsOn ingestion_InitScripts (which deploys IngestionSetup_HubInfra.kql, creating CommitmentDiscountEligibility before it's referenced).

Open questions / risks / scope not covered

  • Did not attempt option 1 or 2 from the Prices_transform_v1_0 function does not work as intended #1625 discussion (changing ingestion to process all files at once, or a post-ingestion .update command) — the open-data lookup is less invasive and matches RolandKrummenacher's preferred direction.
  • Prices_transform_v1_0 is marked DEPRECATED in its own docstring ("Use Prices_transform_v1_2() instead"), but issue [Hubs] Price ingestion creates extra rows #1736's reported counts were specifically against Prices_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.
  • The CommitmentDiscountEligibility open-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.
  • Did not add new Pester coverage asserting the eligibility values end-to-end (e.g., a fixture-driven test of 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

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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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