Replace serviceFamily sharding with a single traversal per price type - #2251
Replace serviceFamily sharding with a single traversal per price type#2251Roland Krummenacher (RolandKrummenacher) wants to merge 15 commits into
Conversation
… type The weekly Update Commitment Discount Eligibility job exceeded its 60-minute timeout on run 31612943595 and was killed 13 of 29 shards into the Consumption fetch, so it still published nothing. The sharding and multi-pass union existed to work around a period when the Retail Prices API returned an unstable page order. That instability was last observed in early June 2026; MS support case 2606030050003725 closed without a root cause, seven full traversals on 2026-06-29 were byte-identical, and the workaround's own telemetry in run 31612943595 showed every repeat pass across all 22 completed shards adding exactly zero meters. Building the shard list also required a full discovery traversal per price type, which alone consumed 25 of the job's 60 minutes. Each price type is now walked once, following NextPageLink verbatim, with the per-serviceFamily counts tallied in the same pass (every item already carries serviceFamily, so the per-family completeness guard costs nothing extra). Both completeness guards are unchanged; if the instability returns they abort the run loudly rather than publishing partial data. Also fixes two latent bugs found while validating: - Retry-After was never honored. It was read via $Response.Headers['Retry-After'], but HttpResponseHeaders has no string indexer, so that silently evaluated to $null and every 429 fell through to the exponential backoff. Now reads the typed .RetryAfter property, handling both the delta-seconds and HTTP-date forms, clamped so an outsized value cannot stall the job past its timeout. - $cachedShardCounts['Reservation'] threw "Cannot index into a null array" when no baseline sidecar exists. Since the job has never completed a successful run the sidecar has never existed, so this is the path every first run takes; it would have failed the run even with the timeout raised. Validated against the live API: two independent full runs both walked 146,671 Reservation items over 147 pages and 689,723 Consumption items over 690 pages, producing an identical 66,199 RI-eligible and 82,677 SP-eligible meters. Both match the sharded run 31612943595 exactly, per service family as well as in aggregate. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
There was a problem hiding this comment.
Pull request overview
This PR updates the commitment discount eligibility refresh script to complete within the workflow’s 60-minute timeout by removing the prior serviceFamily sharding/multi-pass union approach and replacing it with a single NextPageLink traversal per price type, while keeping the existing aggregate + per-family completeness guards as the data integrity mechanism.
Changes:
- Replace multi-pass sharded fetch with a single traversal per
priceType, collecting eligible meter IDs and per-serviceFamilycounts in the same pass. - Fix retry behavior to honor
Retry-Aftervia typed headers, including parsing/clamping logic. - Update unit tests to cover the new helper functions (
Get-RetryDelay,Get-EligibleMeter) and the revised fetch/count behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/scripts/Update-CommitmentDiscountEligibility.ps1 | Removes sharding/repeat passes; adds single-pass traversal with per-family counting, typed Retry-After handling, and guarded baseline lookups. |
| src/powershell/Tests/Unit/Update-CommitmentDiscountEligibility.Tests.ps1 | Updates AST-extracted helper coverage and adds regression tests for Retry-After and the new traversal/count helper. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
Addresses review feedback on #2251. Sort the per-family baseline by key before serializing. Hashtable enumeration order is not guaranteed, and the run that validated this PR wrote the families in neither alphabetical nor insertion order. Because the workflow treats ANY diff in shardcounts.json as "data changed", a reshuffle alone would push a branch and ask for a PR with no count actually having moved. ConvertTo-SortedMap makes the file a function of its contents alone. Add #Requires -Version 7.0. The script uses ConvertFrom-Json -AsHashtable, Export-Csv -UseQuotes, and reads Invoke-RestMethod's error response as an HttpResponseMessage, none of which work on Windows PowerShell 5.1. The workflow runs `shell: pwsh` so CI is unaffected; this only gives a clear message to someone running the script by hand. Unit tests go from 17 to 22, covering sort order, value preservation, empty input, family names with spaces and symbols, and the actual regression: that two maps differing only in insertion order serialize identically. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…dry run This job runs weekly and takes roughly fifteen minutes to fail, and its checkout step was pinned to `ref: dev`, so a change to the fetch script could not be exercised end-to-end until after it merged. That is a poor feedback loop for a job that has not completed a successful run in months. Adds two workflow_dispatch inputs: - `ref` (default `dev`) selects the branch to check out and run from, so a script change can be validated from its PR branch. The scheduled run is unaffected: github.event.inputs is null for a schedule, so the expression falls back to dev. - `dry_run` (default false) runs the fetch and the change detection but skips the branch push, so a test leaves no stray opendata/* branches behind. It writes a summary showing what would have changed. dry_run is compared as a string in bash rather than tested in a GitHub `if:` expression, because a `type: boolean` input arrives as the literal "false", which is truthy in expression syntax and would have inverted the check. Only users with write access can dispatch a workflow, so the `ref` input does not widen who can run code in the repo. Verified by extracting the run block and exercising all four paths against a scratch repository: dry run with changes (detects both the modified CSV and a newly created sidecar, pushes nothing), no changes, scheduled run with an empty DRY_RUN, and an explicit DRY_RUN=false. The last two both take the push path. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
|
Added 528a5f1, which touches the workflow rather than the script — flagging it explicitly since it widens this PR beyond the fetch logic. Motivation: the checkout step was pinned to
One subtlety worth noting for review: I verified this by extracting the run block and exercising all four paths against a scratch repository — dry run with changes (correctly detects both the modified CSV and a newly created sidecar, pushes nothing), no changes, scheduled run with an empty Happy to split this into its own PR if you would rather keep this one to the script. |
The comment claimed the sidecar "has never existed in CI (the job has never completed a successful run)". That is wrong: #2164 committed CommitmentDiscountEligibility.shardcounts.json alongside the CSV on 2026-08-12, so it is present on dev and $cachedShardCounts is not null in a normal CI run. I hit the null path locally only because the validation harness copied just the CSV to a scratch path and not the sidecar. The guard is still correct and still needed -- a fresh -OutputPath or a removed sidecar reaches it -- but it would not have failed the scheduled run, as the commit message for 9dbe983 claimed. Comment-only change; the guard itself is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
|
Correction to the PR description. One claim in it is wrong, and I want it on the record rather than buried in a commit. The description states that because the job has never completed a successful run, the baseline sidecar has never existed, and that the null-index bug "would have failed the run even with the timeout raised." That is incorrect. #2164 committed I hit the null path locally only because my validation harness copied just the CSV to a scratch The good news is that this makes the validation stronger than claimed. Because the sidecar was present, today's dispatch actually exercised the per-family guard against #2164's committed baseline rather than skipping it. It passed, and the comparison is informative:
Both shrinking families are inside the 15% tolerance, so the guard passed on real data rather than trivially. End-to-end dispatch result (run 31674572978), run from this branch with
Note that 20m39s exceeds the ~12-13 minute figure I projected from the old run's discovery timings, so that projection was optimistic. It is still comfortably inside the timeout, but worth knowing the real number is closer to a third of the budget than a fifth. |
…gone With serviceFamily sharding removed, "shard" is a misnomer. Worse, the code was mixing both vocabularies: Get-EligibleMeter returned FamilyCounts, which was stored via $newShardCounts into shardcounts.json and checked by Get-ShardShortfall. Consistent naming is clearer than either half. shardcounts.json -> familycounts.json (git mv, 100% rename) Get-ShardShortfall -> Get-FamilyShortfall $ShardCountPath -> $FamilyCountPath $cachedShardCounts -> $cachedFamilyCounts $newShardCounts -> $newFamilyCounts $shardShortfall -> $familyShortfall The .DESCRIPTION history section still says "sharded" where it describes the design that was removed, which is correct in the past tense. Two spots had to be right, and both were verified rather than assumed: - The existing sidecar is moved with git mv (confirmed 100% rename, content identical after line-ending normalisation) rather than left behind. Renaming only the path in the script would have silently dropped the per-family guard's baseline for a run, since Get-FamilyShortfall returns empty for a missing baseline instead of failing. - The packaging exclusion in Package-Toolkit.ps1 was re-tested live: with -Exclude '*.familycounts.json', a directory containing Regions.json, Other.json, and CommitmentDiscountEligibility.familycounts.json copies the first two only. A stale pattern would have shipped an internal operational baseline in release packages. Also updates the open-data CI path filter, both workflow references, and the regex alternations (keeping the \. escape). 22 unit tests pass, PSScriptAnalyzer clean, both workflow YAMLs parse. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Resolves the conflict introduced by #2244, which bumped actions/checkout in .github/workflows/opendata-commitment-eligibility.yml while this branch was editing the `ref:` line directly beneath it. The two changes are additive, not contradictory: the resolution keeps #2244's new pin (3d3c42e5... v7.0.1, up from 11d5960a... v4.4.0) and this branch's `ref: ${{ github.event.inputs.ref || 'dev' }}` plus the workflow_dispatch inputs. Also brings in #2252, which fixes the two update-mslearn-dates assertions that had been failing this branch's Pester run through no fault of its own. Verified after resolution: no conflict markers remain, the workflow YAML parses, both `ref` and `dry_run` inputs are present, the checkout step carries dev's new SHA, the familycounts rename is intact, and 51 unit tests pass across both the previously failing Action.UpdateMsLearnDates suite and this branch's own. Note the dispatch run that validated this branch used checkout v4.4.0; the scheduled job will now run v7.0.1. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Brett Wilson (MSBrett)
left a comment
There was a problem hiding this comment.
Review findings from the single-traversal change.
…ng it Addresses review feedback on #2251. The ref/dry_run inputs made the unsafe combination the easiest one to invoke: dry_run defaulted to false, so the natural branch-testing flow (set only `ref`) checked out the feature branch, created the opendata/* branch FROM it, and generated a compare-to-dev URL carrying every feature commit alongside the data update. A comment saying to pair the inputs is not a control. Both of the suggested remedies are applied, because they cover different mistakes: - dry_run now defaults to true, so a real publish is always a deliberate choice rather than the path of least resistance. - A guard step rejects ref != dev unless dry_run is true, which also covers someone who unticks dry_run out of habit. The guard runs BEFORE checkout, so an invalid combination costs seconds instead of the ~35 minutes the fetch now takes. The scheduled run is unaffected: a schedule has no inputs object at all, so DRY_RUN is empty (not the "true" default) and RUN_REF falls back to dev, which takes the normal push path. Verified across all ten input combinations, including the edge cases the string comparison exists to handle: "dev-experiment" and "DEV" are both correctly rejected, and the literal string "false" -- truthy in a GitHub if: expression, which is why this is compared in bash -- does not bypass the guard. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…counts Addresses review feedback on #2251. The per-family guard was presented as the replacement for the convergence loop, and it cannot carry that claim. It compares one traversal against the PREVIOUS RUN's counts with a 15% tolerance, so a paging regression still passes when it drops meters uniformly under the threshold, when it drops old meters while new ones in the same family offset the count, or when it misses brand-new meters that have no baseline at all. Step 4 then rewrites the CSV from the incomplete sets. The docstring's promise that instability would "abort loudly instead of publishing partial data" was therefore not true as written. Each price type is now traversed twice and the two meterId sets are compared directly. That is a real completeness signal: it needs no historical baseline, so it catches all three cases above on the run that suffers them. - The published set is the UNION of both passes. A meter missed by exactly one pass is real and belongs in the output; the intersection would let a faulty pass silently delete rows, which is the failure being guarded against. - Per-family SETS are unioned too, not the counts. Taking the max of two counts would undercount a family whose passes each missed a different meter (pass 1 sees {a,b}, pass 2 sees {a,c}: union 3, max 2), so Get-EligibleMeter now returns FamilyKeys and FamilyCounts is derived from it. - The symmetric difference is reported on EVERY run, not just failures. A drift that climbs week over week while staying under the threshold is the early warning that the June-2026 fault is returning, and that is only visible if the passing value is logged. - -MaxVerifyDrift (default 0.1%) separates genuine catalogue churn over the ~20 minutes the two passes span from a real paging fault, which dropped a scattered percentage of rows. Set it to 0 to require exact agreement. Reservation is verified first: at ~3 minutes per pass against Consumption's ~17, an unstable API aborts the run after ~7 minutes rather than ~40. The historical count guards are kept and re-documented as a backstop rather than the completeness signal, because they catch the one thing set comparison structurally cannot -- a DETERMINISTIC omission shared by both passes, which the comparison sees as perfect agreement. Cost is 2x the item volume (~35-40 min, timeout raised 60 -> 90). That is still well inside what the removed workaround cost: it needed a full discovery traversal per price type (25 of 60 minutes on its own) before repeat-until-stable across 22 shards, and it timed out. Two bounded passes is the difference between "no completeness signal" and "a bounded one". Unit tests go from 22 to 32, driving the verification through each failure mode a count comparison cannot see -- an offsetting drop and add that leaves the total unchanged, a meter missed by either pass, the exact-tolerance boundary, per-family set unioning, and the empty-result divide-by-zero path. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
8bb06cd made the script write the baseline sidecar with sorted keys, but the committed file was still in the arbitrary hashtable order the run that produced it happened to emit. The first run after merge would therefore have rewritten it in sorted order, and since the workflow treats ANY diff in the sidecar as "data changed", that reordering alone would have pushed a branch and asked for a PR -- exactly the empty update PR 8bb06cd set out to prevent. Ordering only. Verified that both sections keep the same family count and that every per-family value is byte-identical before and after. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The end-to-end validation (run 31710007476) came in at 44 minutes, not the ~35 I estimated when writing these comments, and the churn window the -MaxVerifyDrift rationale refers to is ~9 minutes for Reservation and ~32 for Consumption rather than a flat ~20. Comments that carry load-bearing numbers should carry the real ones. Also records the measured drift (0% on both price types) next to the default, so the tolerance is justified by an observation rather than by an assertion. No behaviour change; the 90-minute timeout still leaves 46 minutes of headroom. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Michael Flanakin (flanakin)
left a comment
There was a problem hiding this comment.
🤖 [AI][Claude] PR Review
Summary: This replaces the old sharded discovery approach for the weekly commitment-discount-eligibility fetch with a two-pass verified traversal, and fixes a real Retry-After parsing bug along the way. The core logic is correct, well tested (15/15 unit tests pass locally, no lint regressions), and the workflow_dispatch safety guard behaves correctly across all input combinations I simulated. No blockers — just a few things worth tightening before merge.
⚠️ Should fix (3)
- PR description is out of date. It says renaming
shardcounts.jsonis "a separate change" and projects "~12-13 minutes" runtime, but the final commits did rename the file tofamilycounts.jsonand the actual measured runtime is ~20 minutes (per your own follow-up comment on this PR). Since GitHub carries this description into the merge commit, worth a quick edit so it matches what actually shipped. The title ("a single traversal") is also a bit of an undersell — the final approach does two passes per price type with drift verification, which is a stronger reliability story than "single traversal" suggests. - No changelog entry. The commitment-discount-eligibility feature shipped in v14, and this fixes a bug where the weekly refresh job has never completed successfully since — so open-data consumers have been getting stale data. That's user-facing enough to warrant a changelog entry (e.g., a "Fixed" line in
docs-mslearn/toolkit/changelog.md). - See inline comment on the timeout comment mismatch (44 min vs. the 20-minute run you reported).
💡 Suggestions (2)
See inline comments — a small clarifying comment for a PowerShell idiom, and a concrete gap in the retry backoff clamp.
MaxSeconds bounded only the Retry-After path, so the last of the five retries waited 2^5 * 10 = 320s -- past the same 300s ceiling an outsized server value is held to. Also documents why the -SkipVerify return path merges hashtables with `+`. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The weekly refresh has not published since the dataset shipped in v14, so the stale data is user-facing and belongs in the changelog. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The PowerShell Tests workflow did not fire on f61c901 even though the PR diff matches its 'src/powershell/**' path filter. Empty commit to force a synchronize event. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…rsal Resolves a conflict on the changelog's ms.date. Both sides moved it from 08/12/2026 -- dev to 08/13, this branch to 08/17 -- so per the repo's conflict guidance it is set to today rather than either side's value. Entries from both sides are kept; dev's additions are in the v15 section and this branch's is in the commented-out Unreleased block. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
|
|
#2254 made Test-PowerShell.ps1 fail below Pester 6 and pinned that floor in dev.yml, but opendata-ci.yml calls the same script through Build-OpenData.ps1 -Test and installs no modules, so it picked up the ubuntu runner's Pester 5.9.0 and failed during discovery. No PR had triggered Open Data CI since #2254 merged, so this went unnoticed; it would have broken every future PR touching src/open-data. Uses the same Install-Module invocation as Init-Repo.ps1 rather than psmodulecache, which this repo only uses on Windows runners. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
🐛 Problem
The weekly Update Commitment Discount Eligibility job exceeded its 60-minute timeout on run 31612943595 and was killed 13 of 29 shards into the Consumption fetch, so it still published nothing. That run was the first to get past the
$orderbyHTTP 400 fixed in #2164 — the fetch itself works now; it is simply too slow to finish.🔧 Solution
The serviceFamily sharding and multi-pass union existed to work around a period when the Retail Prices API returned an unstable page order. That workaround is no longer earning its cost:
Each price type is now walked by following the documented
NextPageLinkverbatim, with per-serviceFamily counts tallied in the same pass — every item already carriesserviceFamily, so the per-family bookkeeping costs nothing extra.Completeness is verified per run, not inferred from history
The first revision of this PR leaned on the two historical guards alone, and MSBrett was right that they cannot carry that weight: comparing one traversal against last week's counts with a 15% tolerance passes a uniform under-fetch, passes an offsetting drop-and-add, and cannot see a brand-new meter that was missed.
So each price type is traversed twice and the two meterId sets are compared directly, which needs no baseline and catches all three cases on the run that suffers them:
{a,b}, pass 2 sees{a,c}: union 3, max 2).-MaxVerifyDriftdefaults to 0.1%. The catalogue is live and the two passes span ~9 minutes for Reservation and ~32 for Consumption, so a meter can legitimately appear or retire between them.-MaxVerifyDrift 0gives strict equality.The symmetric difference is logged on every run, not just failures: drift that climbs week over week while staying under the threshold is the early warning that the June 2026 fault is returning, and that is only visible if the passing value is printed too.
The historical guards are retained as a backstop rather than the primary signal, because they catch the one thing set comparison structurally cannot — a deterministic omission shared by both passes, which the comparison sees as perfect agreement.
Removes
Get-ServiceFamily,Assert-DiscoveryConverged,Add-BaselineShard, andInvoke-ShardedUnion.Two latent bugs fixed along the way
Retry-Afterwas never honored. It was read via$Response.Headers['Retry-After'], butHttpResponseHeadershas no string indexer, so that silently evaluated to$null(it does not throw) and every 429 fell through to the exponential backoff. Now reads the typed.RetryAfterproperty, handling both the delta-seconds and HTTP-date forms, and-MaxSecondsclamps both paths so no single wait can exceed the ceiling.$cachedShardCounts['Reservation']threwCannot index into a null arraywhen no baseline sidecar exists. Fix commitment discount eligibility fetch (Retail Prices API pagination) #2164 committed the sidecar alongside the CSV, so this is not the path a normal CI run takes — it is reached by a fresh-OutputPathor a removed sidecar, which is how I hit it locally. The guard is still correct and worth keeping, but it was not blocking the scheduled run. Pre-existing ondevat four call sites.Determinism and workflow safety (added during review)
shardcounts.json→familycounts.json, andGet-ShardShortfall→Get-FamilyShortfall. The workflow, the open-data CI path filter, and thePackage-Toolkit.ps1exclusion were all updated to match, so nothing keys off the old name any more.ConvertTo-SortedMap. Hashtable key order is not guaranteed, and the validation run wrote the families in neither alphabetical nor insertion order. Since the workflow treats any diff in the sidecar as "data changed", that would have manufactured empty update PRs.workflow_dispatchgainedrefanddry_runinputs, so a change to the fetch script can be exercised end-to-end from its PR branch before merging — previously the checkout was pinned toref: dev, meaning a weekly job that takes 44 minutes to fail could only be tested after merge.dry_rundefaults to true, and a guard step that runs before checkout rejects a non-devref unlessdry_runis set, so a data branch can never be built from a feature ref and carry its code commits into the data PR.dry_runis compared as a string in bash rather than in a GitHubif:expression: atype: booleaninput arrives as the literal string"false", which is truthy in expression syntax, soif: ${{ !inputs.dry_run }}would have inverted the check.🧪 Validation
Two independent full runs against the live API produced identical results:
Both match sharded run 31612943595 exactly, per service family as well as in aggregate (Compute 58,723 / Databases 5,536 / Storage 1,673 / Analytics 132 / AI + ML 82 / Data 49 / Developer Tools 2 / Security 1 / Management and Governance 1). The simplification demonstrably loses nothing, and the matching runs are fresh evidence the API is stable.
Resulting CSV verified: 92,550 rows, sorted, lowercased, no duplicates. The per-family guard ran against #2164's committed baseline rather than skipping it, and passed on real data — Compute −4.3% and Databases −5.7%, both inside the 15% tolerance, with Storage and AI + ML unchanged.
End-to-end in CI, from this branch:
dry_run=true: succeeded in 44m00s, nothing pushed, noopendata/*branch left behind. The sharded run it replaces was killed at 60 minutes having completed 13 of 29 shards.dry_rununticked: failed in 25 seconds before checkout, fetch, and push, all three reportedskipped.Also exercised all ten dispatch input combinations against the guard, including
dev-experimentandDEV(both correctly rejected — no prefix or case-insensitive match) and the literal string"false".Retry-Afterindexer bug (asserts 30, not the fallback 20), theMaxSecondsclamp on the exponential path, sidecar key-order stability, and each of the three failure modes the verification pass exists to catch.Lint.Tests.ps1andMsLearnDocs.Tests.ps1: 2982 pass.📝 Notes for reviewers
timeout-minutesis therefore raised from 60 to 90; the headroom absorbsRetry-Afterbackoff on a throttled run.#Requires -Version 7.0— it depends onConvertFrom-Json -AsHashtable,Export-Csv -UseQuotes, and reading the error response as anHttpResponseMessage. CI is unaffected; the workflow already runsshell: pwsh.🤖 Generated with Claude Code