Skip to content

Replace serviceFamily sharding with a single traversal per price type - #2251

Open
Roland Krummenacher (RolandKrummenacher) wants to merge 10 commits into
devfrom
RolandKrummenacher/simplify-eligibility-traversal
Open

Replace serviceFamily sharding with a single traversal per price type#2251
Roland Krummenacher (RolandKrummenacher) wants to merge 10 commits into
devfrom
RolandKrummenacher/simplify-eligibility-traversal

Conversation

@RolandKrummenacher

Copy link
Copy Markdown
Collaborator

🐛 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 $orderby HTTP 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:

  • The instability was last observed in early June 2026. MS support case 2606030050003725 closed without a root cause, and seven full traversals on 2026-06-29 were byte-identical.
  • Run 31612943595's own telemetry shows every repeat pass across all 22 completed shards adding exactly zero meters, while costing 2-3x the traversal volume. No shard needed a 4th pass and none reported non-convergence, meaning pass 1 was already complete everywhere.
  • Building the shard list required a full discovery traversal per price type, which alone consumed 25 of the job's 60 minutes and produced only 38 family-name strings.

Each price type is now walked once, following the documented NextPageLink verbatim, with 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. 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, and Invoke-ShardedUnion.

Two latent bugs fixed along the way

  • 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 (it does not throw) 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. 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 on dev at four call sites.

🧪 Validation

Two independent full runs against the live API produced identical results:

Items Pages Families Eligible meters
Reservation 146,671 147 9 66,199
Consumption 689,723 690 29 82,677

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.

  • Unit tests: 17 pass, including a regression test pinning the Retry-After indexer bug (asserts 30, not the fallback 20).
  • PSScriptAnalyzer: clean.
  • Full unit suite: 774 pass. The 5 failures in Start-FinOpsCostExport/DocsLinks reproduce identically on a clean dev worktree and are unrelated.

📝 Notes for reviewers

  • The shardcounts.json filename and Get-ShardShortfall name are deliberately retained even though sharding is gone — the workflow, the open-data CI path filter, and the packaging exclusion in Package-Toolkit.ps1 all key off that name. Renaming is a separate change.
  • No regenerated open data is included here. Validation wrote to a scratch copy; the data refresh belongs in the workflow's own PR.
  • timeout-minutes is left at 60. Projected runtime is ~12-13 minutes.
  • The workflow's checkout step is pinned to ref: dev, so a workflow_dispatch test run only exercises this change after merge.

🤖 Generated with Claude Code

… 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>

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.

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-serviceFamily counts in the same pass.
  • Fix retry behavior to honor Retry-After via 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.

Comment thread src/scripts/Update-CommitmentDiscountEligibility.ps1 Outdated
Comment thread src/scripts/Update-CommitmentDiscountEligibility.ps1
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>
@RolandKrummenacher

Copy link
Copy Markdown
Collaborator Author

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 ref: dev, so a change to the fetch script could not be exercised end-to-end until after it merged. For a job that runs weekly, takes ~15 minutes to fail, and has not completed a successful run in months, that is a poor feedback loop. Two workflow_dispatch inputs address it:

  • ref (default dev) — the branch to check out and run from. 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 change detection but skips the branch push, so testing leaves no stray opendata/* branches. It writes a summary of what would have changed.

One subtlety worth noting for review: dry_run is compared as a string in bash rather than tested in a GitHub if: expression. A type: boolean input arrives as the literal string "false", which is truthy in expression syntax, so if: ${{ !inputs.dry_run }} would have inverted the check and skipped the push whenever the box was left unticked.

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 DRY_RUN, and an explicit DRY_RUN=false. The last two both correctly take the push path.

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>
@RolandKrummenacher

Copy link
Copy Markdown
Collaborator Author

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 src/open-data/CommitmentDiscountEligibility.shardcounts.json alongside the CSV on 2026-08-12, so it is present on dev and on this branch, and $cachedShardCounts is not null in a normal CI run.

I hit the null path locally only because my validation harness copied just the CSV to a scratch -OutputPath and not the sidecar — an artifact of my own test setup, which I wrote up as a production finding. The guard is still correct and still worth keeping (a fresh -OutputPath, or a removed sidecar, reaches it), but it was not blocking the scheduled run. Comment corrected in bcaff15.

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:

Family Baseline (#2164) This run Delta
Compute 61,368 58,723 −4.3%
Databases 5,873 5,536 −5.7%
Storage 1,673 1,673 0%
AI + Machine Learning 82 82 0%

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 dry_run=true:

  • Succeeded in 20m39s, against a 60-minute timeout. The sharded run it replaces was killed at 60 minutes having completed 13 of 29 shards.
  • 146,671 Reservation items / 147 pages → 66,199 RI-eligible; 689,723 Consumption items / 690 pages → 82,677 SP-eligible — identical to both local validation runs.
  • 92,550 meters written; 576 added, 4,359 changed, 308 removed.
  • Zero rate-limiting events on the runner.
  • Dry run took the correct path (Dry run: changes detected, not pushing.) and left no opendata/* branch behind.

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.

Roland Krummenacher and others added 2 commits August 13, 2026 09:24
…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>

@MSBrett Brett Wilson (MSBrett) 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.

Review findings from the single-traversal change.

Comment thread .github/workflows/opendata-commitment-eligibility.yml
Comment thread src/scripts/Update-CommitmentDiscountEligibility.ps1
Roland Krummenacher and others added 3 commits August 13, 2026 16:22
…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>
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.

4 participants