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 10 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…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) <noreply@anthropic.com>
|
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) <noreply@anthropic.com>
|
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
🐛 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 once, following the documented
NextPageLinkverbatim, with per-serviceFamily counts tallied in the same pass — every item already carriesserviceFamily, so the per-family completeness guard costs nothing extra.Both completeness guards are unchanged. Data integrity now rests on them rather than on re-fetching: if the instability returns, the run aborts loudly instead of publishing partial data. That is the intended trade and the signal to reopen the support case.
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, clamped so an outsized value cannot stall the job past its timeout.$cachedShardCounts['Reservation']threwCannot index into a null arraywhen no baseline sidecar exists. Because 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. Pre-existing ondevat four call sites.🧪 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 two matching runs are fresh evidence the API is stable.
Resulting CSV verified: 92,550 rows, sorted, lowercased, no duplicates; guard passed against the cached 92,282.
Retry-Afterindexer bug (asserts 30, not the fallback 20).Start-FinOpsCostExport/DocsLinksreproduce identically on a cleandevworktree and are unrelated.📝 Notes for reviewers
shardcounts.jsonfilename andGet-ShardShortfallname are deliberately retained even though sharding is gone — the workflow, the open-data CI path filter, and the packaging exclusion inPackage-Toolkit.ps1all key off that name. Renaming is a separate change.timeout-minutesis left at 60. Projected runtime is ~12-13 minutes.ref: dev, so aworkflow_dispatchtest run only exercises this change after merge.🤖 Generated with Claude Code