diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 401d9a4..abf2d14 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,32 +2,36 @@ This repository is the attendee-facing material for Brandon Minnick's two-day workshop **"Become An Expert With Async Await in C#"**. Attendees clone it, read the READMEs, and run the samples on their own laptops. Optimize every change for the attendee experience and for the teaching goal, not for a production codebase. +Most attendees open the starter project and jump straight into the code without reading the rest of the README. Every challenge app therefore has to guide an attendee through the whole challenge by itself, through its workshop guide and the comments in the code, without sending them back to the README for instructions, clues, or hints. + ## What lives where - Numbered top-level folders are workshop sections and must stay in teaching order. Day 1 is asynchronous programming: `0. Prerequisites`, `1. Thread Switching and Compiler Generated Code` (slides only), `2. Correcting Common Async Await Mistakes`, `3. .NET Internals`, `4. Creating Custom Implementation of Task`. Day 2 is parallel programming: `5. Asynchronous vs Parallel Programming`, `6. Coordinating Multiple Tasks`, `7. Data Parallelism`, `8. Concurrent Collections`, `9. Channels`. - Day 2 material was adapted from Brandon's Dometrain course "From Zero to Hero: Parallel Programming in C#". Dometrain attendees said that course's console samples were not realistic enough, so every Day 2 sample is a Blazor Server app built around a bug a working .NET developer would actually ship: `OrderPortal` (singleton services under concurrent load), `ProductDetails` (a page awaiting its backend services one at a time), `ImportPortal` (a batch job that is sequential and uses `Parallel.ForEach` with an `async` lambda), `StockWatch` (a live dashboard with unsynchronized collections), and `TelemetryPipeline` (a webhook handler doing slow work inline). Keep new samples in that register: a realistic app with a realistic mistake. - Each hands-on section has a `README.md` (attendee-facing challenge) and a `SOLUTION.md` (step-by-step walkthrough the host presents after the challenge and group review). Sections 2 and 4 through 9 have `1. Start` (intentionally imperfect) and `2. Finish` (completed) projects. Section 4's `CoffeeShop` Blazor app differs between Start and Finish only in `CustomTask.cs` (stubs that throw `NotImplementedException` with hints) and its port; its `Steps` folder is the attendee's guide, and the app re-verifies Steps 1 to 5 against the attendee's `CustomTask` on startup. -- `3. .NET Internals/4. SynchronizationContext/HackerNews` is a copy of `2. Correcting Common Async Await Mistakes/2. Finish/HackerNews` plus the thread and `SynchronizationContext` log lines in `News.razor.cs`. Changes to one usually belong in the other. -- Section numbers are embedded in paths in the root `README.md`, `.github/workflows/build.yml`, `.github/dependabot.yml`, and the module READMEs. When renaming or reordering sections, update all of them and grep for stale numbers before finishing. +- Every challenge app uses the guided steps format that Section 4 introduced. In Sections 2 and 5 through 9 the realistic page (Top stories, Checkout, Product, Import, Dashboard, Ingest) is the app itself at `/`, and `Components/Layout/WorkshopGuide.razor` is docked beside it on every screen: a Your challenge tab, one tab per step, progress, and the step's results and hints. Attendees missed a separate steps page behind a top-bar link, so never move the guide back behind navigation. Each of those apps has its own visual identity and an in-world guide (HackerNews a pull request code review, OrderPortal a launch checklist), while the guide's structure and labels stay the same everywhere. Sections 3 and 4 have no separate realistic page, so their apps open on a steps overview (InternalsLab's Workshop steps page, CoffeeShop's Home page) with a page per step; CoffeeShop's checks show expected results without per-check hints, and InternalsLab's steps are investigations. In Sections 2 and 5 through 9, each `Steps/Step*.cs` file is one bug or concept with a story, how to see it in the app, where to look, a task list, clues, and checks that run the attendee's real code and attach a hint to every expected result. In Sections 2 and 5 through 9, `Verification/StepVerifier.cs` checks every step on startup, in order, stops at the first failure, and logs each failed hint to the terminal; CoffeeShop does the same for Steps 1 to 5, and InternalsLab records progress in its `LabNotebook` instead. A step unlocks only after the step before it passes. In Sections 2 and 5 through 9, the `Steps` and `Verification` folders, the layout with its workshop guide, the realistic page's markup, and `app.css` are identical in Start and Finish. +- `3. .NET Internals` is one guided Blazor app, `InternalsLab`, with no Start or Finish. Its four steps (ThreadStatic, ExecutionContext, Principal, SynchronizationContext) are investigations: the attendee records predictions, runs the experiment, compares results, and answers multiple-choice explain questions, and a step passes once its experiment has run and every question is answered correctly. State lives in a singleton `LabNotebook` for as long as the app runs, and the `Steps` files hold the answers. The Principal experiment is an MVC controller with cookie sign-in inside the same app, because it has to observe a real ASP.NET Core request. +- Section numbers are embedded in paths in the root `README.md`, `0. Prerequisites/README.md`, and the module READMEs (`build.yml` and `dependabot.yml` build and scan from the repository root, so they name no section). When renaming or reordering sections, update all of them and grep for stale numbers before finishing. - Every sample is a Blazor Server, ASP.NET Core MVC, or console app on .NET 10. No MAUI, no mobile workloads, no emulators: attendees only have the .NET SDK, an editor, and a browser. -- Samples must run offline. `8. Concurrent Collections` uses a simulated in-process market feed (`MarketDataService`) rather than a real quote API, so there is no API key to leak and no rate limit to hit in a room full of attendees. +- Samples must run offline. `8. Concurrent Collections` uses a simulated in-process market feed (`MarketDataService`) rather than a real quote API, so there is no API key to leak and no rate limit to hit in a room full of attendees. The HackerNews Top stories page is the one exception, and its workshop steps still run offline against a fake in-memory `IHackerNewsAPI`. ## Non-negotiable rules -1. **Code and walkthroughs stay in sync, in both directions.** Any change to sample code must be reflected in that section's `README.md` and `SOLUTION.md` (snippets and prose), and any walkthrough change must be reflected in the code. Check every duplicated copy: Start, Finish, and the SynchronizationContext copy. -2. **Never "fix" the Start projects.** Code marked `// ToDo Refactor` and the compiler warnings it produces (for example CS4014) *are* the challenge. Do not refactor it, suppress its warnings, or run analyzer-based formatting over it. The only permitted change is the element ordering described under "C# element ordering" below: moving a member, with its `// ToDo Refactor` comment attached, never changes what it does. -3. **Write for attendees, not the host.** READMEs must flow naturally into the challenge without revealing the answer. Solutions belong only in `SOLUTION.md` and the `2. Finish` folders. -4. **Discourage AI from solving the challenges.** Every challenge begins with a note whose first sentences are exactly: `> **Note:** Please avoid letting AI Agents solve the challenges for you. You're smart. You got this.` followed by what AI *may* be used for (understanding the code, clarifying concepts, interpreting errors). +1. **Code and walkthroughs stay in sync, in both directions.** Any change to sample code must be reflected in that section's `README.md` and `SOLUTION.md` (snippets and prose), and any walkthrough change must be reflected in the code. The app's step text (story, tasks, clues, and check hints) is part of the walkthrough too. Check every duplicated copy: Start and Finish. +2. **Never "fix" the Start projects.** Code marked `// ToDo Refactor (Step N)` and the compiler warnings it produces (for example CS4014) *are* the challenge. The step number names the workshop step that checks that line. Do not refactor it, suppress its warnings, or run analyzer-based formatting over it. The only permitted change is the element ordering described under "C# element ordering" below: moving a member, with its `// ToDo Refactor` comment attached, never changes what it does. Every step must fail on Start for its own reason and pass on Finish, reliably: amplify race conditions in the checks rather than hoping a single run catches them. +3. **Write for attendees, not the host.** READMEs and the in-app step text must flow naturally into the challenge without revealing the answer. Tasks and hints stay at the level of the README requirements; clues can be more direct because attendees open them on purpose. Solutions belong only in `SOLUTION.md` and the `2. Finish` folders. +4. **Discourage AI from solving the challenges.** Every challenge begins with a note whose first sentences are exactly: `> **Note:** Please avoid letting AI Agents solve the challenges for you. You're smart. You got this.` followed by what AI *may* be used for (understanding the code, clarifying concepts, interpreting errors). Every challenge app repeats it in its Your challenge panel. 5. **Keep it simple.** Make the smallest correct change. No null-forgiving operator (`!`), no `#pragma` suppressions, no abstractions for one-off work, and no committed helper tooling or scripts beyond `BuildAllSolutions.ps1`. If a bulk edit is needed, do the edit rather than adding automation to the repo. -6. **Style rules are warnings, not errors,** and live in `.editorconfig`. That includes the StyleCop element-ordering rules, which `Directory.Build.props` suppresses with `NoWarn` in every configuration except Release so attendees working in Debug never see them. Do not add build-blocking style enforcement that would interrupt attendees while they iterate; ordering is enforced by the Release builds in `BuildAllSolutions.ps1`, which attendees do not run. +6. **New style rules are warnings, not errors,** and live in `.editorconfig` (a few naming and correctness rules there are errors; do not add more). That includes the StyleCop element-ordering rules, which `Directory.Build.props` suppresses with `NoWarn` in every configuration except Release so attendees working in Debug never see them. Do not add build-blocking style enforcement that would interrupt attendees while they iterate; ordering is enforced by the Release builds in `BuildAllSolutions.ps1`, which attendees do not run. 7. **The Prerequisites README must stay foolproof.** It has to work on Windows and macOS, with or without admin rights, with Visual Studio, Rider, or VS Code, with Git blocked (ZIP download), and behind corporate networks that block Hacker News. Do not remove a path just because it looks redundant. ## Build and validate - Full validation, required before finishing any code change: `pwsh ./BuildAllSolutions.ps1`. It runs `dotnet format whitespace` and then `dotnet build -c Release --no-incremental` for every `.slnx`, and it fails when any build reports a StyleCop element-ordering warning (`SA1201`, `SA1202`, `SA1204`, `SA1214`, `SA1215`). Keep the script on `dotnet format whitespace`: full `dotnet format` fails on the .NET 10 SDK and analyzer/code-style formatting would erase the intentional Start warnings. - Single solution: `dotnet build "
//.slnx"`. -- Run a web sample: `dotnet run --project "/.csproj"`. Ports are fixed and referenced by the READMEs: PrincipalExample `5000`, HackerNews Start `5001` / Finish `5002`, SynchronizationContext HackerNews `5004`, StockWatch Start `5005` / Finish `5006`, OrderPortal Start `5007` / Finish `5008`, ProductDetails Start `5009` / Finish `5010`, ImportPortal Start `5011` / Finish `5012`, TelemetryPipeline Start `5013` / Finish `5014`, CoffeeShop Start `5015` / Finish `5016`. -- `Properties/launchSettings.json` is gitignored, but the HackerNews copies are force-tracked because `ASPNETCORE_ENVIRONMENT=Development` is required for `dotnet run` to serve `_framework/blazor.web.js`. Keep them tracked and keep the environment variable. +- Run a web sample: `dotnet run --project "/.csproj"`. Ports are fixed and referenced by the READMEs: HackerNews Start `5001` / Finish `5002`, InternalsLab `5003`, StockWatch Start `5005` / Finish `5006`, OrderPortal Start `5007` / Finish `5008`, ProductDetails Start `5009` / Finish `5010`, ImportPortal Start `5011` / Finish `5012`, TelemetryPipeline Start `5013` / Finish `5014`, CoffeeShop Start `5015` / Finish `5016`. +- `Properties/launchSettings.json` is gitignored, but every web sample's copy is force-tracked because `ASPNETCORE_ENVIRONMENT=Development` is required for `dotnet run` to serve `_framework/blazor.web.js`. Keep them tracked and keep the environment variable. +- A step's checks create their own instances of the services they exercise, never the app's singletons, and must never crash the app: an unhandled exception on a thread pool thread, a timer callback, or an `async void` ends the process, so gate any check that could provoke one behind the step that fixes it. Bugs inside a page's `*.razor.cs` are checked by rendering a verifier-only subclass of the page with `HtmlRenderer`. ## Code conventions beyond .editorconfig diff --git a/0. Prerequisites/README.md b/0. Prerequisites/README.md index 9f8dfc5..477614c 100644 --- a/0. Prerequisites/README.md +++ b/0. Prerequisites/README.md @@ -14,13 +14,13 @@ dotnet run --project "2. Correcting Common Async Await Mistakes/2. Finish/Hacker ## 1. Install or Open an Editor -One of these three is required. A plain text editor is not enough: the .NET Internals section on Day 1 is spent setting breakpoints and inspecting threads, and none of that works from `dotnet run`. +One of these three is required. A plain text editor is not enough: the .NET Internals section on Day 1 invites you to set breakpoints and inspect threads, and we debug through its experiments together in the group review. None of that works from `dotnet run`. * [Visual Studio 2026](https://visualstudio.microsoft.com/downloads/) (18.0 or later) on Windows. .NET 10 is not offered by Visual Studio 2022, so if that is what you have, use Visual Studio Code or Rider instead. * [JetBrains Rider](https://www.jetbrains.com/rider/download/) (2025.3 or later) on Windows or macOS * [Visual Studio Code](https://code.visualstudio.com/) with the current [C# Dev Kit](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csdevkit) extension. This is the smallest install of the three. -If your work laptop blocks all three, tell us before the workshop starts rather than on the morning. We can pair you with someone for the debugging exercises. +If your work laptop blocks all three, tell us before the workshop starts rather than on the morning of the workshop. We can pair you with someone for the debugging exercises. ## 2. Install .NET 10 SDK @@ -164,7 +164,7 @@ Then run the Blazor sample: dotnet run --project "2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/HackerNews.csproj" ``` -Open [http://localhost:5002](http://localhost:5002). Confirm that the **HackerNews** Blazor app loads and displays top stories. +Open [http://localhost:5002](http://localhost:5002). The **HackerNews** Blazor app opens on its **Top stories** page, with a workshop guide docked beside it. Confirm that the page displays top stories, and that the guide shows 5 of 5 steps pass once its checks finish, about 20 seconds after the app starts. To stop the app, return to the terminal and press **Ctrl+C**. @@ -181,7 +181,7 @@ Before continuing to the workshop exercises, prove your machine is ready: 3. Run `dotnet --version` and confirm it reports .NET 10. 4. Build the completed HackerNews sample from step 4. 5. Run the completed HackerNews sample from step 4. -6. Open the local URL and confirm that the app displays top stories. +6. Open the local URL and confirm that the app displays top stories, and that the workshop guide beside them shows 5 of 5 steps pass. 7. Write down any blocker, exact error message, operating system, editor, and install path you used. If every check passes, you are ready for the workshop. @@ -207,15 +207,16 @@ If `localhost:5002` does not load: * If port `5002` is already in use, run: ```console -dotnet run --project "2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/HackerNews.csproj" --urls http://localhost:5012 +dotnet run --project "2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/HackerNews.csproj" --urls http://localhost:5099 ``` -Then open [http://localhost:5012](http://localhost:5012). +Then open [http://localhost:5099](http://localhost:5099). If your company blocks access to Hacker News: * The app can still build. -* The live story list may show a refresh error until you are on a network that allows access to `https://hacker-news.firebaseio.com`. +* The workshop guide still shows 5 of 5 steps pass, because its checks use an in-memory copy of Hacker News instead of the real site. +* The live story list on **Top stories** may show a refresh error until you are on a network that allows access to `https://hacker-news.firebaseio.com`. ## 7. Maintainer Build Check diff --git a/1. Thread Switching and Compiler Generated Code/ThreadSwitchingAndCompilerGeneratedCode.pptx b/1. Thread Switching and Compiler Generated Code/ThreadSwitchingAndCompilerGeneratedCode.pptx index b02aea2..9de299a 100644 Binary files a/1. Thread Switching and Compiler Generated Code/ThreadSwitchingAndCompilerGeneratedCode.pptx and b/1. Thread Switching and Compiler Generated Code/ThreadSwitchingAndCompilerGeneratedCode.pptx differ diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Components/Layout/MainLayout.razor b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Components/Layout/MainLayout.razor index e5f4720..f745fc5 100644 --- a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Components/Layout/MainLayout.razor +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Components/Layout/MainLayout.razor @@ -1,5 +1,9 @@ @inherits LayoutComponentBase -
- @Body -
\ No newline at end of file +
+
+ @Body +
+ + +
diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Components/Layout/StepBadge.razor b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Components/Layout/StepBadge.razor new file mode 100644 index 0000000..352439e --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Components/Layout/StepBadge.razor @@ -0,0 +1,10 @@ +@inherits StepBadgeBase + +@if (IsCompact) +{ + @Label +} +else +{ + @Icon @Label +} diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Components/Layout/StepBadge.razor.cs b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Components/Layout/StepBadge.razor.cs new file mode 100644 index 0000000..a329996 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Components/Layout/StepBadge.razor.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Components; + +namespace HackerNews.Components.Layout; + +public partial class StepBadgeBase : ComponentBase +{ + [Parameter, EditorRequired] + public StepStatus Status { get; set; } + + [Parameter] + public bool IsUnlocked { get; set; } = true; + + // Only the icon, for the collapsed guide + [Parameter] + public bool IsCompact { get; set; } + + public string Icon => Status is StepStatus.Running ? "⏳" : !IsUnlocked ? "🔒" : Status switch + { + StepStatus.Passed => "✅", + StepStatus.Failed or StepStatus.TimedOut or StepStatus.Crashed => "❌", + _ => "•", + }; + + public string Label => Status is StepStatus.Running ? "Running" : !IsUnlocked ? "Locked" : Status switch + { + StepStatus.Passed => "Passed", + StepStatus.Failed => "Failed", + StepStatus.TimedOut => "Timed out", + StepStatus.Crashed => "Crashed", + StepStatus.Stopped => "Stopped", + _ => "Not run", + }; + + public string CssClass => Status is StepStatus.Running ? "badge-running" : !IsUnlocked ? "badge-locked" : Status switch + { + StepStatus.Passed => "badge-passed", + StepStatus.Failed or StepStatus.TimedOut or StepStatus.Crashed => "badge-failed", + _ => "badge-idle", + }; +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Components/Layout/WorkshopGuide.razor b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Components/Layout/WorkshopGuide.razor new file mode 100644 index 0000000..ec82b3d --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Components/Layout/WorkshopGuide.razor @@ -0,0 +1,321 @@ +@inherits WorkshopGuideBase + + diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Components/Layout/WorkshopGuide.razor.cs b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Components/Layout/WorkshopGuide.razor.cs new file mode 100644 index 0000000..ffdbc44 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Components/Layout/WorkshopGuide.razor.cs @@ -0,0 +1,179 @@ +using Microsoft.AspNetCore.Components; + +namespace HackerNews.Components.Layout; + +// Workshop plumbing: the guide docked beside the app. It shows your challenge, every step's result, and the step you are working on. +public partial class WorkshopGuideBase : ComponentBase, IAsyncDisposable +{ + // Selecting this shows your challenge instead of a step + public const int ChallengeTab = 0; + + // A run that fails quickly can finish faster than anyone can see a spinner + static readonly TimeSpan _minimumActivityIndicatorTime = TimeSpan.FromMilliseconds(600); + + readonly CancellationTokenSource _disposeCancellationTokenSource = new(); + + Task _polling = Task.CompletedTask; + bool _isStartingCheck; + int? _startingRunStepNumber; + + [Inject] + public required StepVerifier Verifier { get; init; } + + [Inject] + public required ILogger Logger { get; init; } + + public int SelectedTab { get; private set; } = ChallengeTab; + + public bool IsCollapsed { get; private set; } + + public string? ErrorMessage { get; private set; } + + public int PassedStepCount => Verifier.Steps.Count(step => Verifier.GetState(step.Number).Status is StepStatus.Passed); + + public bool IsCheckingSteps => _isStartingCheck || Verifier.IsCheckingAllSteps; + + // The first step that does not pass yet, which is the one to work on next + public WorkshopStep? StoppedAtStep => Verifier.Steps.FirstOrDefault(step => Verifier.GetState(step.Number).Status is not StepStatus.Passed); + + // The last step the check reaches: the first one that did not pass, or the last step when they all passed + public WorkshopStep LastCheckedStep => StoppedAtStep ?? Verifier.Steps[^1]; + + public WorkshopStep? SelectedStep => Verifier.FindStep(SelectedTab); + + // The run lives in the verifier, not in this guide, so a step that is still running shows up again after a reload + public ActiveStepRun? SelectedRun => Verifier.ActiveRun is { } run && run.Step.Number == SelectedTab ? run : null; + + public bool IsSelectedStepRunning => _startingRunStepNumber == SelectedTab || SelectedRun is not null; + + public async ValueTask DisposeAsync() + { + // Only stops refreshing the guide. A step that is running keeps running. + await _disposeCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + await _polling.ConfigureAwait(false); + + _disposeCancellationTokenSource.Dispose(); + + GC.SuppressFinalize(this); + } + + protected override void OnInitialized() + { + _polling = PollVerifier(_disposeCancellationTokenSource.Token); + } + + protected void SelectTab(int tab) + { + SelectedTab = tab; + IsCollapsed = false; + ErrorMessage = null; + } + + protected void ToggleCollapsed() => IsCollapsed = !IsCollapsed; + + protected async Task CheckAllStepsAsync() + { + var token = _disposeCancellationTokenSource.Token; + string? errorMessage = null; + + ErrorMessage = null; + _isStartingCheck = true; + + // Show the activity indicator before the check starts + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + try + { + // Keep the indicator on screen long enough to notice, even when Step 1 fails quickly + await Task.WhenAll(Verifier.CheckAllSteps(), Task.Delay(_minimumActivityIndicatorTime, token)).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The guide was closed while the steps were being checked + return; + } + catch (Exception e) + { + Logger.LogError(e, "Checking every step failed"); + + errorMessage = "Checking the steps failed. The terminal running the app has the details."; + } + + // The continuation is off Blazor's renderer, so every component state change goes back through it + await InvokeAsync(() => + { + ErrorMessage = errorMessage; + _isStartingCheck = false; + + StateHasChanged(); + }).ConfigureAwait(false); + } + + protected async Task RunSelectedStepAsync() + { + if (SelectedStep is not { } step) + return; + + var token = _disposeCancellationTokenSource.Token; + string? errorMessage = null; + + ErrorMessage = null; + _startingRunStepNumber = step.Number; + + // Show the activity indicator before the run starts + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + try + { + // Keep the indicator on screen long enough to notice, even when the step fails quickly + await Task.WhenAll(Verifier.RunStep(step), Task.Delay(_minimumActivityIndicatorTime, token)).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The guide was closed while the step was running + return; + } + catch (Exception e) + { + Logger.LogError(e, "Running Step {StepNumber} failed", step.Number); + + errorMessage = "The step could not be run. The terminal running the app has the details."; + } + + // The continuation is off Blazor's renderer, so every component state change goes back through it + await InvokeAsync(() => + { + ErrorMessage = errorMessage; + _startingRunStepNumber = null; + + StateHasChanged(); + }).ConfigureAwait(false); + } + + protected void StopRun() => Verifier.StopRun(); + + // Re-renders while the verifier is busy, plus once more when it finishes, so the live log and results appear without a refresh + async Task PollVerifier(CancellationToken token) + { + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100)); + var wasBusy = true; + + try + { + while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false)) + { + var isBusy = Verifier.IsBusy; + + if (isBusy || wasBusy) + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + wasBusy = isBusy; + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // Expected when the guide is disposed + } + } +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Components/Pages/News.razor b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Components/Pages/News.razor index e4a4a87..cd410ce 100644 --- a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Components/Pages/News.razor +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Components/Pages/News.razor @@ -3,73 +3,75 @@ Top Stories -
-
-
-

Hacker News

-

Top stories

-

Fresh links from the front page, ranked by score and ready to scan.

-
+
+
+ + Hacker News + -
-
- @TopStoryCollection.Count - of @StoriesConstants.NumberOfStories stories loaded -
+ @TopStoryCollection.Count of @StoriesConstants.NumberOfStories stories loaded - -
+
- @if (RefreshErrorMessage is not null) - { - - } +
+

Top stories

+ + @if (RefreshErrorMessage is not null) + { + + } - @if (TopStoryCollection.Count is 0 && IsListRefreshing) - { -
- @for (var index = 0; index < 8; index++) - { -
- @(index + 1) -
- - + @if (TopStoryCollection.Count is 0 && IsListRefreshing) + { +
+ @for (var index = 0; index < 8; index++) + { +
+ @(index + 1). +
+ + +
-
- } -
- } - else - { -
- @for (var index = 0; index < TopStoryCollection.Count; index++) - { - var story = TopStoryCollection[index]; + } +
+ } + else + { +
+ @for (var index = 0; index < TopStoryCollection.Count; index++) + { + var story = TopStoryCollection[index]; -
- @(index + 1) -
- @if (string.IsNullOrWhiteSpace(story.Url)) - { - @story.Title - } - else - { - @story.Title - } +
+ @(index + 1). +
+ @if (string.IsNullOrWhiteSpace(story.Url)) + { + @story.Title + } + else + { + @story.Title + } - -
-
- } -
- } -
\ No newline at end of file + + + + } + + } + + diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Components/Pages/News.razor.cs b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Components/Pages/News.razor.cs index 06a35fe..47bd218 100644 --- a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Components/Pages/News.razor.cs +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Components/Pages/News.razor.cs @@ -52,7 +52,8 @@ protected override void OnInitialized() { IsListRefreshing = true; - //ToDo Refactor + // ToDo Refactor (Step 1): this starts the refresh and forgets it. OnInitialized() returns straight away, + // so Blazor treats the page as initialized while the stories are still loading, and nothing observes the task. RefreshAsync(_disposeCancellationTokenSource.Token); } @@ -74,12 +75,15 @@ async Task RefreshAsync(CancellationToken token) IsListRefreshing = true; RefreshErrorMessage = null; - // ToDo Refactor + // ToDo Refactor (Step 3): this method receives a CancellationToken, and this delay never hears about it. + // When the page goes away, the refresh keeps running until the 2 seconds are up. var minimumRefreshTimeTask = Task.Delay(TimeSpan.FromSeconds(2)); try { - // ToDo Refactor + // ToDo Refactor (Step 2): this await captures Blazor's context, so everything after it, including the + // finally block, runs on the renderer. + // ToDo Refactor (Step 4): it also hands back nothing until every story has arrived. var topStoriesList = await GetTopStories(token, StoriesConstants.NumberOfStories); await InvokeAsync(() => @@ -102,7 +106,8 @@ await InvokeAsync(() => } finally { - // ToDo Refactor + // ToDo Refactor (Step 2): Wait() blocks the thread that runs it. Here that is Blazor's renderer for this + // browser tab, and nothing else can run on it until the 2 second delay ends. minimumRefreshTimeTask.Wait(); await InvokeAsync(() => @@ -113,7 +118,7 @@ await InvokeAsync(() => } } - // ToDo Refactor + // ToDo Refactor (Step 4): the page sees none of these stories until the last one has arrived and the list is sorted async Task> GetTopStories(CancellationToken token, int storyCount = int.MaxValue) { List topStoryList = []; @@ -134,13 +139,14 @@ async Task> GetTopStories(CancellationToken token, int return topStoryList.OrderByDescending(x => x.Score).ToList(); } - //ToDo Refactor + // ToDo Refactor (Step 5): an async state machine whose only job is to await one task and return its result async Task GetStory(long storyId, CancellationToken token) { return await HackerNewsApiService.GetStory(storyId, token); } - //ToDo Refactor + // ToDo Refactor (Step 5): when the stories on the page are recent, this completes without awaiting anything, + // yet every call still allocates a Task to hand back the result async Task> GetTopStoryIDs(CancellationToken token) { if (IsDataRecent(TimeSpan.FromHours(1))) diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Program.cs b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Program.cs index 5277d97..3f165c5 100644 --- a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Program.cs +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Program.cs @@ -20,6 +20,11 @@ public static void Main(string[] args) .ConfigureHttpClient(static client => client.BaseAddress = new Uri("https://hacker-news.firebaseio.com/v0")) .AddStandardResilienceHandler(static options => options.Retry = new WebHttpRetryStrategyOptions()); + // Workshop plumbing: one StepVerifier holds every step's result. It is also a hosted service, + // so it checks your News page against every step each time the app starts. + builder.Services.AddSingleton(); + builder.Services.AddHostedService(static serviceProvider => serviceProvider.GetRequiredService()); + var app = builder.Build(); if (!app.Environment.IsDevelopment()) diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Steps/Step1LoadStoriesDuringInitialization.cs b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Steps/Step1LoadStoriesDuringInitialization.cs new file mode 100644 index 0000000..c9dae82 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Steps/Step1LoadStoriesDuringInitialization.cs @@ -0,0 +1,106 @@ +using HackerNews.Components.Pages; + +namespace HackerNews; + +public sealed class Step1LoadStoriesDuringInitialization : WorkshopStep +{ + const string _asyncVoidHint = "async void silences the compiler warning without fixing anything. Nobody can await an async void method, and an exception that escapes one cannot be caught by its caller: " + + "on Blazor's renderer it tears down the user's connection, and anywhere else it ends the whole app. Blazor's lifecycle methods can return a Task, so use one that does."; + + const string _notInitializedHint = "Blazor only waits for work it can see through the Task a lifecycle method returns. OnInitialized() starts the refresh and returns straight away, so Blazor finished initializing the page before a single story arrived. " + + "Make the refresh part of the page's initialization, so the Task Blazor gets back completes only after the stories load."; + + // Fast enough that loading the page takes about as long as the refresh's 2 second minimum + static readonly TimeSpan _responseTime = TimeSpan.FromMilliseconds(10); + + public override int Number => 1; + + public override string Scenario => "Load stories as part of initialization"; + + public override string Title => "OnInitialized()"; + + public override string Story => "Blazor waits for a page to finish initializing before it treats that page's first render as complete. Prerendering sends that render to the browser, and HtmlRenderer, which renders components into emails, PDFs and caches, returns it. " + + "The Top stories page starts its refresh in OnInitialized() and returns straight away, so as far as Blazor knows, the page finished initializing with an empty story list. " + + "Nothing awaits the refresh either, so nothing can tell when it ends, or whether it failed."; + + public override string SeeItInTheApp => "Build the app and read the CS4014 warning on OnInitialized(): the compiler has already spotted this one. The Top stories page itself looks fine, because this app turns prerendering off, so the browser simply shows the loading skeleton until the stories arrive. " + + "This step renders the page the way prerendering does, waiting for initialization to finish, and today that render is the loading skeleton."; + + public override string FileToChange => "Components/Pages/News.razor.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Do not leave an unobserved task in the page's initialization. The Task Blazor gets back from initialization should complete only after the stories load.", + "Do not reach for async void to silence the warning. Use it only when a framework API truly requires it, and Blazor's lifecycle methods do not.", + "Use safe fire-and-forget, such as SafeFireAndForget() from AsyncAwaitBestPractices, only when the caller truly cannot return a Task. A Blazor lifecycle method can.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "A task is started from a lifecycle method without being awaited. That is what CS4014 is pointing at.", + "Blazor only knows about work it can see through the Task a lifecycle method returns.", + "Most of Blazor's synchronous lifecycle methods have an async sibling that returns a Task, and Blazor awaits it.", + ]; + + public override string TimeoutHint => "Your News page never finished initializing. Check that nothing in OnInitialized() or OnInitializedAsync() blocks on a task with Wait(), Result or GetAwaiter().GetResult(). The refresh needs Blazor's renderer to finish, and a blocking wait on the renderer holds it forever."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + // Part 1: read the compiled page for async void methods. The page is only rendered once there are none, because an exception escaping one can end the app. + var asyncVoidMethods = CodeInspector.GetAsyncVoidMethods(typeof(NewsPageBase)); + report.Log(asyncVoidMethods.Count is 0 ? "NewsPageBase has no async void methods" : $"NewsPageBase has async void methods: {string.Join(", ", asyncVoidMethods)}"); + + report.Expect("NewsPageBase has no async void methods", "none", asyncVoidMethods.Count is 0 ? "none" : string.Join(", ", asyncVoidMethods), asyncVoidMethods.Count is 0, _asyncVoidHint); + + if (asyncVoidMethods.Count is not 0) + { + report.Expect($"When Blazor finishes initializing the page, all {StoriesConstants.NumberOfStories} stories are loaded", $"{StoriesConstants.NumberOfStories} stories", "Skipped: the page has async void methods", false, _asyncVoidHint); + return; + } + + // Part 2: render the page against an in-memory Hacker News and wait for Blazor to finish initializing it, the way prerendering does + await using var session = new NewsPageSession(new FakeHackerNewsAPI(_responseTime)); + + report.Log($"Rendering the News page. Every Hacker News call takes {_responseTime.TotalMilliseconds:0} ms"); + await session.BeginRendering(token).ConfigureAwait(false); + + report.Log("Waiting for Blazor to finish initializing the page"); + var isInitialized = await session.WaitForInitialization(token).ConfigureAwait(false); + + if (!isInitialized) + { + report.Log($"Initialization did not finish within {NewsPageSession.RenderTimeout.TotalSeconds:0} seconds"); + report.Expect($"When Blazor finishes initializing the page, all {StoriesConstants.NumberOfStories} stories are loaded", $"{StoriesConstants.NumberOfStories} stories", $"initialization did not finish within {NewsPageSession.RenderTimeout.TotalSeconds:0} seconds", false, TimeoutHint); + return; + } + + // Read everything at the instant initialization finished, before any fire-and-forget work can catch up + var storiesLoaded = await session.Read(static page => page.Stories.Count, token).ConfigureAwait(false); + var html = await session.GetHtml(token).ConfigureAwait(false); + var storiesOnPage = NewsPageSession.CountStoriesOnPage(html); + var storiesReturned = session.HackerNewsApi.StoriesReturned; + var refreshFailed = session.Logger.Errors.Count > 0 || await session.Read(static page => page.ErrorMessage is not null, token).ConfigureAwait(false); + var storiesHint = refreshFailed ? "The refresh failed while the page was initializing, so the stories never finished loading. The last expected result says what your page logged." : _notInitializedHint; + + report.Log($"Initialization finished. Hacker News had returned {storiesReturned} stories, the page had loaded {storiesLoaded}, and its render shows {storiesOnPage}"); + + report.Expect( + $"When Blazor finishes initializing the page, all {StoriesConstants.NumberOfStories} stories are loaded", + NewsPageSession.DescribeStories(StoriesConstants.NumberOfStories), + NewsPageSession.DescribeStories(storiesLoaded), + storiesLoaded is StoriesConstants.NumberOfStories, + storiesHint); + + report.Expect( + "The page Blazor renders at that moment, which prerendering would send, lists every story", + $"{NewsPageSession.DescribeStories(StoriesConstants.NumberOfStories)} on the page", + $"{NewsPageSession.DescribeStories(storiesOnPage)} on the page", + storiesOnPage is StoriesConstants.NumberOfStories, + refreshFailed ? storiesHint : "Prerendering and HtmlRenderer send the page as it is rendered once initialization finishes. Until the refresh is part of initialization, that render is the loading skeleton."); + + await session.ExpectNoErrors(report, token).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Steps/Step2KeepTheRendererFree.cs b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Steps/Step2KeepTheRendererFree.cs new file mode 100644 index 0000000..44aa54c --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Steps/Step2KeepTheRendererFree.cs @@ -0,0 +1,153 @@ +using System.Diagnostics; +using HackerNews.Components.Pages; + +namespace HackerNews; + +public sealed class Step2KeepTheRendererFree : WorkshopStep +{ + const string _blockingHint = "A blocking wait holds whichever thread runs it until the task finishes. Inside an async method, await the task instead, and keep the 2 second minimum: " + + "moving the wait off Blazor's renderer with ConfigureAwait(false), or swapping Wait() for Result or GetAwaiter().GetResult(), still blocks a thread pool thread for every refresh."; + + // A render of 50 stories takes a few milliseconds. A blocking wait on the 2 second minimum holds the renderer for well over a second. + static readonly TimeSpan _maximumRendererDelay = TimeSpan.FromMilliseconds(500); + + static readonly TimeSpan _heartbeatInterval = TimeSpan.FromMilliseconds(25); + + static readonly TimeSpan _minimumRefreshTime = TimeSpan.FromSeconds(1.9); + + // Every call answers almost at once, so nearly all of a refresh is spent on its 2 second minimum + static readonly TimeSpan _responseTime = TimeSpan.FromMilliseconds(1); + + public override int Number => 2; + + public override string Scenario => "Keep Blazor's renderer free"; + + public override string Title => "RefreshAsync(CancellationToken)"; + + public override string Story => "Every browser tab connected to a Blazor Server app has its own renderer, and your component code runs on it one piece of work at a time, much like a UI thread. " + + "RefreshAsync() ends by calling Wait() on its 2 second minimum refresh time. When that line runs on the renderer, the tab's clicks, renders and UI updates queue up behind it, and a thread pool thread sits blocked doing nothing. " + + "One user pressing Refresh never notices. Two hundred users pressing it at once starve the thread pool for everyone on the server."; + + public override string SeeItInTheApp => "On the Top stories page, press Refresh. The spinner keeps turning, because the browser animates it, so the page looks busy rather than stuck. " + + "For most of those 2 seconds, though, the server cannot run anything else for your tab. This step posts a tiny piece of work to the renderer every 25 milliseconds during a refresh, and times how long each one waits."; + + public override string FileToChange => "Components/Pages/News.razor.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Replace the blocking wait on the minimum refresh time with await. Keep the 2 second minimum: the refresh indicator should still stop only after it ends.", + "Do not trade Wait() for Result or GetAwaiter().GetResult(). They block exactly the same way.", + "Use ConfigureAwait(false) only where the code after the await does not need Blazor's context.", + "After ConfigureAwait(false), make every change to the page's state, and every StateHasChanged(), through InvokeAsync(...).", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "A blocking wait is used inside an async method.", + "A continuation is allowed to capture context even when it does not need to. Ask which thread runs the finally block after the await above it.", + "ConfigureAwait(false) moves the Wait() off the renderer, but it still blocks a thread pool thread. That is why this step reads your code as well as timing the renderer.", + "UI state changes must be marshaled through Blazor's renderer when continuations run away from the captured context. Blazor throws InvalidOperationException when StateHasChanged() runs anywhere else.", + ]; + + public override string TimeoutHint => "A refresh never finished. Check for a blocking wait that runs on Blazor's renderer: the code after each await needs the renderer to finish, and a wait that holds it can hold it forever."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + // Part 1: read the compiled page for blocking waits. A Wait() that runs off the renderer does not delay it, so timing alone would miss it. + var blockingCalls = FindBlockingCalls(); + report.Log(blockingCalls.Count is 0 ? "NewsPageBase never blocks on a task" : $"NewsPageBase blocks on a task with {string.Join(", ", blockingCalls)}"); + + report.Expect("NewsPageBase never calls Wait(), Result or GetAwaiter().GetResult() on a task", "none", blockingCalls.Count is 0 ? "none" : string.Join(", ", blockingCalls), blockingCalls.Count is 0, _blockingHint); + + if (NewsPageSession.HasAsyncVoidMethods(report)) + return; + + // Part 2: load the page, then press Refresh and time how long the renderer takes to pick up a tiny piece of work, every 25 ms, until the refresh ends + await using var session = new NewsPageSession(new FakeHackerNewsAPI(_responseTime)); + + report.Log("Rendering the News page and waiting for it to finish initializing"); + await session.BeginRendering(token).ConfigureAwait(false); + + if (!await session.WaitForInitialization(token).ConfigureAwait(false)) + { + report.Expect("The page finishes initializing", "within 10 seconds", "did not finish", false, "Step 1 checks this. Make it pass again first."); + return; + } + + report.Log("Pressing Refresh"); + + var stopwatch = Stopwatch.StartNew(); + var refreshTask = session.PressRefresh(); + var longestDelay = TimeSpan.Zero; + var heartbeats = 0; + + while (!refreshTask.IsCompleted && stopwatch.Elapsed < NewsPageSession.RenderTimeout) + { + var postedAt = stopwatch.Elapsed; + var ranAt = await session.Dispatcher.InvokeAsync(() => stopwatch.Elapsed).WaitAsync(NewsPageSession.RenderTimeout, token).ConfigureAwait(false); + var delay = ranAt - postedAt; + + if (delay > longestDelay) + { + longestDelay = delay; + report.Log($"The renderer took {delay.TotalMilliseconds:0} ms to run a piece of work posted {postedAt.TotalMilliseconds:0} ms into the refresh"); + } + + heartbeats++; + await Task.Delay(_heartbeatInterval, token).ConfigureAwait(false); + } + + await refreshTask.WaitAsync(NewsPageSession.RenderTimeout, token).ConfigureAwait(false); + var refreshTime = stopwatch.Elapsed; + + var isRefreshing = await session.Read(static page => page.IsRefreshing, token).ConfigureAwait(false); + + report.Log($"The refresh finished after {refreshTime.TotalSeconds:F2}s. The renderer ran {heartbeats} pieces of work, and the longest wait was {longestDelay.TotalMilliseconds:0} ms"); + + report.Expect( + "While a refresh runs, the renderer picks up other work quickly", + $"within {_maximumRendererDelay.TotalMilliseconds:0} ms", + $"{longestDelay.TotalMilliseconds:0} ms", + longestDelay <= _maximumRendererDelay, + "Blazor's renderer was blocked, so everything else for this browser tab had to wait. Something in the refresh holds the renderer's thread while it waits for a task, and the code after the awaits in RefreshAsync(CancellationToken) runs on the renderer. Await the task instead."); + + report.Expect( + "Refresh keeps its indicator up for the 2 second minimum, then turns it off", + "at least 2 seconds, then off", + $"{refreshTime.TotalSeconds:F1} seconds, then {(isRefreshing ? "still on" : "off")}", + refreshTime >= _minimumRefreshTime && !isRefreshing, + refreshTime < _minimumRefreshTime + ? "The refresh ended before its 2 second minimum. Await the minimum refresh time rather than removing the wait." + : "IsListRefreshing is still true after the refresh finished. Turn it off, through InvokeAsync(...), once the minimum refresh time has passed."); + + await session.ExpectNoErrors(report, token).ConfigureAwait(false); + } + + // Blocking waits the compiler never generates. Every await compiles into one IsCompleted check and one GetResult() call on the same awaiter, + // so an awaiter's GetResult() call without its own IsCompleted check was written by hand. + static IReadOnlyList FindBlockingCalls() + { + var calledMethods = CodeInspector.GetCalledMethods(typeof(NewsPageBase)); + List blockingCalls = []; + + if (calledMethods.Any(static method => method is "Task.Wait" or "Task.WaitAll" or "Task.WaitAny")) + blockingCalls.Add("Wait()"); + + if (calledMethods.Any(static method => method is "Task`1.get_Result" or "ValueTask`1.get_Result")) + blockingCalls.Add("Result"); + + var awaiterTypes = calledMethods + .Where(static method => method.EndsWith(".GetResult", StringComparison.Ordinal)) + .Select(static method => method[..^".GetResult".Length]) + .Where(static type => type.Contains("Awaiter", StringComparison.Ordinal)) + .Distinct(); + + if (awaiterTypes.Any(awaiterType => calledMethods.Count(method => method == $"{awaiterType}.GetResult") > calledMethods.Count(method => method == $"{awaiterType}.get_IsCompleted"))) + blockingCalls.Add("GetAwaiter().GetResult()"); + + return blockingCalls; + } +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Steps/Step3StopWhenThePageGoesAway.cs b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Steps/Step3StopWhenThePageGoesAway.cs new file mode 100644 index 0000000..f3c393d --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Steps/Step3StopWhenThePageGoesAway.cs @@ -0,0 +1,202 @@ +using System.Diagnostics; + +namespace HackerNews; + +public sealed class Step3StopWhenThePageGoesAway : WorkshopStep +{ + const int _storiesBeforeClosing = 10; + + // Stopping a refresh takes a few milliseconds. A refresh that ignores its token runs out the rest of its 2 second minimum. + static readonly TimeSpan _maximumStopTime = TimeSpan.FromMilliseconds(500); + + static readonly TimeSpan _responseTime = TimeSpan.FromMilliseconds(10); + + public override int Number => 3; + + public override string Scenario => "Stop refresh work when the page goes away"; + + public override string Title => "RefreshAsync(CancellationToken) and Dispose()"; + + public override string Story => "People open the Top stories page, glance at it, and leave. When they do, Blazor disposes the page, and Dispose() cancels the page's CancellationTokenSource so that its refresh can stop. " + + "RefreshAsync(CancellationToken) receives that token and passes it to Hacker News, but not to its 2 second minimum refresh time, so every abandoned page keeps its refresh alive on the server until the delay runs out. " + + "The other half of ending well is failing well: when Hacker News is down, the page has to say so in words a user can act on, while the details go to the server log."; + + public override string SeeItInTheApp => "On the Top stories page, press Refresh, and reload the browser tab straight away. The page is gone from your browser, but its refresh keeps running on the server until the 2 second minimum ends. " + + "If you are offline, or your network blocks Hacker News, the page shows its refresh error instead of stories: a short message you can act on, with the details in the terminal."; + + public override string FileToChange => "Components/Pages/News.razor.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Forward the CancellationToken that RefreshAsync(CancellationToken) receives to every cancellable async API it calls, including the minimum refresh time.", + "When the page goes away mid-refresh, let the refresh end quietly: no exception may escape it. Catch OperationCanceledException only when your own token was canceled.", + "Keep logging the full exception on the server, and keep showing users a generic, actionable refresh error. Never put exception text on the page.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "A cancellation token is accepted but not always forwarded.", + "Dispose() cancels _disposeCancellationTokenSource. Follow its token through RefreshAsync(CancellationToken), and look for an async call that never sees it.", + "Once the minimum refresh time listens to the token, awaiting it after the page goes away throws TaskCanceledException, and that await is in a finally block.", + "ConfigureAwaitOptions.SuppressThrowing awaits a Task without throwing when it is canceled or faulted. It works on Task, not on Task.", + ]; + + public override string TimeoutHint => "A refresh never finished after the page closed, or after Hacker News failed. Check that every await in RefreshAsync(CancellationToken) can end once its token is canceled."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + if (NewsPageSession.HasAsyncVoidMethods(report)) + return; + + await ClosePageMidRefresh(report, token).ConfigureAwait(false); + + await FailRefresh(report, token).ConfigureAwait(false); + } + + // Part 1: press Refresh, then close the page while the refresh is still loading stories, the way a user navigates away + static async Task ClosePageMidRefresh(StepReport report, CancellationToken token) + { + var hackerNewsApi = new FakeHackerNewsAPI(_responseTime); + await using var session = new NewsPageSession(hackerNewsApi); + + report.Log("Rendering the News page and waiting for it to finish initializing"); + await session.BeginRendering(token).ConfigureAwait(false); + + if (!await session.WaitForInitialization(token).ConfigureAwait(false)) + { + report.Expect("The page finishes initializing", "within 10 seconds", "did not finish", false, "Step 1 checks this. Make it pass again first."); + return; + } + + var requestsBeforeRefresh = hackerNewsApi.StoryRequests; + + report.Log("Pressing Refresh"); + var refreshTask = session.PressRefresh(); + + var waitForStories = Stopwatch.StartNew(); + + while (hackerNewsApi.StoryRequests - requestsBeforeRefresh < _storiesBeforeClosing && !refreshTask.IsCompleted && waitForStories.Elapsed < NewsPageSession.RenderTimeout) + { + await Task.Delay(TimeSpan.FromMilliseconds(5), token).ConfigureAwait(false); + } + + var requestsWhenClosed = hackerNewsApi.StoryRequests; + report.Log($"Closing the page after the refresh asked Hacker News for {requestsWhenClosed - requestsBeforeRefresh} stories"); + + var stopwatch = Stopwatch.StartNew(); + await session.ClosePage().ConfigureAwait(false); + + Exception? escapedException = null; + + try + { + await refreshTask.WaitAsync(NewsPageSession.RenderTimeout, token).ConfigureAwait(false); + } + catch (Exception e) when (e is not TimeoutException && !token.IsCancellationRequested) + { + escapedException = e; + } + + var stopTime = stopwatch.Elapsed; + var requestsAfterClosing = hackerNewsApi.StoryRequests - requestsWhenClosed; + + report.Log($"The refresh ended {stopTime.TotalMilliseconds:0} ms after the page closed, and asked Hacker News for {requestsAfterClosing} more stories after that"); + + if (escapedException is not null) + report.Log($"The refresh ended with {escapedException.GetType().Name}"); + + report.Expect( + "After the page closes mid-refresh, the refresh ends quickly", + $"within {_maximumStopTime.TotalMilliseconds:0} ms", + $"{stopTime.TotalMilliseconds:0} ms", + stopTime <= _maximumStopTime, + "The page was gone, but its refresh kept running until the minimum refresh time ran out. Dispose() cancels the token RefreshAsync(CancellationToken) receives, and a cancellable async call can only stop early if it is given that token."); + + // One request can start at the very moment the page closes + report.Expect( + "The refresh asks Hacker News for no more stories after the page closes", + "at most 1", + $"{requestsAfterClosing}", + requestsAfterClosing <= 1, + "The refresh kept fetching stories for a page nobody can see. Forward the CancellationToken to every call that accepts one, all the way down to HackerNewsApiService."); + + report.Expect( + "No exception escapes the refresh when the page closes", + "none", + escapedException?.GetType().Name ?? "none", + escapedException is null, + "In the app, an exception that escapes a Refresh click shows Blazor's unhandled error bar and ends the user's connection. Awaiting a canceled task throws, even in a finally block. " + + "Await the minimum refresh time in a way that does not throw when it is canceled, and catch OperationCanceledException only when your own token was canceled."); + } + + // Part 2: load the page while Hacker News is down + static async Task FailRefresh(StepReport report, CancellationToken token) + { + await using var session = new NewsPageSession(new FakeHackerNewsAPI(_responseTime) { IsFailing = true }); + + report.Log("Rendering the News page while every Hacker News call throws HttpRequestException"); + await session.BeginRendering(token).ConfigureAwait(false); + + if (!await session.WaitForInitialization(token).ConfigureAwait(false)) + { + report.Expect("The page finishes initializing when Hacker News is down", "within 10 seconds", "did not finish", false, "Something in the refresh waits forever once a call to Hacker News fails. Check the catch and finally blocks in RefreshAsync(CancellationToken)."); + return; + } + + var errorMessage = await session.Read(static page => page.ErrorMessage, token).ConfigureAwait(false); + var isRefreshing = await session.Read(static page => page.IsRefreshing, token).ConfigureAwait(false); + var html = await session.GetHtml(token).ConfigureAwait(false); + var errors = session.Logger.Errors; + + report.Log(errorMessage is null ? "The page shows no error message" : $"The page shows the error message \"{errorMessage}\""); + report.Log(errors.Count is 0 ? "Your page logged no errors" : $"Your page logged: {string.Join(", ", errors.Select(static error => $"\"{error.Message}\" ({error.Exception?.GetType().Name ?? "no exception"})"))}"); + + report.Expect( + "When Hacker News is down, the page shows an error message", + "an error message", + errorMessage is null ? "none" : "an error message", + errorMessage is not null, + "The refresh failed and the page said nothing. Catch the failure in RefreshAsync(CancellationToken) and set RefreshErrorMessage to a message the user can act on."); + + var showsDetails = ShowsExceptionDetails(errorMessage) || ShowsExceptionDetails(html); + + report.Expect( + "The page shows no exception details", + "no exception details", + showsDetails ? "the exception's message or type" : "no exception details", + !showsDetails, + "Exception text can reveal servers, URLs and internals, and it means nothing to the person reading it. Show a generic message that says what to try next, and keep the details in the server log."); + + var loggedFailure = errors.Any(static error => IsFailure(error.Exception)); + + report.Expect( + "The full exception is logged on the server", + "HttpRequestException logged", + loggedFailure ? "HttpRequestException logged" : errors.Count is 0 ? "no errors logged" : "logged without the exception", + loggedFailure, + "Pass the exception itself to Logger.LogError(...), not just its message, so the server log keeps its type and stack trace."); + + report.Expect( + "The refresh indicator turns off after a failed refresh", + "off", + isRefreshing ? "still on" : "off", + !isRefreshing, + "A failed refresh has to stop the indicator too. Turn IsListRefreshing off where it runs whether or not the refresh succeeded."); + } + + static bool ShowsExceptionDetails(string? text) => text is not null + && (text.Contains(FakeHackerNewsAPI.FailureDetail, StringComparison.Ordinal) + || text.Contains(nameof(HttpRequestException), StringComparison.Ordinal) + || text.Contains("Service Unavailable", StringComparison.Ordinal)); + + static bool IsFailure(Exception? exception) => exception switch + { + HttpRequestException { Message: FakeHackerNewsAPI.FailureDetail } => true, + AggregateException aggregateException => aggregateException.InnerExceptions.Any(IsFailure), + null => false, + _ => IsFailure(exception.InnerException), + }; +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Steps/Step4StreamStories.cs b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Steps/Step4StreamStories.cs new file mode 100644 index 0000000..6c2bdf3 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Steps/Step4StreamStories.cs @@ -0,0 +1,132 @@ +using System.Diagnostics; +using HackerNews.Components.Pages; + +namespace HackerNews; + +public sealed class Step4StreamStories : WorkshopStep +{ + const string _notStreamingHint = "The page showed nothing until the very last story arrived, even though Hacker News had already answered for every other story. " + + "Hand each story to the page as soon as it arrives, add it to the list in its sorted place through InvokeAsync(...), and render it."; + + // The front page's last story is the one Hacker News is slow to return + static readonly long _slowStoryId = FakeHackerNewsAPI.GetStoryId(StoriesConstants.NumberOfStories); + + // Streaming renders a story a few milliseconds after it arrives. This is how long the step looks for one before deciding nothing streamed. + static readonly TimeSpan _renderWait = TimeSpan.FromSeconds(1); + + static readonly TimeSpan _responseTime = TimeSpan.FromMilliseconds(10); + + public override int Number => 4; + + public override string Scenario => "Stream stories as they arrive"; + + public override string Title => "GetTopStories() and RefreshAsync(CancellationToken)"; + + public override string Story => "Hacker News serves every story as its own request, so the page makes 50 of them. GetTopStories() collects every story into a list, sorts it, and only then hands it back, " + + "so people stare at the loading skeleton until the slowest story arrives, even though most of the others came back long before. " + + "The page already knows how to put a single story in its sorted place. It just never gets a story until it has all of them."; + + public override string SeeItInTheApp => "Reload the Top stories page in your browser and watch the count of loaded stories in the orange bar. It sits at 0 behind the loading skeleton, then jumps straight to 50. " + + "Once the stories stream, it counts up as they arrive. This step makes the last story slow, and looks at the page while it waits."; + + public override string FileToChange => "Components/Pages/News.razor.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Stream the stories out of GetTopStories() as an IAsyncEnumerable, handing each story back as soon as it arrives.", + "Give the stream the CancellationToken as well, so a canceled refresh stops the stream. [EnumeratorCancellation] connects the token passed to WithCancellation() to the iterator.", + "In RefreshAsync(CancellationToken), clear the list once, then add each story to its sorted place through InvokeAsync(...), and render it as it arrives.", + "Keep the list sorted by score, and keep every story in it once.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "Story loading waits for a full list before the UI can process results.", + "An async iterator is an async method that can both await and yield return.", + "await foreach consumes an IAsyncEnumerable, and ConfigureAwait(false) works on it too.", + "InsertIntoSortedList() already puts one story in its place, so nothing needs sorting at the end.", + ]; + + public override string TimeoutHint => "The story stream never finished. Check that GetTopStories() completes after its last story, and that the loop in RefreshAsync(CancellationToken) ends."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + // Part 1: read the compiled page for the stream's return type + var getTopStories = CodeInspector.FindMethod(typeof(NewsPageBase), "GetTopStories"); + var returnType = getTopStories?.ReturnType; + + report.Expect( + "GetTopStories() returns IAsyncEnumerable", + "IAsyncEnumerable", + returnType is null ? "no GetTopStories() method" : CodeInspector.DescribeType(returnType), + returnType == typeof(IAsyncEnumerable), + returnType is null + ? "Keep the method's name, GetTopStories, so this step can find it." + : "A method that returns a list can only return once, when every story is in it. An async iterator returns IAsyncEnumerable and can hand back one story at a time."); + + if (NewsPageSession.HasAsyncVoidMethods(report)) + return; + + // Part 2: load the page with the front page's last story held back, and look at the page while every other story has arrived + var hackerNewsApi = new FakeHackerNewsAPI(_responseTime) { HeldStoryId = _slowStoryId }; + await using var session = new NewsPageSession(hackerNewsApi); + + try + { + report.Log($"Rendering the News page. Every Hacker News call takes {_responseTime.TotalMilliseconds:0} ms, except story {StoriesConstants.NumberOfStories}, which waits until the step has looked at the page"); + await session.BeginRendering(token).ConfigureAwait(false); + + await hackerNewsApi.EveryOtherStoryReturned.WaitAsync(NewsPageSession.RenderTimeout, token).ConfigureAwait(false); + report.Log($"Hacker News has returned {hackerNewsApi.StoriesReturned} stories. Story {StoriesConstants.NumberOfStories} is still on its way"); + + var storiesOnPage = 0; + var stopwatch = Stopwatch.StartNew(); + + while (storiesOnPage is 0 && stopwatch.Elapsed < _renderWait) + { + storiesOnPage = NewsPageSession.CountStoriesOnPage(await session.GetHtml(token).ConfigureAwait(false)); + + if (storiesOnPage is 0) + await Task.Delay(TimeSpan.FromMilliseconds(20), token).ConfigureAwait(false); + } + + report.Log($"While story {StoriesConstants.NumberOfStories} is on its way, the page shows {storiesOnPage} stories"); + + report.Expect( + "While the last story is on its way, the page already shows the stories that have arrived", + "at least 1 story on the page", + $"{NewsPageSession.DescribeStories(storiesOnPage)} on the page", + storiesOnPage > 0, + _notStreamingHint); + } + finally + { + hackerNewsApi.ReleaseHeldStory(); + } + + if (!await session.WaitForInitialization(token).ConfigureAwait(false)) + { + report.Expect("The page finishes initializing", "within 10 seconds", "did not finish", false, TimeoutHint); + return; + } + + // Part 3: once the last story arrives, the list is complete, sorted, and has no story twice + var stories = await session.Read(static page => page.Stories, token).ConfigureAwait(false); + var isSorted = stories.Zip(stories.Skip(1)).All(static pair => pair.First.Score >= pair.Second.Score); + var distinctStories = stories.DistinctBy(static story => story.Id).Count(); + + report.Log($"The refresh finished with {stories.Count} stories, {(isSorted ? "sorted" : "not sorted")} by score, {distinctStories} of them different"); + + report.Expect( + "When the refresh finishes, the page lists every story once, highest score first", + $"{StoriesConstants.NumberOfStories} stories, sorted, none twice", + $"{NewsPageSession.DescribeStories(stories.Count)}, {(isSorted ? "sorted" : "not sorted")}, {(distinctStories == stories.Count ? "none twice" : $"{stories.Count - distinctStories} twice")}", + stories.Count is StoriesConstants.NumberOfStories && isSorted && distinctStories == stories.Count, + "Stories now arrive one at a time and in any order. Clear the list once before the first story, insert each story into its sorted place, and skip a story that is already in the list."); + + await session.ExpectNoErrors(report, token).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Steps/Step5SkipUnneededStateMachines.cs b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Steps/Step5SkipUnneededStateMachines.cs new file mode 100644 index 0000000..b04a810 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Steps/Step5SkipUnneededStateMachines.cs @@ -0,0 +1,122 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using HackerNews.Components.Pages; + +namespace HackerNews; + +public sealed class Step5SkipUnneededStateMachines : WorkshopStep +{ + static readonly TimeSpan _responseTime = TimeSpan.FromMilliseconds(1); + + public override int Number => 5; + + public override string Scenario => "Skip the state machines you don't need"; + + public override string Title => "GetStory() and GetTopStoryIDs()"; + + public override string Story => "Every async method compiles into a state machine, and a call that does not finish synchronously allocates one. GetStory() only awaits one call and returns its result, " + + "so its state machine does nothing but cost, 50 times per refresh, on every page, for every user. " + + "GetTopStoryIDs() is the opposite: when the stories on the page are recent, it answers from them without awaiting anything, yet every call still allocates a new Task to hand back a result it already had."; + + public override string SeeItInTheApp => "Nothing on the page changes in this step, which is exactly why these costs survive code review. Press Refresh twice on the Top stories page: " + + "the second refresh reuses the story IDs already on the page instead of asking Hacker News, so GetTopStoryIDs() completes synchronously and still allocates a Task."; + + public override string FileToChange => "Components/Pages/News.razor.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Return Task directly from GetStory(), a method that only wraps another task, instead of awaiting that task in an async method.", + "Return ValueTask> from GetTopStoryIDs(), because its hot path completes synchronously on most refreshes.", + "Use ValueTask only where the hot path can complete synchronously. GetStory() always waits for the network, so it stays a Task.", + "Keep the cached path in GetTopStoryIDs() synchronous: when the stories on the page are recent, it must not call Hacker News.", + "Where GetTopStoryIDs() does await, ask whether the code after it needs Blazor's context.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "Some methods create async state machines even though they only wrap another async call.", + "A method whose only await is the task it returns, with no try, using or code after it, does not need to be async.", + "ValueTask avoids allocating when the result is already available. A ValueTask may only be awaited once.", + "Changing a method's return type changes how its callers consume it.", + ]; + + public override string TimeoutHint => "GetTopStoryIDs() never answered. Check that its cached path returns without waiting for anything."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + // Part 1: read the compiled page. The compiler marks every async method with AsyncStateMachineAttribute. + var getStory = FindMethod("GetStory", typeof(long), typeof(CancellationToken)); + var getTopStoryIds = FindMethod("GetTopStoryIDs", typeof(CancellationToken)); + + report.Log(getStory is null ? "NewsPageBase has no GetStory(long, CancellationToken)" : $"GetStory() returns {CodeInspector.DescribeType(getStory.ReturnType)}{(IsAsync(getStory) ? " and is an async method" : string.Empty)}"); + report.Log(getTopStoryIds is null ? "NewsPageBase has no GetTopStoryIDs(CancellationToken)" : $"GetTopStoryIDs() returns {CodeInspector.DescribeType(getTopStoryIds.ReturnType)}"); + + report.Expect( + "GetStory() returns Task without an async state machine", + "Task, not async", + getStory is null ? "no GetStory(long, CancellationToken) method" : $"{CodeInspector.DescribeType(getStory.ReturnType)}, {(IsAsync(getStory) ? "async" : "not async")}", + getStory is not null && getStory.ReturnType == typeof(Task) && !IsAsync(getStory), + getStory is null + ? "Keep GetStory(long, CancellationToken), so this step can find it." + : getStory.ReturnType != typeof(Task) + ? "GetStory() always waits for the network, so a ValueTask would never complete synchronously and would only add cost. Keep returning Task." + : "GetStory() awaits a task and returns its result, and that is all. Its caller can await the same task, so hand it back directly and let the compiler skip the state machine."); + + report.Expect( + "GetTopStoryIDs() returns ValueTask>", + "ValueTask>", + getTopStoryIds is null ? "no GetTopStoryIDs(CancellationToken) method" : CodeInspector.DescribeType(getTopStoryIds.ReturnType), + getTopStoryIds?.ReturnType == typeof(ValueTask>), + getTopStoryIds is null + ? "Keep GetTopStoryIDs(CancellationToken), so this step can find it." + : "When the stories on the page are recent, GetTopStoryIDs() already has its answer, yet a Task still has to be allocated to carry it. ValueTask carries a result that is already available without allocating."); + + if (getTopStoryIds is null || NewsPageSession.HasAsyncVoidMethods(report)) + return; + + // Part 2: load the page, then call GetTopStoryIDs() on Blazor's renderer, the way a second refresh does, and see whether it needed Hacker News + await using var session = new NewsPageSession(new FakeHackerNewsAPI(_responseTime)); + + report.Log("Rendering the News page and waiting for it to finish initializing"); + await session.BeginRendering(token).ConfigureAwait(false); + + if (!await session.WaitForInitialization(token).ConfigureAwait(false)) + { + report.Expect("The page finishes initializing", "within 10 seconds", "did not finish", false, "Step 1 checks this. Make it pass again first."); + return; + } + + var requestsBefore = session.HackerNewsApi.TopStoryIdRequests; + report.Log("The page has loaded its stories. Calling GetTopStoryIDs() the way a second refresh does"); + + var (completedSynchronously, storyIds) = await session.Read(page => CallGetTopStoryIds(getTopStoryIds, page), token).ConfigureAwait(false); + var storyIdCount = storyIds is null ? 0 : (await storyIds.WaitAsync(NewsPageSession.RenderTimeout, token).ConfigureAwait(false)).Count; + var requestsDuringCall = session.HackerNewsApi.TopStoryIdRequests - requestsBefore; + + report.Log($"GetTopStoryIDs() {(completedSynchronously ? "completed synchronously" : "did not complete synchronously")}, returned {storyIdCount} IDs, and asked Hacker News {requestsDuringCall} times"); + + report.Expect( + "With recent stories on the page, GetTopStoryIDs() completes synchronously without calling Hacker News", + "completed synchronously, 0 calls", + $"{(completedSynchronously ? "completed synchronously" : "did not complete synchronously")}, {requestsDuringCall} calls", + completedSynchronously && requestsDuringCall is 0, + "The cached path is the reason GetTopStoryIDs() can return a ValueTask. When IsDataRecent() is true, return the IDs of the stories on the page without awaiting anything."); + } + + static MethodInfo? FindMethod(string name, params Type[] parameterTypes) => + typeof(NewsPageBase).GetMethod(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, parameterTypes); + + static bool IsAsync(MethodInfo method) => method.IsDefined(typeof(AsyncStateMachineAttribute), false); + + // Runs on Blazor's renderer. Whatever GetTopStoryIDs() returns, report whether it had already completed, and hand back a Task to await for its result. + static (bool CompletedSynchronously, Task>? StoryIds) CallGetTopStoryIds(MethodInfo getTopStoryIds, NewsPageProbe page) => + getTopStoryIds.Invoke(page, [CancellationToken.None]) switch + { + ValueTask> valueTask => (valueTask.IsCompleted, valueTask.AsTask()), + Task> task => (task.IsCompleted, task), + _ => (false, null), + }; +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Steps/StepContext.cs b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Steps/StepContext.cs new file mode 100644 index 0000000..a46f8d1 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Steps/StepContext.cs @@ -0,0 +1,4 @@ +namespace HackerNews; + +// Token is cancelled when the run is stopped, times out, or the app shuts down +public sealed record StepContext(StepReport Report, CancellationToken Token); \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Steps/WorkshopStep.cs b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Steps/WorkshopStep.cs new file mode 100644 index 0000000..99b1f88 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Steps/WorkshopStep.cs @@ -0,0 +1,37 @@ +namespace HackerNews; + +// Every step is one async await mistake in the Top stories page. +// Its tab in the workshop guide tells you what to fix, and Run() renders your News page the way Blazor does for a browser tab, logs what happens, +// and records an expected result, with a hint, for everything that should be true once the mistake is fixed. +public abstract class WorkshopStep +{ + public abstract int Number { get; } + + // What this step fixes, in a few words + public abstract string Scenario { get; } + + // The members this step exercises + public abstract string Title { get; } + + // What goes wrong in production, and why it slipped through + public abstract string Story { get; } + + // How to watch this mistake happen on the Top stories page + public abstract string SeeItInTheApp { get; } + + // Where the mistake lives + public abstract string FileToChange { get; } + + // What this step asks you to change. Some of it cannot be measured from outside, so not every task has an expected result. + public abstract IReadOnlyList Tasks { get; } + + // Shown only when you ask for them + public abstract IReadOnlyList Clues { get; } + + // What is most likely waiting forever when this step does not finish in time + public abstract string TimeoutHint { get; } + + public string SourceFile => $"Steps/{GetType().Name}.cs"; + + public abstract Task Run(StepContext context); +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/ActiveStepRun.cs b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/ActiveStepRun.cs new file mode 100644 index 0000000..419eb90 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/ActiveStepRun.cs @@ -0,0 +1,3 @@ +namespace HackerNews; + +public sealed record ActiveStepRun(WorkshopStep Step, StepReport Report); \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/CapturingLogger.cs b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/CapturingLogger.cs new file mode 100644 index 0000000..99aed98 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/CapturingLogger.cs @@ -0,0 +1,39 @@ +using HackerNews.Components.Pages; + +namespace HackerNews; + +// Workshop plumbing: the ILogger a step gives your page, so it can check what the page logged on the server. +// Nothing is written to the terminal, so the errors a step causes on purpose do not look like errors in your app. +public sealed class CapturingLogger : ILogger +{ + readonly Lock _lock = new(); + readonly List _entries = []; + + public IReadOnlyList Entries + { + get + { + lock (_lock) + { + return [.. _entries]; + } + } + } + + public IReadOnlyList Errors => [.. Entries.Where(static entry => entry.Level >= LogLevel.Error)]; + + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + var entry = new LoggedEntry(logLevel, formatter(state, exception), exception); + + lock (_lock) + { + _entries.Add(entry); + } + } +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/CodeInspector.cs b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/CodeInspector.cs new file mode 100644 index 0000000..33792b3 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/CodeInspector.cs @@ -0,0 +1,117 @@ +using System.Reflection; +using System.Reflection.Emit; +using System.Runtime.CompilerServices; + +namespace HackerNews; + +// Workshop plumbing: reads a type's compiled code, including the lambdas and async state machines the compiler generates for it. +// A few requirements cannot always be seen from outside, such as a blocking wait that happens to run off Blazor's renderer, so a step reads the code instead. +public static class CodeInspector +{ + const BindingFlags _declaredMembers = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + + static readonly Dictionary _opCodes = typeof(OpCodes) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Select(static field => field.GetValue(null) is OpCode opCode ? opCode : throw new InvalidOperationException($"OpCodes.{field.Name} is not an OpCode")) + .ToDictionary(static opCode => opCode.Value); + + // Every method the type's code calls, written as Type.Method, once per call. A method called from two places is listed twice. + public static IReadOnlyList GetCalledMethods(Type type) + { + List calledMethods = []; + + foreach (var declaringType in GetTypeAndNestedTypes(type)) + { + var methods = declaringType.GetMethods(_declaredMembers).Cast().Concat(declaringType.GetConstructors(_declaredMembers)); + + foreach (var method in methods) + { + AddCalledMethods(method, calledMethods); + } + } + + return calledMethods; + } + + // async void methods, including async lambdas the compiler turned into async void methods + public static IReadOnlyList GetAsyncVoidMethods(Type type) => + [ + .. GetTypeAndNestedTypes(type) + .SelectMany(static declaringType => declaringType.GetMethods(_declaredMembers)) + .Where(static method => method.ReturnType == typeof(void) && method.IsDefined(typeof(AsyncStateMachineAttribute), false)) + .Select(static method => DescribeMethod(method.Name)), + ]; + + public static MethodInfo? FindMethod(Type type, string name) => type.GetMethods(_declaredMembers).FirstOrDefault(method => method.Name == name); + + // Task> rather than Task`1 + public static string DescribeType(Type type) => type.IsGenericType + ? $"{type.Name[..type.Name.IndexOf('`')]}<{string.Join(", ", type.GetGenericArguments().Select(DescribeType))}>" + : Type.GetTypeCode(type) switch + { + TypeCode.Boolean => "bool", + TypeCode.Int32 => "int", + TypeCode.Int64 => "long", + TypeCode.String => "string", + _ => type == typeof(void) ? "void" : type.Name, + }; + + // The compiler names a lambda b__12_0 + static string DescribeMethod(string name) => name.StartsWith('<') && name.IndexOf('>') is > 1 and var end + ? $"an async lambda in {name[1..end]}()" + : $"{name}()"; + + static IEnumerable GetTypeAndNestedTypes(Type type) => + type.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic).SelectMany(GetTypeAndNestedTypes).Prepend(type); + + static void AddCalledMethods(MethodBase method, List calledMethods) + { + if (method.GetMethodBody()?.GetILAsByteArray() is not { } il) + return; + + var offset = 0; + + while (offset < il.Length) + { + // Two byte opcodes start with 0xFE + short value = il[offset++]; + + if (value is 0xFE && offset < il.Length) + value = unchecked((short)(0xFE00 | il[offset++])); + + if (!_opCodes.TryGetValue(value, out var opCode)) + return; + + if (opCode.OperandType is OperandType.InlineMethod && ResolveMethod(method, BitConverter.ToInt32(il, offset)) is { } calledMethod) + calledMethods.Add(calledMethod.DeclaringType is { } declaringType ? $"{declaringType.Name}.{calledMethod.Name}" : calledMethod.Name); + + offset += opCode.OperandType switch + { + OperandType.InlineNone => 0, + OperandType.ShortInlineBrTarget or OperandType.ShortInlineI or OperandType.ShortInlineVar => 1, + OperandType.InlineVar => 2, + OperandType.InlineI8 or OperandType.InlineR => 8, + OperandType.InlineSwitch => 4 + (BitConverter.ToInt32(il, offset) * 4), + _ => 4, + }; + } + } + + static MethodBase? ResolveMethod(MethodBase method, int metadataToken) + { + var declaringType = method.DeclaringType; + + try + { + return method.Module.ResolveMethod( + metadataToken, + declaringType is { IsGenericType: true } ? declaringType.GetGenericArguments() : null, + method.IsGenericMethod ? method.GetGenericArguments() : null); + } + catch (ArgumentException) + { + // A call that cannot be resolved outside its generic context is not one a step looks for + return null; + } + } +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/FakeHackerNewsAPI.cs b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/FakeHackerNewsAPI.cs new file mode 100644 index 0000000..44909ec --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/FakeHackerNewsAPI.cs @@ -0,0 +1,83 @@ +namespace HackerNews; + +// Workshop plumbing: an in-memory Hacker News for the steps, so every check runs offline and gives the same answer on every machine. +// Every call waits a delay the step chooses and honors its CancellationToken, the way a real HTTP request does. +public sealed class FakeHackerNewsAPI(TimeSpan responseTime) : IHackerNewsAPI +{ + // More than the page asks for, like the real API + public const int TopStoryCount = 80; + + // What the fake puts in the exception when it is failing. None of it may ever reach the page. + public const string FailureDetail = "Response status code does not indicate success: 503 (Service Unavailable) from hacker-news.firebaseio.com edge node sjc-7"; + + readonly TaskCompletionSource _heldStoryReleased = new(TaskCreationOptions.RunContinuationsAsynchronously); + readonly TaskCompletionSource _everyOtherStoryReturned = new(TaskCreationOptions.RunContinuationsAsynchronously); + readonly DateTimeOffset _now = DateTimeOffset.UtcNow; + + int _topStoryIdRequests; + int _storyRequests; + int _storiesReturned; + + // When set, every call throws HttpRequestException, the way it does when Hacker News is down + public bool IsFailing { get; init; } + + // When set, this story is not returned until ReleaseHeldStory() is called, so a step can look at the page while it waits + public long? HeldStoryId { get; init; } + + public int TopStoryIdRequests => Volatile.Read(ref _topStoryIdRequests); + + public int StoryRequests => Volatile.Read(ref _storyRequests); + + public int StoriesReturned => Volatile.Read(ref _storiesReturned); + + // Completes once every story the page shows, apart from the held one, has been returned + public Task EveryOtherStoryReturned => _everyOtherStoryReturned.Task; + + // Ranks start at 1, the way the front page numbers them + public static long GetStoryId(int rank) => 45_000_000 + rank; + + // Scores are deliberately out of rank order, so the page has to sort them. 37 and 101 share no factors, so no two ranks get the same score. + public static int GetScore(int rank) => 100 + (rank * 37 % 101); + + public void ReleaseHeldStory() => _heldStoryReleased.TrySetResult(); + + public async Task> GetTopStoryIDs(CancellationToken token) + { + Interlocked.Increment(ref _topStoryIdRequests); + + await Task.Delay(responseTime, token).ConfigureAwait(false); + + if (IsFailing) + throw new HttpRequestException(FailureDetail); + + return [.. Enumerable.Range(1, TopStoryCount).Select(GetStoryId)]; + } + + public async Task GetStory(long storyId, CancellationToken token) + { + Interlocked.Increment(ref _storyRequests); + + await Task.Delay(responseTime, token).ConfigureAwait(false); + + if (IsFailing) + throw new HttpRequestException(FailureDetail); + + if (storyId == HeldStoryId) + await _heldStoryReleased.Task.WaitAsync(token).ConfigureAwait(false); + + var rank = (int)(storyId - GetStoryId(0)); + + if (Interlocked.Increment(ref _storiesReturned) is StoriesConstants.NumberOfStories - 1 && HeldStoryId is not null) + _everyOtherStoryReturned.TrySetResult(); + + // Posted between 1 and 80 minutes ago, so the stories on the page always count as recent + return new StoryModel( + "workshop", + storyId, + GetScore(rank), + _now.AddMinutes(-rank).ToUnixTimeSeconds(), + $"Workshop story {rank}", + "story", + $"https://example.com/stories/{rank}"); + } +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/LoggedEntry.cs b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/LoggedEntry.cs new file mode 100644 index 0000000..7225bda --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/LoggedEntry.cs @@ -0,0 +1,3 @@ +namespace HackerNews; + +public sealed record LoggedEntry(LogLevel Level, string Message, Exception? Exception); \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/NewsPageProbe.cs b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/NewsPageProbe.cs new file mode 100644 index 0000000..24d3545 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/NewsPageProbe.cs @@ -0,0 +1,18 @@ +using HackerNews.Components.Pages; + +namespace HackerNews; + +// Workshop plumbing: your News page, plus a few read-only views of its state for the steps. +// It adds no behavior, so rendering it runs exactly the code the Top stories page runs. +// Read these members through NewsPageSession, which reads them on Blazor's renderer, the same way the page does. +public sealed class NewsPageProbe : News +{ + public IReadOnlyList Stories => [.. TopStoryCollection]; + + public bool IsRefreshing => IsListRefreshing; + + public string? ErrorMessage => RefreshErrorMessage; + + // What the Refresh button calls + public Task Refresh() => RefreshAsync(); +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/NewsPageSession.cs b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/NewsPageSession.cs new file mode 100644 index 0000000..db88c59 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/NewsPageSession.cs @@ -0,0 +1,174 @@ +using HackerNews.Components.Pages; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.Web.HtmlRendering; + +namespace HackerNews; + +// Workshop plumbing: renders your News page in memory with Blazor's HtmlRenderer, the way Blazor renders it for a browser tab. +// The renderer runs component code one piece of work at a time, just like the renderer behind every browser tab of a Blazor Server app. +// Every session has its own services and its own FakeHackerNewsAPI, so it never touches the app's singletons or the real Hacker News. +public sealed class NewsPageSession : IAsyncDisposable +{ + // Every wait on your page gives up after this long, so a page that never answers fails its check instead of hanging the step + public static readonly TimeSpan RenderTimeout = TimeSpan.FromSeconds(10); + + readonly ServiceProvider _serviceProvider; + readonly HtmlRenderer _renderer; + readonly NewsPageActivator _activator = new(); + + HtmlRootComponent? _rootComponent; + bool _isClosed; + + public NewsPageSession(FakeHackerNewsAPI hackerNewsApi) + { + HackerNewsApi = hackerNewsApi; + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton>(Logger); + services.AddSingleton(hackerNewsApi); + services.AddSingleton(); + services.AddSingleton(_activator); + + _serviceProvider = services.BuildServiceProvider(); + _renderer = new HtmlRenderer(_serviceProvider, _serviceProvider.GetRequiredService()); + } + + public FakeHackerNewsAPI HackerNewsApi { get; } + + public CapturingLogger Logger { get; } = new(); + + public Dispatcher Dispatcher => _renderer.Dispatcher; + + // Completes when Blazor considers the page initialized: when the Task returned by its lifecycle methods completes + public Task InitializationTask => RootComponent.QuiescenceTask; + + HtmlRootComponent RootComponent => _rootComponent ?? throw new InvalidOperationException("The News page has not been rendered yet"); + + NewsPageProbe Page => _activator.Page ?? throw new InvalidOperationException("The News page has not been rendered yet"); + + // Steps that render your page stop here while it still has an async void method: an exception that escapes one + // can end the whole app, and a step must never do that + public static bool HasAsyncVoidMethods(StepReport report) + { + var asyncVoidMethods = CodeInspector.GetAsyncVoidMethods(typeof(NewsPageBase)); + + if (asyncVoidMethods.Count is 0) + return false; + + report.Expect( + "NewsPageBase has no async void methods", + "none", + string.Join(", ", asyncVoidMethods), + false, + "This step renders your page, and it will not while an exception could escape an async void method and end the app. Step 1 explains why, so make it pass again first."); + + return true; + } + + public static int CountStoriesOnPage(string html) => html.Split("
count is 1 ? "1 story" : $"{count} stories"; + + // Renders the page for the first time. OnInitialized() and OnInitializedAsync() run up to their first incomplete await before this returns. + public async Task BeginRendering(CancellationToken token) + { + _rootComponent = await _renderer.Dispatcher.InvokeAsync(() => _renderer.BeginRenderingComponent()).WaitAsync(RenderTimeout, token).ConfigureAwait(false); + } + + // Waits for Blazor to finish initializing the page. False when it did not finish in time. + public async Task WaitForInitialization(CancellationToken token) + { + try + { + await InitializationTask.WaitAsync(RenderTimeout, token).ConfigureAwait(false); + return true; + } + catch (TimeoutException) + { + return false; + } + } + + // The HTML of the page as it was last rendered. Changing component state without a render does not change it. + public Task GetHtml(CancellationToken token) => Read(_ => RootComponent.ToHtmlString(), token); + + // Reads the page's state on Blazor's renderer, where the page reads and writes it + public Task Read(Func read, CancellationToken token) => _renderer.Dispatcher.InvokeAsync(() => read(Page)).WaitAsync(RenderTimeout, token); + + // Presses Refresh: the refresh starts on Blazor's renderer, and the returned Task completes when the refresh does + public Task PressRefresh() => _renderer.Dispatcher.InvokeAsync(Page.Refresh); + + // Closes the page, the way Blazor does when the user navigates away or closes the tab: the renderer disposes every component on it + public async Task ClosePage() + { + if (_isClosed) + return; + + _isClosed = true; + + try + { + await _renderer.DisposeAsync().AsTask().WaitAsync(RenderTimeout).ConfigureAwait(false); + } + catch (TimeoutException) + { + // Blazor's renderer is still blocked by your page. Abandon it rather than hang the step. + } + } + + // Nothing fails on the fake Hacker News unless a step asks it to, so any error during a refresh came from the page itself + public async Task ExpectNoErrors(StepReport report, CancellationToken token) + { + var errorMessage = await Read(static page => page.ErrorMessage, token).ConfigureAwait(false); + var errors = Logger.Errors; + + foreach (var error in errors) + { + report.Log($"Your page logged an error: \"{error.Message}\" ({error.Exception?.GetType().Name ?? "no exception"})"); + } + + var loggedExceptions = string.Join(", ", errors.Select(static error => error.Exception?.GetType().Name ?? "an error").Distinct()); + + var actual = (errors.Count, errorMessage) switch + { + (0, null) => "no errors", + (0, _) => "an error message on the page", + (_, null) => $"logged {loggedExceptions}", + _ => $"logged {loggedExceptions}, and an error message on the page", + }; + + report.Expect( + "A refresh against a healthy Hacker News logs no errors and shows no error message", + "no errors", + actual, + errors.Count is 0 && errorMessage is null, + errors.Any(static error => error.Exception is InvalidOperationException) + ? "Your page logged an InvalidOperationException. Blazor throws it when a component renders off its renderer, which is where the code after ConfigureAwait(false) runs. Make those state changes, and StateHasChanged(), through InvokeAsync(...)." + : "Nothing failed on the Hacker News side, so the error came from your page. The step's log lists every message your page logged, with its exception type. Compare it with the catch blocks in RefreshAsync(CancellationToken)."); + } + + public async ValueTask DisposeAsync() + { + await ClosePage().ConfigureAwait(false); + await _serviceProvider.DisposeAsync().ConfigureAwait(false); + } + + // Blazor asks this for every component it creates, which is how the session gets hold of the page it rendered + sealed class NewsPageActivator : IComponentActivator + { + public NewsPageProbe? Page { get; private set; } + + public IComponent CreateInstance(Type componentType) + { + if (Activator.CreateInstance(componentType) is not IComponent component) + throw new ArgumentException($"{componentType.Name} is not a component", nameof(componentType)); + + if (component is NewsPageProbe page) + Page = page; + + return component; + } + } +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/StepCheck.cs b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/StepCheck.cs new file mode 100644 index 0000000..df8a338 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/StepCheck.cs @@ -0,0 +1,3 @@ +namespace HackerNews; + +public sealed record StepCheck(string Description, string Expected, string Actual, bool Passed, string Hint); \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/StepLogEntry.cs b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/StepLogEntry.cs new file mode 100644 index 0000000..b1fdd01 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/StepLogEntry.cs @@ -0,0 +1,3 @@ +namespace HackerNews; + +public sealed record StepLogEntry(TimeSpan Elapsed, int ThreadId, bool IsThreadPoolThread, string Message); \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/StepReport.cs b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/StepReport.cs new file mode 100644 index 0000000..35e26da --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/StepReport.cs @@ -0,0 +1,82 @@ +using System.Diagnostics; + +namespace HackerNews; + +// Collects what a step logged and every expected result it checked. +// Steps write to it from thread pool threads while the workshop guide reads it, so every member takes the lock. +public sealed class StepReport +{ + readonly Lock _lock = new(); + readonly Stopwatch _stopwatch = Stopwatch.StartNew(); + readonly List _entries = []; + readonly List _checks = []; + + bool _isComplete; + + public IReadOnlyList Entries + { + get + { + lock (_lock) + { + return [.. _entries]; + } + } + } + + public IReadOnlyList Checks + { + get + { + lock (_lock) + { + return [.. _checks]; + } + } + } + + public void Log(string message) + { + var threadId = Environment.CurrentManagedThreadId; + var isThreadPoolThread = Thread.CurrentThread.IsThreadPoolThread; + + lock (_lock) + { + if (!_isComplete) + _entries.Add(new StepLogEntry(_stopwatch.Elapsed, threadId, isThreadPoolThread, message)); + } + } + + // The hint is shown under the expected result only when it does not match + public void Expect(string description, T expected, T actual, string hint) + { + Expect(description, Format(expected), Format(actual), EqualityComparer.Default.Equals(expected, actual), hint); + } + + public void Expect(string description, string expected, string actual, bool passed, string hint) + { + lock (_lock) + { + if (!_isComplete) + _checks.Add(new StepCheck(description, expected, actual, passed, hint)); + } + } + + // Called when the run ends, including a run that timed out or was stopped while its step is still blocked somewhere. + // Anything that abandoned step writes later is ignored, so the result in the workshop guide can no longer change. + public void Complete() + { + lock (_lock) + { + _isComplete = true; + _stopwatch.Stop(); + } + } + + static string Format(T value) => value switch + { + null => "null", + int or long => $"{value:N0}", + _ => value.ToString() ?? "null", + }; +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/StepState.cs b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/StepState.cs new file mode 100644 index 0000000..8c2557e --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/StepState.cs @@ -0,0 +1,9 @@ +namespace HackerNews; + +// ExceptionType is set for Crashed. Only the name is kept: exception messages and stack traces stay in the terminal. +// CompletedAt is when the run that produced this state ended, so the workshop guide can show that a click really ran again. +public sealed record StepState( + StepStatus Status, + StepReport? Report, + string? ExceptionType = null, + DateTimeOffset? CompletedAt = null); \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/StepStatus.cs b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/StepStatus.cs new file mode 100644 index 0000000..9a2cf59 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/StepStatus.cs @@ -0,0 +1,12 @@ +namespace HackerNews; + +public enum StepStatus +{ + NotRun, + Running, + Passed, + Failed, + TimedOut, + Crashed, + Stopped, +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/StepVerifier.cs b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/StepVerifier.cs new file mode 100644 index 0000000..b2c0053 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/Verification/StepVerifier.cs @@ -0,0 +1,232 @@ +namespace HackerNews; + +// Workshop plumbing: runs the steps against your News page and keeps their results. +// Every step renders its own copy of the page against an in-memory Hacker News, so a check never touches the Top stories page you have open, or the real API. +// Only one step runs at a time, so a step that times Blazor's renderer never competes with another run for thread pool threads. +public sealed class StepVerifier(ILogger logger, IHostApplicationLifetime applicationLifetime) : BackgroundService +{ + public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); + + readonly Lock _lock = new(); + readonly StepState[] _states = [.. Enumerable.Repeat(new StepState(StepStatus.NotRun, null), 5)]; + + // Both start at 1: the app checks every step on startup, and nothing else may run before that finishes + int _isBusy = 1; + int _isCheckingAllSteps = 1; + + ActiveStepRun? _activeRun; + TaskCompletionSource? _stopRequested; + + public IReadOnlyList Steps { get; } = + [ + new Step1LoadStoriesDuringInitialization(), + new Step2KeepTheRendererFree(), + new Step3StopWhenThePageGoesAway(), + new Step4StreamStories(), + new Step5SkipUnneededStateMachines(), + ]; + + public bool IsBusy => Volatile.Read(ref _isBusy) is 1; + + public bool IsCheckingAllSteps => Volatile.Read(ref _isCheckingAllSteps) is 1; + + public ActiveStepRun? ActiveRun + { + get + { + lock (_lock) + { + return _activeRun; + } + } + } + + public WorkshopStep? FindStep(int number) => Steps.FirstOrDefault(step => step.Number == number); + + public StepState GetState(int number) + { + lock (_lock) + { + return _states[number - 1]; + } + } + + public bool IsUnlocked(int number) => number is 1 || GetState(number - 1).Status is StepStatus.Passed; + + // Re-runs every step, the same way startup does + public async Task CheckAllSteps() + { + if (Interlocked.CompareExchange(ref _isBusy, 1, 0) is not 0) + return; + + Volatile.Write(ref _isCheckingAllSteps, 1); + + try + { + await CheckSteps(applicationLifetime.ApplicationStopping).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isCheckingAllSteps, 0); + Volatile.Write(ref _isBusy, 0); + } + } + + public async Task RunStep(WorkshopStep step) + { + if (!IsUnlocked(step.Number) || Interlocked.CompareExchange(ref _isBusy, 1, 0) is not 0) + return GetState(step.Number); + + try + { + return await Run(step, applicationLifetime.ApplicationStopping).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isBusy, 0); + } + } + + public void StopRun() + { + lock (_lock) + { + _stopRequested?.TrySetResult(); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await CheckSteps(stoppingToken).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isCheckingAllSteps, 0); + Volatile.Write(ref _isBusy, 0); + } + } + + async Task CheckSteps(CancellationToken token) + { + try + { + // Results from before the latest change to your code are not trusted + lock (_lock) + { + Array.Fill(_states, new StepState(StepStatus.NotRun, null)); + } + + logger.LogInformation("Checking your News page against every step. Open the app in your browser: the workshop guide beside the Top stories page shows what to fix next"); + + foreach (var step in Steps) + { + var state = await Run(step, token).ConfigureAwait(false); + + if (state.Status is not StepStatus.Passed) + { + logger.LogInformation("Stopped checking at Step {StepNumber}. Open it in the app to see what to change, with a hint for every result that did not match. Later steps unlock after it passes", step.Number); + return; + } + } + + logger.LogInformation("Every step passed. Pause here for the group review"); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The app is shutting down + } + catch (Exception e) + { + logger.LogError(e, "Checking the steps failed unexpectedly"); + } + } + + async Task Run(WorkshopStep step, CancellationToken token) + { + var report = new StepReport(); + var stopRequested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var previousState = GetState(step.Number); + + lock (_lock) + { + _activeRun = new ActiveStepRun(step, report); + _stopRequested = stopRequested; + _states[step.Number - 1] = new StepState(StepStatus.Running, report); + } + + // Cancelled when the run ends for any reason, so a step that is still working can stop + using var runCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + + var state = previousState; + + try + { + state = await Execute(step, report, stopRequested.Task, runCancellationTokenSource.Token, token).ConfigureAwait(false); + } + finally + { + await runCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + report.Complete(); + + lock (_lock) + { + _activeRun = null; + _stopRequested = null; + _states[step.Number - 1] = state with { CompletedAt = DateTimeOffset.Now }; + } + } + + return state; + } + + async Task Execute(WorkshopStep step, StepReport report, Task stopRequested, CancellationToken runToken, CancellationToken token) + { + try + { + // A dedicated thread runs the step until its first await. If a deadlock blocks it forever, + // a thread pool thread is not lost for the rest of the app's life. + var stepTask = Task.Factory.StartNew(() => step.Run(new StepContext(report, runToken)), token, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); + + var finished = await Task.WhenAny(stepTask, stopRequested).WaitAsync(Timeout, token).ConfigureAwait(false); + + if (finished == stopRequested) + { + logger.LogInformation("Step {StepNumber} was stopped", step.Number); + return new StepState(StepStatus.Stopped, report); + } + + await stepTask.ConfigureAwait(false); + + var checks = report.Checks; + var passedChecks = checks.Count(static check => check.Passed); + var status = checks.Count > 0 && passedChecks == checks.Count ? StepStatus.Passed : StepStatus.Failed; + + logger.LogInformation("Step {StepNumber} {Result} ({PassedChecks}/{TotalChecks} checks)", step.Number, status is StepStatus.Passed ? "passed" : "failed", passedChecks, checks.Count); + + // The hints belong in the terminal too, next to anything the app logged while the step ran + foreach (var check in checks.Where(static check => !check.Passed)) + { + logger.LogWarning("Step {StepNumber}: {Description}. Expected {Expected}, actual {Actual}. Hint: {Hint}", step.Number, check.Description, check.Expected, check.Actual, check.Hint); + } + + return new StepState(status, report); + } + catch (TimeoutException) + { + logger.LogWarning("Step {StepNumber} did not finish within {Timeout}. {TimeoutHint}", step.Number, Timeout, step.TimeoutHint); + return new StepState(StepStatus.TimedOut, report); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + return new StepState(StepStatus.NotRun, null); + } + catch (Exception e) + { + logger.LogError(e, "Step {StepNumber} threw an unexpected exception", step.Number); + return new StepState(StepStatus.Crashed, report, e.GetType().Name); + } + } +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/wwwroot/app.css b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/wwwroot/app.css index 7a1430d..4095b01 100644 --- a/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/wwwroot/app.css +++ b/2. Correcting Common Async Await Mistakes/1. Start/HackerNews/wwwroot/app.css @@ -1,19 +1,27 @@ +/* HackerNews: the front page, with the workshop guide docked beside it as the pull request's code review */ + :root { - --canvas: #eef3f1; - --panel: #ffffff; - --panel-strong: #112a2d; - --ink: #18211f; - --muted: #62706d; - --line: #d7e0dd; - --accent: #f46a1f; - --accent-dark: #b94713; - --teal: #0f766e; - --gold: #c59d35; - --shadow: 0 18px 55px rgb(28 45 42 / 14%); + --hn-orange: #ff6600; + --hn-beige: #f6f6ef; + --hn-ink: #000000; + --hn-muted: #828282; + --review-canvas: #f6f8fa; + --review-panel: #ffffff; + --review-line: #d0d7de; + --review-ink: #1f2328; + --review-muted: #59636e; + --review-blue: #0969da; + --review-purple: #8250df; + --good: #1a7f37; + --good-strong: #1f883d; + --good-dark: #1c8139; + --bad: #cf222e; + --warn: #9a6700; + --guide-width: 460px; color-scheme: light; - font-family: "Aptos", "Avenir Next", "Segoe UI", sans-serif; - background: var(--canvas); - color: var(--ink); + font-family: Verdana, Geneva, sans-serif; + background: #e9e9e2; + color: var(--hn-ink); } * { @@ -23,119 +31,116 @@ body { margin: 0; overflow-x: clip; - background: - linear-gradient(135deg, rgb(15 118 110 / 14%), transparent 32%), - radial-gradient(circle at top right, rgb(244 106 31 / 16%), transparent 30%), - var(--canvas); + background: #e9e9e2; } -.shell { - min-height: 100vh; +code { + font-family: ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace; + font-size: 0.88em; } -.news-page { - width: min(1120px, calc(100% - 32px)); - margin: 0 auto; - padding: 32px 0 56px; +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; } -.hero-panel { - display: grid; - grid-template-columns: 1fr auto; - gap: 28px; - align-items: end; - margin-bottom: 22px; - padding: 30px; - border: 1px solid rgb(36 35 31 / 10%); - border-radius: 8px; - background: linear-gradient(135deg, var(--panel-strong), #15463e 58%, #6f3b17); - box-shadow: var(--shadow); - color: #f8fffc; +@keyframes spin { + to { + transform: rotate(360deg); + } } -.hero-copy { - max-width: 680px; -} +/* The app and the guide share the screen */ -.eyebrow { - margin: 0 0 10px; - font-size: 0.78rem; - font-weight: 800; - letter-spacing: 0; - text-transform: uppercase; - color: #ffd29d; +.workspace { + display: flex; + min-height: 100vh; } -h1 { - margin: 0; - font-family: "Iowan Old Style", "Sitka Text", Georgia, serif; - font-size: 4.45rem; - font-weight: 700; - line-height: 0.95; +.app-pane { + flex: 1 1 auto; + min-width: 0; + padding: 10px 16px 40px; } -.hero-summary { - max-width: 560px; - margin: 16px 0 0; - font-size: 1rem; - line-height: 1.6; - color: #dcefeb; +/* The front page */ + +.hn { + width: min(980px, 100%); + margin: 0 auto; + background: var(--hn-beige); } -.hero-actions { - display: grid; - gap: 12px; - min-width: 220px; +.hn-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + padding: 4px 8px; + background: var(--hn-orange); + font-size: 0.82rem; } -.status-tile { +.hn-logo { display: grid; - gap: 2px; - padding: 16px; - border: 1px solid rgb(255 255 255 / 18%); - border-radius: 8px; - background: rgb(255 250 240 / 10%); - backdrop-filter: blur(10px); + place-items: center; + width: 20px; + height: 20px; + border: 1px solid #fff; + color: #fff; + font-weight: 700; } -.status-value { - font-family: "Iowan Old Style", "Sitka Text", Georgia, serif; - font-size: 2.35rem; +.hn-name { font-weight: 700; - line-height: 1; } -.status-label { - font-size: 0.78rem; - font-weight: 700; - color: #dcefeb; +.hn-links { + display: flex; + gap: 4px; +} + +.hn-link + .hn-link::before { + content: "| "; +} + +.hn-link.current { + color: #fff; +} + +.hn-count { + margin-left: auto; } .refresh-button { display: inline-flex; align-items: center; - justify-content: center; - gap: 10px; - min-height: 44px; - border: 0; - border-radius: 8px; - background: var(--accent); - color: #fff; - cursor: pointer; + gap: 6px; + padding: 2px 10px; + border: 1px solid rgb(0 0 0 / 35%); + border-radius: 2px; + background: var(--hn-beige); + color: var(--hn-ink); font: inherit; - font-weight: 800; - padding: 10px 16px; - box-shadow: 0 10px 24px rgb(0 0 0 / 20%); + cursor: pointer; +} + +.refresh-button:hover:not(:disabled) { + background: #fff; } .refresh-button:disabled { cursor: progress; - opacity: 0.7; + opacity: 0.75; } .refresh-icon { - width: 15px; - height: 15px; + width: 11px; + height: 11px; border: 2px solid currentcolor; border-left-color: transparent; border-radius: 50%; @@ -145,32 +150,26 @@ h1 { animation: spin 900ms linear infinite; } +.hn-body { + padding: 10px 8px 24px; +} + .story-list { display: grid; - gap: 10px; + gap: 6px; } .story-card { display: grid; - grid-template-columns: 42px 1fr; - gap: 16px; - align-items: start; - padding: 16px; - border: 1px solid var(--line); - border-radius: 8px; - background: rgb(255 255 255 / 88%); - box-shadow: 0 8px 24px rgb(28 45 42 / 8%); + grid-template-columns: 34px 1fr; + gap: 4px; + align-items: baseline; } .story-rank { - display: inline-grid; - place-items: center; - width: 42px; - height: 42px; - border-radius: 8px; - background: #e4efeb; - color: var(--accent-dark); - font-weight: 900; + color: var(--hn-muted); + font-size: 0.86rem; + text-align: right; } .story-content { @@ -178,38 +177,37 @@ h1 { } .story-title { - color: var(--ink); - font-size: 1.02rem; - font-weight: 800; + color: var(--hn-ink); + font-size: 0.9rem; line-height: 1.35; text-decoration: none; } +.story-title:visited { + color: var(--hn-muted); +} + .story-title:hover { - color: var(--accent-dark); text-decoration: underline; - text-decoration-thickness: 2px; - text-underline-offset: 3px; } .story-meta { - margin: 6px 0 0; - font-size: 0.84rem; - font-weight: 650; - color: var(--muted); + margin: 2px 0 0; + color: var(--hn-muted); + font-size: 0.7rem; } .error-panel { + margin: 0 0 12px; + padding: 8px 10px; border: 1px solid #cc4b24; - border-radius: 8px; background: #fff4ee; - margin-bottom: 18px; - padding: 14px 16px; color: #7f1d1d; + font-size: 0.82rem; } .error-panel p { - margin: 6px 0 0; + margin: 4px 0 0; line-height: 1.45; } @@ -219,21 +217,21 @@ h1 { .skeleton-line { display: block; - height: 14px; - border-radius: 999px; - background: linear-gradient(90deg, #dbe6e2, #f7fbf9, #dbe6e2); + height: 11px; + border-radius: 2px; + background: linear-gradient(90deg, #e2e2d8, #fbfbf6, #e2e2d8); background-size: 220% 100%; animation: shimmer 1.4s ease-in-out infinite; } .title-line { - width: min(620px, 80%); - height: 18px; + width: min(560px, 80%); + height: 13px; } .meta-line { - width: min(360px, 58%); - margin-top: 10px; + width: min(300px, 50%); + margin-top: 5px; } @keyframes shimmer { @@ -246,39 +244,663 @@ h1 { } } -@keyframes spin { +/* The workshop guide: a pull request review, docked beside the front page */ + +.guide { + position: sticky; + top: 0; + flex: 0 0 var(--guide-width); + height: 100vh; + overflow-y: auto; + border-left: 1px solid var(--review-line); + background: var(--review-canvas); + color: var(--review-ink); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif; + font-size: 0.9rem; +} + +.guide.collapsed { + flex-basis: 72px; + display: grid; + align-content: start; + justify-items: center; + gap: 12px; + padding: 12px 0; +} + +button.action { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 5px 16px; + border: 1px solid rgb(31 35 40 / 15%); + border-radius: 6px; + background: var(--good-strong); + color: #fff; + font: inherit; + font-weight: 600; + line-height: 20px; + cursor: pointer; +} + +button.action:hover:not(:disabled) { + background: var(--good-dark); +} + +button.action:disabled { + border-color: var(--review-line); + background: #94d3a2; + cursor: not-allowed; +} + +button.action.secondary { + background: var(--review-canvas); + color: var(--review-ink); +} + +button.action.secondary:hover:not(:disabled) { + background: #eef1f4; +} + +.spinner { + display: inline-block; + width: 12px; + height: 12px; + margin-right: 8px; + border: 2px solid rgb(255 255 255 / 40%); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.banner .spinner { + border-color: rgb(154 103 0 / 25%); + border-top-color: var(--warn); + vertical-align: -2px; +} + +.guide-header { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 16px 20px 12px; + border-bottom: 1px solid var(--review-line); + background: var(--review-panel); +} + +.guide-eyebrow { + margin: 0 0 4px; + color: var(--review-muted); + font-size: 0.75rem; + font-weight: 600; +} + +.guide-title { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + font-size: 1.35rem; + font-weight: 600; +} + +.guide-title::after { + content: "Open"; + padding: 2px 10px; + border-radius: 999px; + background: var(--good-strong); + color: #fff; + font-size: 0.75rem; + font-weight: 600; +} + +.guide-subtitle { + margin: 6px 0 0; + color: var(--review-muted); +} + +.guide-toggle { + flex: none; + align-self: start; + display: grid; + justify-items: center; + gap: 8px; + min-width: 32px; + padding: 4px 8px; + border: 1px solid var(--review-line); + border-radius: 6px; + background: var(--review-canvas); + color: var(--review-ink); + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.guide-toggle:hover { + background: #eef1f4; +} + +.guide-rail-label { + color: var(--review-muted); + font-size: 0.72rem; + font-weight: 600; + writing-mode: vertical-rl; +} + +.guide-rail { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.guide-rail-step { + display: grid; + justify-items: center; + gap: 2px; + width: 48px; + padding: 6px 0; + border: 1px solid var(--review-line); + border-radius: 6px; + background: var(--review-panel); + color: var(--review-ink); + font: inherit; + cursor: pointer; +} + +.guide-rail-number { + font-weight: 600; +} + +.guide-progress { + display: grid; + gap: 8px; + padding: 12px 20px; + border-bottom: 1px solid var(--review-line); + background: var(--review-panel); +} + +.progress-meter { + display: flex; + gap: 2px; + overflow: hidden; + border-radius: 6px; +} + +.progress-segment { + flex: 1; + height: 8px; + background: var(--review-line); +} + +.progress-segment.done { + background: var(--good-strong); +} + +.guide-progress-row { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.guide-progress-count { + margin: 0; + color: var(--review-muted); +} + +.guide-progress-count strong { + color: var(--review-ink); +} + +.guide-tabs { + display: grid; + margin: 16px 20px 0; + overflow: hidden; + border: 1px solid var(--review-line); + border-radius: 6px; + background: var(--review-panel); +} + +.guide-tab { + display: grid; + grid-template-columns: 24px 1fr auto; + gap: 10px; + align-items: center; + padding: 8px 12px; + border: 0; + border-top: 1px solid var(--review-line); + background: transparent; + color: var(--review-ink); + font: inherit; + text-align: left; + cursor: pointer; +} + +.guide-tab:first-child { + border-top: 0; +} + +.guide-tab:hover { + background: var(--review-canvas); +} + +.guide-tab.selected { + box-shadow: inset 3px 0 0 var(--hn-orange); + background: #fff8f2; + font-weight: 600; +} + +.guide-tab-number { + display: grid; + place-items: center; + width: 22px; + height: 22px; + border-radius: 50%; + background: #eef1f4; + color: var(--review-muted); + font-size: 0.78rem; + font-weight: 600; +} + +.guide-tab.selected .guide-tab-number { + background: var(--hn-orange); + color: #fff; +} + +.guide-body { + display: grid; + gap: 12px; + padding: 16px 20px 40px; +} + +.guide-section { + padding: 14px 16px; + border: 1px solid var(--review-line); + border-radius: 6px; + background: var(--review-panel); +} + +.guide-section h3, +.guide-section h4 { + margin: -14px -16px 12px; + padding: 8px 16px; + border-bottom: 1px solid var(--review-line); + border-radius: 6px 6px 0 0; + background: var(--review-canvas); + font-size: 0.88rem; + font-weight: 600; +} + +.guide-section p { + margin: 0 0 10px; + line-height: 1.5; +} + +.guide-section p:last-child { + margin-bottom: 0; +} + +.guide-note { + color: var(--review-muted); + font-size: 0.85rem; +} + +.step-header { + display: grid; + gap: 10px; +} + +.step-eyebrow { + margin: 0; + color: var(--review-muted); + font-size: 0.75rem; + font-weight: 600; +} + +.step-title { + margin: 2px 0 4px; + font-size: 1.25rem; + font-weight: 600; +} + +.step-members { + padding: 2px 6px; + border-radius: 6px; + background: #eef1f4; + color: var(--review-ink); + overflow-wrap: anywhere; +} + +.step-actions { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.step-buttons { + display: flex; + gap: 8px; +} + +.how-it-works, +.task-list { + margin: 0 0 12px; + padding-left: 20px; + line-height: 1.5; +} + +.how-it-works li + li, +.task-list li + li { + margin-top: 6px; +} + +.how-it-works { + margin-bottom: 0; +} + +.facts { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 12px; + margin: 0 0 12px; +} + +.facts dt { + color: var(--review-muted); + font-weight: 600; +} + +.facts dd { + margin: 0; + overflow-wrap: anywhere; +} + +.see-it { + padding: 8px 12px; + border-left: 3px solid var(--hn-orange); + border-radius: 6px; + background: #fff8f2; +} + +.ai-note { + padding: 8px 12px; + border-left: 3px solid var(--review-purple); + border-radius: 6px; + background: #fbefff; + color: #512a97; +} + +.clues { + padding: 8px 12px; + border: 1px solid var(--review-line); + border-radius: 6px; + background: var(--review-canvas); +} + +.clues summary, +.guide-log summary { + font-weight: 600; + cursor: pointer; +} + +.clues ul { + margin: 10px 0 0; + padding-left: 20px; + line-height: 1.5; +} + +.guide-log-count { + margin-left: 6px; + color: var(--review-muted); + font-weight: 400; +} + +/* Step results, shown the way a pull request shows its checks */ + +.badge { + display: inline-block; + padding: 1px 8px; + border: 1px solid var(--review-line); + border-radius: 999px; + background: var(--review-panel); + color: var(--review-muted); + font-size: 0.75rem; + font-weight: 600; + white-space: nowrap; +} + +.badge-compact { + padding: 0; + border: 0; + background: transparent; +} + +.badge-passed { + border-color: rgb(31 136 61 / 40%); + background: #dafbe1; + color: var(--good); +} + +.badge-failed { + border-color: rgb(207 34 46 / 40%); + background: #ffebe9; + color: var(--bad); +} + +.badge-running { + border-color: rgb(154 103 0 / 40%); + background: #fff8c5; + color: var(--warn); +} + +.badge-compact.badge-passed, +.badge-compact.badge-failed, +.badge-compact.badge-running { + background: transparent; +} + +.banner { + display: grid; + justify-items: start; + gap: 6px; + padding: 10px 14px; + border: 1px solid var(--review-line); + border-radius: 6px; + background: var(--review-panel); + line-height: 1.5; +} + +.banner-passed { + border-color: rgb(31 136 61 / 40%); + background: #dafbe1; + color: #116329; +} + +.banner-failed { + border-color: rgb(207 34 46 / 40%); + background: #ffebe9; + color: #a40e26; +} + +.banner-running { + border-color: rgb(154 103 0 / 40%); + background: #fff8c5; + color: #7d4e00; +} + +.banner-locked { + color: var(--review-muted); +} + +.banner-note { + font-size: 0.8rem; + opacity: 0.85; +} + +/* @key recreates the banner for every new result, so this animation plays again after each click */ +.banner-fresh { + animation: banner-in 0.35s ease-out; +} + +@keyframes banner-in { + from { + opacity: 0; + transform: translateY(-6px); + } + to { - transform: rotate(360deg); + opacity: 1; + transform: none; } } -@media (max-width: 720px) { - .news-page { - width: min(100% - 24px, 1120px); - padding-top: 18px; +.link-button { + padding: 0; + border: 0; + background: none; + color: var(--review-blue); + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.link-button:hover { + text-decoration: underline; +} + +.check-list { + display: grid; + margin: 0; + padding: 0; + overflow: hidden; + border: 1px solid var(--review-line); + border-radius: 6px; + list-style: none; +} + +.check { + display: grid; + grid-template-columns: 22px 1fr; + gap: 8px; + padding: 10px 12px; + border-top: 1px solid var(--review-line); +} + +.check:first-child { + border-top: 0; +} + +.check-body { + min-width: 0; +} + +.guide-section .check-description { + margin: 0 0 4px; + font-weight: 600; + line-height: 1.4; +} + +.check-values { + display: grid; + grid-template-columns: max-content 1fr; + gap: 2px 10px; + margin: 0; + color: var(--review-muted); + font-size: 0.82rem; +} + +.check-values dd { + margin: 0; + color: var(--review-ink); + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} + +/* A hint reads like a reviewer's comment on the line that failed */ +.guide-section .check-hint { + position: relative; + margin: 10px 0 0; + padding: 8px 12px; + border: 1px solid var(--review-line); + border-radius: 6px; + background: var(--review-canvas); + line-height: 1.5; +} + +.table-scroll { + margin-top: 10px; + overflow-x: auto; +} + +.step-log { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} + +.step-log th, +.step-log td { + padding: 6px 8px; + border-bottom: 1px solid var(--review-line); + text-align: left; + vertical-align: top; +} + +.step-log th { + color: var(--review-muted); +} + +.step-log td:nth-child(-n + 3) { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* Narrow screens stack the guide under the app */ + +@media (max-width: 1080px) { + :root { + --guide-width: 380px; } +} - .hero-panel { - grid-template-columns: 1fr; - padding: 22px; +@media (max-width: 860px) { + .workspace { + flex-direction: column; } - h1 { - font-size: 2.45rem; + .guide, + .guide.collapsed { + position: static; + flex-basis: auto; + height: auto; + border-top: 1px solid var(--review-line); + border-left: 0; } - .hero-actions { - min-width: 0; + .guide.collapsed { + grid-auto-flow: column; + justify-content: start; + padding: 12px 16px; } - .story-card { - grid-template-columns: 34px 1fr; - gap: 12px; - padding: 14px; + .guide.collapsed .guide-rail { + grid-auto-flow: column; } - .story-rank { - width: 34px; - height: 34px; + .guide-rail-label { + writing-mode: horizontal-tb; } -} \ No newline at end of file + + .app-pane { + padding: 0; + } + + .hn-count { + margin-left: 0; + } +} diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Components/Layout/MainLayout.razor b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Components/Layout/MainLayout.razor index e5f4720..f745fc5 100644 --- a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Components/Layout/MainLayout.razor +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Components/Layout/MainLayout.razor @@ -1,5 +1,9 @@ @inherits LayoutComponentBase -
- @Body -
\ No newline at end of file +
+
+ @Body +
+ + +
diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Components/Layout/StepBadge.razor b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Components/Layout/StepBadge.razor new file mode 100644 index 0000000..352439e --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Components/Layout/StepBadge.razor @@ -0,0 +1,10 @@ +@inherits StepBadgeBase + +@if (IsCompact) +{ + @Label +} +else +{ + @Icon @Label +} diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Components/Layout/StepBadge.razor.cs b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Components/Layout/StepBadge.razor.cs new file mode 100644 index 0000000..a329996 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Components/Layout/StepBadge.razor.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Components; + +namespace HackerNews.Components.Layout; + +public partial class StepBadgeBase : ComponentBase +{ + [Parameter, EditorRequired] + public StepStatus Status { get; set; } + + [Parameter] + public bool IsUnlocked { get; set; } = true; + + // Only the icon, for the collapsed guide + [Parameter] + public bool IsCompact { get; set; } + + public string Icon => Status is StepStatus.Running ? "⏳" : !IsUnlocked ? "🔒" : Status switch + { + StepStatus.Passed => "✅", + StepStatus.Failed or StepStatus.TimedOut or StepStatus.Crashed => "❌", + _ => "•", + }; + + public string Label => Status is StepStatus.Running ? "Running" : !IsUnlocked ? "Locked" : Status switch + { + StepStatus.Passed => "Passed", + StepStatus.Failed => "Failed", + StepStatus.TimedOut => "Timed out", + StepStatus.Crashed => "Crashed", + StepStatus.Stopped => "Stopped", + _ => "Not run", + }; + + public string CssClass => Status is StepStatus.Running ? "badge-running" : !IsUnlocked ? "badge-locked" : Status switch + { + StepStatus.Passed => "badge-passed", + StepStatus.Failed or StepStatus.TimedOut or StepStatus.Crashed => "badge-failed", + _ => "badge-idle", + }; +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Components/Layout/WorkshopGuide.razor b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Components/Layout/WorkshopGuide.razor new file mode 100644 index 0000000..ec82b3d --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Components/Layout/WorkshopGuide.razor @@ -0,0 +1,321 @@ +@inherits WorkshopGuideBase + + diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Components/Layout/WorkshopGuide.razor.cs b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Components/Layout/WorkshopGuide.razor.cs new file mode 100644 index 0000000..ffdbc44 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Components/Layout/WorkshopGuide.razor.cs @@ -0,0 +1,179 @@ +using Microsoft.AspNetCore.Components; + +namespace HackerNews.Components.Layout; + +// Workshop plumbing: the guide docked beside the app. It shows your challenge, every step's result, and the step you are working on. +public partial class WorkshopGuideBase : ComponentBase, IAsyncDisposable +{ + // Selecting this shows your challenge instead of a step + public const int ChallengeTab = 0; + + // A run that fails quickly can finish faster than anyone can see a spinner + static readonly TimeSpan _minimumActivityIndicatorTime = TimeSpan.FromMilliseconds(600); + + readonly CancellationTokenSource _disposeCancellationTokenSource = new(); + + Task _polling = Task.CompletedTask; + bool _isStartingCheck; + int? _startingRunStepNumber; + + [Inject] + public required StepVerifier Verifier { get; init; } + + [Inject] + public required ILogger Logger { get; init; } + + public int SelectedTab { get; private set; } = ChallengeTab; + + public bool IsCollapsed { get; private set; } + + public string? ErrorMessage { get; private set; } + + public int PassedStepCount => Verifier.Steps.Count(step => Verifier.GetState(step.Number).Status is StepStatus.Passed); + + public bool IsCheckingSteps => _isStartingCheck || Verifier.IsCheckingAllSteps; + + // The first step that does not pass yet, which is the one to work on next + public WorkshopStep? StoppedAtStep => Verifier.Steps.FirstOrDefault(step => Verifier.GetState(step.Number).Status is not StepStatus.Passed); + + // The last step the check reaches: the first one that did not pass, or the last step when they all passed + public WorkshopStep LastCheckedStep => StoppedAtStep ?? Verifier.Steps[^1]; + + public WorkshopStep? SelectedStep => Verifier.FindStep(SelectedTab); + + // The run lives in the verifier, not in this guide, so a step that is still running shows up again after a reload + public ActiveStepRun? SelectedRun => Verifier.ActiveRun is { } run && run.Step.Number == SelectedTab ? run : null; + + public bool IsSelectedStepRunning => _startingRunStepNumber == SelectedTab || SelectedRun is not null; + + public async ValueTask DisposeAsync() + { + // Only stops refreshing the guide. A step that is running keeps running. + await _disposeCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + await _polling.ConfigureAwait(false); + + _disposeCancellationTokenSource.Dispose(); + + GC.SuppressFinalize(this); + } + + protected override void OnInitialized() + { + _polling = PollVerifier(_disposeCancellationTokenSource.Token); + } + + protected void SelectTab(int tab) + { + SelectedTab = tab; + IsCollapsed = false; + ErrorMessage = null; + } + + protected void ToggleCollapsed() => IsCollapsed = !IsCollapsed; + + protected async Task CheckAllStepsAsync() + { + var token = _disposeCancellationTokenSource.Token; + string? errorMessage = null; + + ErrorMessage = null; + _isStartingCheck = true; + + // Show the activity indicator before the check starts + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + try + { + // Keep the indicator on screen long enough to notice, even when Step 1 fails quickly + await Task.WhenAll(Verifier.CheckAllSteps(), Task.Delay(_minimumActivityIndicatorTime, token)).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The guide was closed while the steps were being checked + return; + } + catch (Exception e) + { + Logger.LogError(e, "Checking every step failed"); + + errorMessage = "Checking the steps failed. The terminal running the app has the details."; + } + + // The continuation is off Blazor's renderer, so every component state change goes back through it + await InvokeAsync(() => + { + ErrorMessage = errorMessage; + _isStartingCheck = false; + + StateHasChanged(); + }).ConfigureAwait(false); + } + + protected async Task RunSelectedStepAsync() + { + if (SelectedStep is not { } step) + return; + + var token = _disposeCancellationTokenSource.Token; + string? errorMessage = null; + + ErrorMessage = null; + _startingRunStepNumber = step.Number; + + // Show the activity indicator before the run starts + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + try + { + // Keep the indicator on screen long enough to notice, even when the step fails quickly + await Task.WhenAll(Verifier.RunStep(step), Task.Delay(_minimumActivityIndicatorTime, token)).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The guide was closed while the step was running + return; + } + catch (Exception e) + { + Logger.LogError(e, "Running Step {StepNumber} failed", step.Number); + + errorMessage = "The step could not be run. The terminal running the app has the details."; + } + + // The continuation is off Blazor's renderer, so every component state change goes back through it + await InvokeAsync(() => + { + ErrorMessage = errorMessage; + _startingRunStepNumber = null; + + StateHasChanged(); + }).ConfigureAwait(false); + } + + protected void StopRun() => Verifier.StopRun(); + + // Re-renders while the verifier is busy, plus once more when it finishes, so the live log and results appear without a refresh + async Task PollVerifier(CancellationToken token) + { + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100)); + var wasBusy = true; + + try + { + while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false)) + { + var isBusy = Verifier.IsBusy; + + if (isBusy || wasBusy) + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + wasBusy = isBusy; + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // Expected when the guide is disposed + } + } +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Components/Pages/News.razor b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Components/Pages/News.razor index 01d0c79..cd410ce 100644 --- a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Components/Pages/News.razor +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Components/Pages/News.razor @@ -3,73 +3,75 @@ Top Stories -
-
-
-

Hacker News

-

Top stories

-

Fresh links from the front page, ranked by score and ready to scan.

-
+
+
+ + Hacker News + -
-
- @TopStoryCollection.Count - of @StoriesConstants.NumberOfStories stories loaded -
+ @TopStoryCollection.Count of @StoriesConstants.NumberOfStories stories loaded - -
+
- @if (RefreshErrorMessage is not null) - { - - } +
+

Top stories

+ + @if (RefreshErrorMessage is not null) + { + + } - @if (TopStoryCollection.Count is 0 && IsListRefreshing) - { -
- @for (var index = 0; index < 8; index++) - { -
- @(index + 1) -
- - + @if (TopStoryCollection.Count is 0 && IsListRefreshing) + { +
+ @for (var index = 0; index < 8; index++) + { +
+ @(index + 1). +
+ + +
-
- } -
- } - else - { -
- @for (var index = 0; index < TopStoryCollection.Count; index++) - { - var story = TopStoryCollection[index]; + } +
+ } + else + { +
+ @for (var index = 0; index < TopStoryCollection.Count; index++) + { + var story = TopStoryCollection[index]; -
- @(index + 1) -
- @if (string.IsNullOrWhiteSpace(story.Url)) - { - @story.Title - } - else - { - @story.Title - } +
+ @(index + 1). +
+ @if (string.IsNullOrWhiteSpace(story.Url)) + { + @story.Title + } + else + { + @story.Title + } - -
-
- } -
- } -
\ No newline at end of file + + +
+ } + + } + + diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Program.cs b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Program.cs index c07eaab..1f935c6 100644 --- a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Program.cs +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Program.cs @@ -20,6 +20,11 @@ public static void Main(string[] args) .ConfigureHttpClient(static client => client.BaseAddress = new Uri("https://hacker-news.firebaseio.com/v0")) .AddStandardResilienceHandler(static options => options.Retry = new WebHttpRetryStrategyOptions()); + // Workshop plumbing: one StepVerifier holds every step's result. It is also a hosted service, + // so it checks your News page against every step each time the app starts. + builder.Services.AddSingleton(); + builder.Services.AddHostedService(static serviceProvider => serviceProvider.GetRequiredService()); + var app = builder.Build(); if (!app.Environment.IsDevelopment()) diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Steps/Step1LoadStoriesDuringInitialization.cs b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Steps/Step1LoadStoriesDuringInitialization.cs new file mode 100644 index 0000000..c9dae82 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Steps/Step1LoadStoriesDuringInitialization.cs @@ -0,0 +1,106 @@ +using HackerNews.Components.Pages; + +namespace HackerNews; + +public sealed class Step1LoadStoriesDuringInitialization : WorkshopStep +{ + const string _asyncVoidHint = "async void silences the compiler warning without fixing anything. Nobody can await an async void method, and an exception that escapes one cannot be caught by its caller: " + + "on Blazor's renderer it tears down the user's connection, and anywhere else it ends the whole app. Blazor's lifecycle methods can return a Task, so use one that does."; + + const string _notInitializedHint = "Blazor only waits for work it can see through the Task a lifecycle method returns. OnInitialized() starts the refresh and returns straight away, so Blazor finished initializing the page before a single story arrived. " + + "Make the refresh part of the page's initialization, so the Task Blazor gets back completes only after the stories load."; + + // Fast enough that loading the page takes about as long as the refresh's 2 second minimum + static readonly TimeSpan _responseTime = TimeSpan.FromMilliseconds(10); + + public override int Number => 1; + + public override string Scenario => "Load stories as part of initialization"; + + public override string Title => "OnInitialized()"; + + public override string Story => "Blazor waits for a page to finish initializing before it treats that page's first render as complete. Prerendering sends that render to the browser, and HtmlRenderer, which renders components into emails, PDFs and caches, returns it. " + + "The Top stories page starts its refresh in OnInitialized() and returns straight away, so as far as Blazor knows, the page finished initializing with an empty story list. " + + "Nothing awaits the refresh either, so nothing can tell when it ends, or whether it failed."; + + public override string SeeItInTheApp => "Build the app and read the CS4014 warning on OnInitialized(): the compiler has already spotted this one. The Top stories page itself looks fine, because this app turns prerendering off, so the browser simply shows the loading skeleton until the stories arrive. " + + "This step renders the page the way prerendering does, waiting for initialization to finish, and today that render is the loading skeleton."; + + public override string FileToChange => "Components/Pages/News.razor.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Do not leave an unobserved task in the page's initialization. The Task Blazor gets back from initialization should complete only after the stories load.", + "Do not reach for async void to silence the warning. Use it only when a framework API truly requires it, and Blazor's lifecycle methods do not.", + "Use safe fire-and-forget, such as SafeFireAndForget() from AsyncAwaitBestPractices, only when the caller truly cannot return a Task. A Blazor lifecycle method can.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "A task is started from a lifecycle method without being awaited. That is what CS4014 is pointing at.", + "Blazor only knows about work it can see through the Task a lifecycle method returns.", + "Most of Blazor's synchronous lifecycle methods have an async sibling that returns a Task, and Blazor awaits it.", + ]; + + public override string TimeoutHint => "Your News page never finished initializing. Check that nothing in OnInitialized() or OnInitializedAsync() blocks on a task with Wait(), Result or GetAwaiter().GetResult(). The refresh needs Blazor's renderer to finish, and a blocking wait on the renderer holds it forever."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + // Part 1: read the compiled page for async void methods. The page is only rendered once there are none, because an exception escaping one can end the app. + var asyncVoidMethods = CodeInspector.GetAsyncVoidMethods(typeof(NewsPageBase)); + report.Log(asyncVoidMethods.Count is 0 ? "NewsPageBase has no async void methods" : $"NewsPageBase has async void methods: {string.Join(", ", asyncVoidMethods)}"); + + report.Expect("NewsPageBase has no async void methods", "none", asyncVoidMethods.Count is 0 ? "none" : string.Join(", ", asyncVoidMethods), asyncVoidMethods.Count is 0, _asyncVoidHint); + + if (asyncVoidMethods.Count is not 0) + { + report.Expect($"When Blazor finishes initializing the page, all {StoriesConstants.NumberOfStories} stories are loaded", $"{StoriesConstants.NumberOfStories} stories", "Skipped: the page has async void methods", false, _asyncVoidHint); + return; + } + + // Part 2: render the page against an in-memory Hacker News and wait for Blazor to finish initializing it, the way prerendering does + await using var session = new NewsPageSession(new FakeHackerNewsAPI(_responseTime)); + + report.Log($"Rendering the News page. Every Hacker News call takes {_responseTime.TotalMilliseconds:0} ms"); + await session.BeginRendering(token).ConfigureAwait(false); + + report.Log("Waiting for Blazor to finish initializing the page"); + var isInitialized = await session.WaitForInitialization(token).ConfigureAwait(false); + + if (!isInitialized) + { + report.Log($"Initialization did not finish within {NewsPageSession.RenderTimeout.TotalSeconds:0} seconds"); + report.Expect($"When Blazor finishes initializing the page, all {StoriesConstants.NumberOfStories} stories are loaded", $"{StoriesConstants.NumberOfStories} stories", $"initialization did not finish within {NewsPageSession.RenderTimeout.TotalSeconds:0} seconds", false, TimeoutHint); + return; + } + + // Read everything at the instant initialization finished, before any fire-and-forget work can catch up + var storiesLoaded = await session.Read(static page => page.Stories.Count, token).ConfigureAwait(false); + var html = await session.GetHtml(token).ConfigureAwait(false); + var storiesOnPage = NewsPageSession.CountStoriesOnPage(html); + var storiesReturned = session.HackerNewsApi.StoriesReturned; + var refreshFailed = session.Logger.Errors.Count > 0 || await session.Read(static page => page.ErrorMessage is not null, token).ConfigureAwait(false); + var storiesHint = refreshFailed ? "The refresh failed while the page was initializing, so the stories never finished loading. The last expected result says what your page logged." : _notInitializedHint; + + report.Log($"Initialization finished. Hacker News had returned {storiesReturned} stories, the page had loaded {storiesLoaded}, and its render shows {storiesOnPage}"); + + report.Expect( + $"When Blazor finishes initializing the page, all {StoriesConstants.NumberOfStories} stories are loaded", + NewsPageSession.DescribeStories(StoriesConstants.NumberOfStories), + NewsPageSession.DescribeStories(storiesLoaded), + storiesLoaded is StoriesConstants.NumberOfStories, + storiesHint); + + report.Expect( + "The page Blazor renders at that moment, which prerendering would send, lists every story", + $"{NewsPageSession.DescribeStories(StoriesConstants.NumberOfStories)} on the page", + $"{NewsPageSession.DescribeStories(storiesOnPage)} on the page", + storiesOnPage is StoriesConstants.NumberOfStories, + refreshFailed ? storiesHint : "Prerendering and HtmlRenderer send the page as it is rendered once initialization finishes. Until the refresh is part of initialization, that render is the loading skeleton."); + + await session.ExpectNoErrors(report, token).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Steps/Step2KeepTheRendererFree.cs b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Steps/Step2KeepTheRendererFree.cs new file mode 100644 index 0000000..44aa54c --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Steps/Step2KeepTheRendererFree.cs @@ -0,0 +1,153 @@ +using System.Diagnostics; +using HackerNews.Components.Pages; + +namespace HackerNews; + +public sealed class Step2KeepTheRendererFree : WorkshopStep +{ + const string _blockingHint = "A blocking wait holds whichever thread runs it until the task finishes. Inside an async method, await the task instead, and keep the 2 second minimum: " + + "moving the wait off Blazor's renderer with ConfigureAwait(false), or swapping Wait() for Result or GetAwaiter().GetResult(), still blocks a thread pool thread for every refresh."; + + // A render of 50 stories takes a few milliseconds. A blocking wait on the 2 second minimum holds the renderer for well over a second. + static readonly TimeSpan _maximumRendererDelay = TimeSpan.FromMilliseconds(500); + + static readonly TimeSpan _heartbeatInterval = TimeSpan.FromMilliseconds(25); + + static readonly TimeSpan _minimumRefreshTime = TimeSpan.FromSeconds(1.9); + + // Every call answers almost at once, so nearly all of a refresh is spent on its 2 second minimum + static readonly TimeSpan _responseTime = TimeSpan.FromMilliseconds(1); + + public override int Number => 2; + + public override string Scenario => "Keep Blazor's renderer free"; + + public override string Title => "RefreshAsync(CancellationToken)"; + + public override string Story => "Every browser tab connected to a Blazor Server app has its own renderer, and your component code runs on it one piece of work at a time, much like a UI thread. " + + "RefreshAsync() ends by calling Wait() on its 2 second minimum refresh time. When that line runs on the renderer, the tab's clicks, renders and UI updates queue up behind it, and a thread pool thread sits blocked doing nothing. " + + "One user pressing Refresh never notices. Two hundred users pressing it at once starve the thread pool for everyone on the server."; + + public override string SeeItInTheApp => "On the Top stories page, press Refresh. The spinner keeps turning, because the browser animates it, so the page looks busy rather than stuck. " + + "For most of those 2 seconds, though, the server cannot run anything else for your tab. This step posts a tiny piece of work to the renderer every 25 milliseconds during a refresh, and times how long each one waits."; + + public override string FileToChange => "Components/Pages/News.razor.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Replace the blocking wait on the minimum refresh time with await. Keep the 2 second minimum: the refresh indicator should still stop only after it ends.", + "Do not trade Wait() for Result or GetAwaiter().GetResult(). They block exactly the same way.", + "Use ConfigureAwait(false) only where the code after the await does not need Blazor's context.", + "After ConfigureAwait(false), make every change to the page's state, and every StateHasChanged(), through InvokeAsync(...).", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "A blocking wait is used inside an async method.", + "A continuation is allowed to capture context even when it does not need to. Ask which thread runs the finally block after the await above it.", + "ConfigureAwait(false) moves the Wait() off the renderer, but it still blocks a thread pool thread. That is why this step reads your code as well as timing the renderer.", + "UI state changes must be marshaled through Blazor's renderer when continuations run away from the captured context. Blazor throws InvalidOperationException when StateHasChanged() runs anywhere else.", + ]; + + public override string TimeoutHint => "A refresh never finished. Check for a blocking wait that runs on Blazor's renderer: the code after each await needs the renderer to finish, and a wait that holds it can hold it forever."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + // Part 1: read the compiled page for blocking waits. A Wait() that runs off the renderer does not delay it, so timing alone would miss it. + var blockingCalls = FindBlockingCalls(); + report.Log(blockingCalls.Count is 0 ? "NewsPageBase never blocks on a task" : $"NewsPageBase blocks on a task with {string.Join(", ", blockingCalls)}"); + + report.Expect("NewsPageBase never calls Wait(), Result or GetAwaiter().GetResult() on a task", "none", blockingCalls.Count is 0 ? "none" : string.Join(", ", blockingCalls), blockingCalls.Count is 0, _blockingHint); + + if (NewsPageSession.HasAsyncVoidMethods(report)) + return; + + // Part 2: load the page, then press Refresh and time how long the renderer takes to pick up a tiny piece of work, every 25 ms, until the refresh ends + await using var session = new NewsPageSession(new FakeHackerNewsAPI(_responseTime)); + + report.Log("Rendering the News page and waiting for it to finish initializing"); + await session.BeginRendering(token).ConfigureAwait(false); + + if (!await session.WaitForInitialization(token).ConfigureAwait(false)) + { + report.Expect("The page finishes initializing", "within 10 seconds", "did not finish", false, "Step 1 checks this. Make it pass again first."); + return; + } + + report.Log("Pressing Refresh"); + + var stopwatch = Stopwatch.StartNew(); + var refreshTask = session.PressRefresh(); + var longestDelay = TimeSpan.Zero; + var heartbeats = 0; + + while (!refreshTask.IsCompleted && stopwatch.Elapsed < NewsPageSession.RenderTimeout) + { + var postedAt = stopwatch.Elapsed; + var ranAt = await session.Dispatcher.InvokeAsync(() => stopwatch.Elapsed).WaitAsync(NewsPageSession.RenderTimeout, token).ConfigureAwait(false); + var delay = ranAt - postedAt; + + if (delay > longestDelay) + { + longestDelay = delay; + report.Log($"The renderer took {delay.TotalMilliseconds:0} ms to run a piece of work posted {postedAt.TotalMilliseconds:0} ms into the refresh"); + } + + heartbeats++; + await Task.Delay(_heartbeatInterval, token).ConfigureAwait(false); + } + + await refreshTask.WaitAsync(NewsPageSession.RenderTimeout, token).ConfigureAwait(false); + var refreshTime = stopwatch.Elapsed; + + var isRefreshing = await session.Read(static page => page.IsRefreshing, token).ConfigureAwait(false); + + report.Log($"The refresh finished after {refreshTime.TotalSeconds:F2}s. The renderer ran {heartbeats} pieces of work, and the longest wait was {longestDelay.TotalMilliseconds:0} ms"); + + report.Expect( + "While a refresh runs, the renderer picks up other work quickly", + $"within {_maximumRendererDelay.TotalMilliseconds:0} ms", + $"{longestDelay.TotalMilliseconds:0} ms", + longestDelay <= _maximumRendererDelay, + "Blazor's renderer was blocked, so everything else for this browser tab had to wait. Something in the refresh holds the renderer's thread while it waits for a task, and the code after the awaits in RefreshAsync(CancellationToken) runs on the renderer. Await the task instead."); + + report.Expect( + "Refresh keeps its indicator up for the 2 second minimum, then turns it off", + "at least 2 seconds, then off", + $"{refreshTime.TotalSeconds:F1} seconds, then {(isRefreshing ? "still on" : "off")}", + refreshTime >= _minimumRefreshTime && !isRefreshing, + refreshTime < _minimumRefreshTime + ? "The refresh ended before its 2 second minimum. Await the minimum refresh time rather than removing the wait." + : "IsListRefreshing is still true after the refresh finished. Turn it off, through InvokeAsync(...), once the minimum refresh time has passed."); + + await session.ExpectNoErrors(report, token).ConfigureAwait(false); + } + + // Blocking waits the compiler never generates. Every await compiles into one IsCompleted check and one GetResult() call on the same awaiter, + // so an awaiter's GetResult() call without its own IsCompleted check was written by hand. + static IReadOnlyList FindBlockingCalls() + { + var calledMethods = CodeInspector.GetCalledMethods(typeof(NewsPageBase)); + List blockingCalls = []; + + if (calledMethods.Any(static method => method is "Task.Wait" or "Task.WaitAll" or "Task.WaitAny")) + blockingCalls.Add("Wait()"); + + if (calledMethods.Any(static method => method is "Task`1.get_Result" or "ValueTask`1.get_Result")) + blockingCalls.Add("Result"); + + var awaiterTypes = calledMethods + .Where(static method => method.EndsWith(".GetResult", StringComparison.Ordinal)) + .Select(static method => method[..^".GetResult".Length]) + .Where(static type => type.Contains("Awaiter", StringComparison.Ordinal)) + .Distinct(); + + if (awaiterTypes.Any(awaiterType => calledMethods.Count(method => method == $"{awaiterType}.GetResult") > calledMethods.Count(method => method == $"{awaiterType}.get_IsCompleted"))) + blockingCalls.Add("GetAwaiter().GetResult()"); + + return blockingCalls; + } +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Steps/Step3StopWhenThePageGoesAway.cs b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Steps/Step3StopWhenThePageGoesAway.cs new file mode 100644 index 0000000..f3c393d --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Steps/Step3StopWhenThePageGoesAway.cs @@ -0,0 +1,202 @@ +using System.Diagnostics; + +namespace HackerNews; + +public sealed class Step3StopWhenThePageGoesAway : WorkshopStep +{ + const int _storiesBeforeClosing = 10; + + // Stopping a refresh takes a few milliseconds. A refresh that ignores its token runs out the rest of its 2 second minimum. + static readonly TimeSpan _maximumStopTime = TimeSpan.FromMilliseconds(500); + + static readonly TimeSpan _responseTime = TimeSpan.FromMilliseconds(10); + + public override int Number => 3; + + public override string Scenario => "Stop refresh work when the page goes away"; + + public override string Title => "RefreshAsync(CancellationToken) and Dispose()"; + + public override string Story => "People open the Top stories page, glance at it, and leave. When they do, Blazor disposes the page, and Dispose() cancels the page's CancellationTokenSource so that its refresh can stop. " + + "RefreshAsync(CancellationToken) receives that token and passes it to Hacker News, but not to its 2 second minimum refresh time, so every abandoned page keeps its refresh alive on the server until the delay runs out. " + + "The other half of ending well is failing well: when Hacker News is down, the page has to say so in words a user can act on, while the details go to the server log."; + + public override string SeeItInTheApp => "On the Top stories page, press Refresh, and reload the browser tab straight away. The page is gone from your browser, but its refresh keeps running on the server until the 2 second minimum ends. " + + "If you are offline, or your network blocks Hacker News, the page shows its refresh error instead of stories: a short message you can act on, with the details in the terminal."; + + public override string FileToChange => "Components/Pages/News.razor.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Forward the CancellationToken that RefreshAsync(CancellationToken) receives to every cancellable async API it calls, including the minimum refresh time.", + "When the page goes away mid-refresh, let the refresh end quietly: no exception may escape it. Catch OperationCanceledException only when your own token was canceled.", + "Keep logging the full exception on the server, and keep showing users a generic, actionable refresh error. Never put exception text on the page.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "A cancellation token is accepted but not always forwarded.", + "Dispose() cancels _disposeCancellationTokenSource. Follow its token through RefreshAsync(CancellationToken), and look for an async call that never sees it.", + "Once the minimum refresh time listens to the token, awaiting it after the page goes away throws TaskCanceledException, and that await is in a finally block.", + "ConfigureAwaitOptions.SuppressThrowing awaits a Task without throwing when it is canceled or faulted. It works on Task, not on Task.", + ]; + + public override string TimeoutHint => "A refresh never finished after the page closed, or after Hacker News failed. Check that every await in RefreshAsync(CancellationToken) can end once its token is canceled."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + if (NewsPageSession.HasAsyncVoidMethods(report)) + return; + + await ClosePageMidRefresh(report, token).ConfigureAwait(false); + + await FailRefresh(report, token).ConfigureAwait(false); + } + + // Part 1: press Refresh, then close the page while the refresh is still loading stories, the way a user navigates away + static async Task ClosePageMidRefresh(StepReport report, CancellationToken token) + { + var hackerNewsApi = new FakeHackerNewsAPI(_responseTime); + await using var session = new NewsPageSession(hackerNewsApi); + + report.Log("Rendering the News page and waiting for it to finish initializing"); + await session.BeginRendering(token).ConfigureAwait(false); + + if (!await session.WaitForInitialization(token).ConfigureAwait(false)) + { + report.Expect("The page finishes initializing", "within 10 seconds", "did not finish", false, "Step 1 checks this. Make it pass again first."); + return; + } + + var requestsBeforeRefresh = hackerNewsApi.StoryRequests; + + report.Log("Pressing Refresh"); + var refreshTask = session.PressRefresh(); + + var waitForStories = Stopwatch.StartNew(); + + while (hackerNewsApi.StoryRequests - requestsBeforeRefresh < _storiesBeforeClosing && !refreshTask.IsCompleted && waitForStories.Elapsed < NewsPageSession.RenderTimeout) + { + await Task.Delay(TimeSpan.FromMilliseconds(5), token).ConfigureAwait(false); + } + + var requestsWhenClosed = hackerNewsApi.StoryRequests; + report.Log($"Closing the page after the refresh asked Hacker News for {requestsWhenClosed - requestsBeforeRefresh} stories"); + + var stopwatch = Stopwatch.StartNew(); + await session.ClosePage().ConfigureAwait(false); + + Exception? escapedException = null; + + try + { + await refreshTask.WaitAsync(NewsPageSession.RenderTimeout, token).ConfigureAwait(false); + } + catch (Exception e) when (e is not TimeoutException && !token.IsCancellationRequested) + { + escapedException = e; + } + + var stopTime = stopwatch.Elapsed; + var requestsAfterClosing = hackerNewsApi.StoryRequests - requestsWhenClosed; + + report.Log($"The refresh ended {stopTime.TotalMilliseconds:0} ms after the page closed, and asked Hacker News for {requestsAfterClosing} more stories after that"); + + if (escapedException is not null) + report.Log($"The refresh ended with {escapedException.GetType().Name}"); + + report.Expect( + "After the page closes mid-refresh, the refresh ends quickly", + $"within {_maximumStopTime.TotalMilliseconds:0} ms", + $"{stopTime.TotalMilliseconds:0} ms", + stopTime <= _maximumStopTime, + "The page was gone, but its refresh kept running until the minimum refresh time ran out. Dispose() cancels the token RefreshAsync(CancellationToken) receives, and a cancellable async call can only stop early if it is given that token."); + + // One request can start at the very moment the page closes + report.Expect( + "The refresh asks Hacker News for no more stories after the page closes", + "at most 1", + $"{requestsAfterClosing}", + requestsAfterClosing <= 1, + "The refresh kept fetching stories for a page nobody can see. Forward the CancellationToken to every call that accepts one, all the way down to HackerNewsApiService."); + + report.Expect( + "No exception escapes the refresh when the page closes", + "none", + escapedException?.GetType().Name ?? "none", + escapedException is null, + "In the app, an exception that escapes a Refresh click shows Blazor's unhandled error bar and ends the user's connection. Awaiting a canceled task throws, even in a finally block. " + + "Await the minimum refresh time in a way that does not throw when it is canceled, and catch OperationCanceledException only when your own token was canceled."); + } + + // Part 2: load the page while Hacker News is down + static async Task FailRefresh(StepReport report, CancellationToken token) + { + await using var session = new NewsPageSession(new FakeHackerNewsAPI(_responseTime) { IsFailing = true }); + + report.Log("Rendering the News page while every Hacker News call throws HttpRequestException"); + await session.BeginRendering(token).ConfigureAwait(false); + + if (!await session.WaitForInitialization(token).ConfigureAwait(false)) + { + report.Expect("The page finishes initializing when Hacker News is down", "within 10 seconds", "did not finish", false, "Something in the refresh waits forever once a call to Hacker News fails. Check the catch and finally blocks in RefreshAsync(CancellationToken)."); + return; + } + + var errorMessage = await session.Read(static page => page.ErrorMessage, token).ConfigureAwait(false); + var isRefreshing = await session.Read(static page => page.IsRefreshing, token).ConfigureAwait(false); + var html = await session.GetHtml(token).ConfigureAwait(false); + var errors = session.Logger.Errors; + + report.Log(errorMessage is null ? "The page shows no error message" : $"The page shows the error message \"{errorMessage}\""); + report.Log(errors.Count is 0 ? "Your page logged no errors" : $"Your page logged: {string.Join(", ", errors.Select(static error => $"\"{error.Message}\" ({error.Exception?.GetType().Name ?? "no exception"})"))}"); + + report.Expect( + "When Hacker News is down, the page shows an error message", + "an error message", + errorMessage is null ? "none" : "an error message", + errorMessage is not null, + "The refresh failed and the page said nothing. Catch the failure in RefreshAsync(CancellationToken) and set RefreshErrorMessage to a message the user can act on."); + + var showsDetails = ShowsExceptionDetails(errorMessage) || ShowsExceptionDetails(html); + + report.Expect( + "The page shows no exception details", + "no exception details", + showsDetails ? "the exception's message or type" : "no exception details", + !showsDetails, + "Exception text can reveal servers, URLs and internals, and it means nothing to the person reading it. Show a generic message that says what to try next, and keep the details in the server log."); + + var loggedFailure = errors.Any(static error => IsFailure(error.Exception)); + + report.Expect( + "The full exception is logged on the server", + "HttpRequestException logged", + loggedFailure ? "HttpRequestException logged" : errors.Count is 0 ? "no errors logged" : "logged without the exception", + loggedFailure, + "Pass the exception itself to Logger.LogError(...), not just its message, so the server log keeps its type and stack trace."); + + report.Expect( + "The refresh indicator turns off after a failed refresh", + "off", + isRefreshing ? "still on" : "off", + !isRefreshing, + "A failed refresh has to stop the indicator too. Turn IsListRefreshing off where it runs whether or not the refresh succeeded."); + } + + static bool ShowsExceptionDetails(string? text) => text is not null + && (text.Contains(FakeHackerNewsAPI.FailureDetail, StringComparison.Ordinal) + || text.Contains(nameof(HttpRequestException), StringComparison.Ordinal) + || text.Contains("Service Unavailable", StringComparison.Ordinal)); + + static bool IsFailure(Exception? exception) => exception switch + { + HttpRequestException { Message: FakeHackerNewsAPI.FailureDetail } => true, + AggregateException aggregateException => aggregateException.InnerExceptions.Any(IsFailure), + null => false, + _ => IsFailure(exception.InnerException), + }; +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Steps/Step4StreamStories.cs b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Steps/Step4StreamStories.cs new file mode 100644 index 0000000..6c2bdf3 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Steps/Step4StreamStories.cs @@ -0,0 +1,132 @@ +using System.Diagnostics; +using HackerNews.Components.Pages; + +namespace HackerNews; + +public sealed class Step4StreamStories : WorkshopStep +{ + const string _notStreamingHint = "The page showed nothing until the very last story arrived, even though Hacker News had already answered for every other story. " + + "Hand each story to the page as soon as it arrives, add it to the list in its sorted place through InvokeAsync(...), and render it."; + + // The front page's last story is the one Hacker News is slow to return + static readonly long _slowStoryId = FakeHackerNewsAPI.GetStoryId(StoriesConstants.NumberOfStories); + + // Streaming renders a story a few milliseconds after it arrives. This is how long the step looks for one before deciding nothing streamed. + static readonly TimeSpan _renderWait = TimeSpan.FromSeconds(1); + + static readonly TimeSpan _responseTime = TimeSpan.FromMilliseconds(10); + + public override int Number => 4; + + public override string Scenario => "Stream stories as they arrive"; + + public override string Title => "GetTopStories() and RefreshAsync(CancellationToken)"; + + public override string Story => "Hacker News serves every story as its own request, so the page makes 50 of them. GetTopStories() collects every story into a list, sorts it, and only then hands it back, " + + "so people stare at the loading skeleton until the slowest story arrives, even though most of the others came back long before. " + + "The page already knows how to put a single story in its sorted place. It just never gets a story until it has all of them."; + + public override string SeeItInTheApp => "Reload the Top stories page in your browser and watch the count of loaded stories in the orange bar. It sits at 0 behind the loading skeleton, then jumps straight to 50. " + + "Once the stories stream, it counts up as they arrive. This step makes the last story slow, and looks at the page while it waits."; + + public override string FileToChange => "Components/Pages/News.razor.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Stream the stories out of GetTopStories() as an IAsyncEnumerable, handing each story back as soon as it arrives.", + "Give the stream the CancellationToken as well, so a canceled refresh stops the stream. [EnumeratorCancellation] connects the token passed to WithCancellation() to the iterator.", + "In RefreshAsync(CancellationToken), clear the list once, then add each story to its sorted place through InvokeAsync(...), and render it as it arrives.", + "Keep the list sorted by score, and keep every story in it once.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "Story loading waits for a full list before the UI can process results.", + "An async iterator is an async method that can both await and yield return.", + "await foreach consumes an IAsyncEnumerable, and ConfigureAwait(false) works on it too.", + "InsertIntoSortedList() already puts one story in its place, so nothing needs sorting at the end.", + ]; + + public override string TimeoutHint => "The story stream never finished. Check that GetTopStories() completes after its last story, and that the loop in RefreshAsync(CancellationToken) ends."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + // Part 1: read the compiled page for the stream's return type + var getTopStories = CodeInspector.FindMethod(typeof(NewsPageBase), "GetTopStories"); + var returnType = getTopStories?.ReturnType; + + report.Expect( + "GetTopStories() returns IAsyncEnumerable", + "IAsyncEnumerable", + returnType is null ? "no GetTopStories() method" : CodeInspector.DescribeType(returnType), + returnType == typeof(IAsyncEnumerable), + returnType is null + ? "Keep the method's name, GetTopStories, so this step can find it." + : "A method that returns a list can only return once, when every story is in it. An async iterator returns IAsyncEnumerable and can hand back one story at a time."); + + if (NewsPageSession.HasAsyncVoidMethods(report)) + return; + + // Part 2: load the page with the front page's last story held back, and look at the page while every other story has arrived + var hackerNewsApi = new FakeHackerNewsAPI(_responseTime) { HeldStoryId = _slowStoryId }; + await using var session = new NewsPageSession(hackerNewsApi); + + try + { + report.Log($"Rendering the News page. Every Hacker News call takes {_responseTime.TotalMilliseconds:0} ms, except story {StoriesConstants.NumberOfStories}, which waits until the step has looked at the page"); + await session.BeginRendering(token).ConfigureAwait(false); + + await hackerNewsApi.EveryOtherStoryReturned.WaitAsync(NewsPageSession.RenderTimeout, token).ConfigureAwait(false); + report.Log($"Hacker News has returned {hackerNewsApi.StoriesReturned} stories. Story {StoriesConstants.NumberOfStories} is still on its way"); + + var storiesOnPage = 0; + var stopwatch = Stopwatch.StartNew(); + + while (storiesOnPage is 0 && stopwatch.Elapsed < _renderWait) + { + storiesOnPage = NewsPageSession.CountStoriesOnPage(await session.GetHtml(token).ConfigureAwait(false)); + + if (storiesOnPage is 0) + await Task.Delay(TimeSpan.FromMilliseconds(20), token).ConfigureAwait(false); + } + + report.Log($"While story {StoriesConstants.NumberOfStories} is on its way, the page shows {storiesOnPage} stories"); + + report.Expect( + "While the last story is on its way, the page already shows the stories that have arrived", + "at least 1 story on the page", + $"{NewsPageSession.DescribeStories(storiesOnPage)} on the page", + storiesOnPage > 0, + _notStreamingHint); + } + finally + { + hackerNewsApi.ReleaseHeldStory(); + } + + if (!await session.WaitForInitialization(token).ConfigureAwait(false)) + { + report.Expect("The page finishes initializing", "within 10 seconds", "did not finish", false, TimeoutHint); + return; + } + + // Part 3: once the last story arrives, the list is complete, sorted, and has no story twice + var stories = await session.Read(static page => page.Stories, token).ConfigureAwait(false); + var isSorted = stories.Zip(stories.Skip(1)).All(static pair => pair.First.Score >= pair.Second.Score); + var distinctStories = stories.DistinctBy(static story => story.Id).Count(); + + report.Log($"The refresh finished with {stories.Count} stories, {(isSorted ? "sorted" : "not sorted")} by score, {distinctStories} of them different"); + + report.Expect( + "When the refresh finishes, the page lists every story once, highest score first", + $"{StoriesConstants.NumberOfStories} stories, sorted, none twice", + $"{NewsPageSession.DescribeStories(stories.Count)}, {(isSorted ? "sorted" : "not sorted")}, {(distinctStories == stories.Count ? "none twice" : $"{stories.Count - distinctStories} twice")}", + stories.Count is StoriesConstants.NumberOfStories && isSorted && distinctStories == stories.Count, + "Stories now arrive one at a time and in any order. Clear the list once before the first story, insert each story into its sorted place, and skip a story that is already in the list."); + + await session.ExpectNoErrors(report, token).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Steps/Step5SkipUnneededStateMachines.cs b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Steps/Step5SkipUnneededStateMachines.cs new file mode 100644 index 0000000..b04a810 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Steps/Step5SkipUnneededStateMachines.cs @@ -0,0 +1,122 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using HackerNews.Components.Pages; + +namespace HackerNews; + +public sealed class Step5SkipUnneededStateMachines : WorkshopStep +{ + static readonly TimeSpan _responseTime = TimeSpan.FromMilliseconds(1); + + public override int Number => 5; + + public override string Scenario => "Skip the state machines you don't need"; + + public override string Title => "GetStory() and GetTopStoryIDs()"; + + public override string Story => "Every async method compiles into a state machine, and a call that does not finish synchronously allocates one. GetStory() only awaits one call and returns its result, " + + "so its state machine does nothing but cost, 50 times per refresh, on every page, for every user. " + + "GetTopStoryIDs() is the opposite: when the stories on the page are recent, it answers from them without awaiting anything, yet every call still allocates a new Task to hand back a result it already had."; + + public override string SeeItInTheApp => "Nothing on the page changes in this step, which is exactly why these costs survive code review. Press Refresh twice on the Top stories page: " + + "the second refresh reuses the story IDs already on the page instead of asking Hacker News, so GetTopStoryIDs() completes synchronously and still allocates a Task."; + + public override string FileToChange => "Components/Pages/News.razor.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Return Task directly from GetStory(), a method that only wraps another task, instead of awaiting that task in an async method.", + "Return ValueTask> from GetTopStoryIDs(), because its hot path completes synchronously on most refreshes.", + "Use ValueTask only where the hot path can complete synchronously. GetStory() always waits for the network, so it stays a Task.", + "Keep the cached path in GetTopStoryIDs() synchronous: when the stories on the page are recent, it must not call Hacker News.", + "Where GetTopStoryIDs() does await, ask whether the code after it needs Blazor's context.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "Some methods create async state machines even though they only wrap another async call.", + "A method whose only await is the task it returns, with no try, using or code after it, does not need to be async.", + "ValueTask avoids allocating when the result is already available. A ValueTask may only be awaited once.", + "Changing a method's return type changes how its callers consume it.", + ]; + + public override string TimeoutHint => "GetTopStoryIDs() never answered. Check that its cached path returns without waiting for anything."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + // Part 1: read the compiled page. The compiler marks every async method with AsyncStateMachineAttribute. + var getStory = FindMethod("GetStory", typeof(long), typeof(CancellationToken)); + var getTopStoryIds = FindMethod("GetTopStoryIDs", typeof(CancellationToken)); + + report.Log(getStory is null ? "NewsPageBase has no GetStory(long, CancellationToken)" : $"GetStory() returns {CodeInspector.DescribeType(getStory.ReturnType)}{(IsAsync(getStory) ? " and is an async method" : string.Empty)}"); + report.Log(getTopStoryIds is null ? "NewsPageBase has no GetTopStoryIDs(CancellationToken)" : $"GetTopStoryIDs() returns {CodeInspector.DescribeType(getTopStoryIds.ReturnType)}"); + + report.Expect( + "GetStory() returns Task without an async state machine", + "Task, not async", + getStory is null ? "no GetStory(long, CancellationToken) method" : $"{CodeInspector.DescribeType(getStory.ReturnType)}, {(IsAsync(getStory) ? "async" : "not async")}", + getStory is not null && getStory.ReturnType == typeof(Task) && !IsAsync(getStory), + getStory is null + ? "Keep GetStory(long, CancellationToken), so this step can find it." + : getStory.ReturnType != typeof(Task) + ? "GetStory() always waits for the network, so a ValueTask would never complete synchronously and would only add cost. Keep returning Task." + : "GetStory() awaits a task and returns its result, and that is all. Its caller can await the same task, so hand it back directly and let the compiler skip the state machine."); + + report.Expect( + "GetTopStoryIDs() returns ValueTask>", + "ValueTask>", + getTopStoryIds is null ? "no GetTopStoryIDs(CancellationToken) method" : CodeInspector.DescribeType(getTopStoryIds.ReturnType), + getTopStoryIds?.ReturnType == typeof(ValueTask>), + getTopStoryIds is null + ? "Keep GetTopStoryIDs(CancellationToken), so this step can find it." + : "When the stories on the page are recent, GetTopStoryIDs() already has its answer, yet a Task still has to be allocated to carry it. ValueTask carries a result that is already available without allocating."); + + if (getTopStoryIds is null || NewsPageSession.HasAsyncVoidMethods(report)) + return; + + // Part 2: load the page, then call GetTopStoryIDs() on Blazor's renderer, the way a second refresh does, and see whether it needed Hacker News + await using var session = new NewsPageSession(new FakeHackerNewsAPI(_responseTime)); + + report.Log("Rendering the News page and waiting for it to finish initializing"); + await session.BeginRendering(token).ConfigureAwait(false); + + if (!await session.WaitForInitialization(token).ConfigureAwait(false)) + { + report.Expect("The page finishes initializing", "within 10 seconds", "did not finish", false, "Step 1 checks this. Make it pass again first."); + return; + } + + var requestsBefore = session.HackerNewsApi.TopStoryIdRequests; + report.Log("The page has loaded its stories. Calling GetTopStoryIDs() the way a second refresh does"); + + var (completedSynchronously, storyIds) = await session.Read(page => CallGetTopStoryIds(getTopStoryIds, page), token).ConfigureAwait(false); + var storyIdCount = storyIds is null ? 0 : (await storyIds.WaitAsync(NewsPageSession.RenderTimeout, token).ConfigureAwait(false)).Count; + var requestsDuringCall = session.HackerNewsApi.TopStoryIdRequests - requestsBefore; + + report.Log($"GetTopStoryIDs() {(completedSynchronously ? "completed synchronously" : "did not complete synchronously")}, returned {storyIdCount} IDs, and asked Hacker News {requestsDuringCall} times"); + + report.Expect( + "With recent stories on the page, GetTopStoryIDs() completes synchronously without calling Hacker News", + "completed synchronously, 0 calls", + $"{(completedSynchronously ? "completed synchronously" : "did not complete synchronously")}, {requestsDuringCall} calls", + completedSynchronously && requestsDuringCall is 0, + "The cached path is the reason GetTopStoryIDs() can return a ValueTask. When IsDataRecent() is true, return the IDs of the stories on the page without awaiting anything."); + } + + static MethodInfo? FindMethod(string name, params Type[] parameterTypes) => + typeof(NewsPageBase).GetMethod(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, parameterTypes); + + static bool IsAsync(MethodInfo method) => method.IsDefined(typeof(AsyncStateMachineAttribute), false); + + // Runs on Blazor's renderer. Whatever GetTopStoryIDs() returns, report whether it had already completed, and hand back a Task to await for its result. + static (bool CompletedSynchronously, Task>? StoryIds) CallGetTopStoryIds(MethodInfo getTopStoryIds, NewsPageProbe page) => + getTopStoryIds.Invoke(page, [CancellationToken.None]) switch + { + ValueTask> valueTask => (valueTask.IsCompleted, valueTask.AsTask()), + Task> task => (task.IsCompleted, task), + _ => (false, null), + }; +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Steps/StepContext.cs b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Steps/StepContext.cs new file mode 100644 index 0000000..a46f8d1 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Steps/StepContext.cs @@ -0,0 +1,4 @@ +namespace HackerNews; + +// Token is cancelled when the run is stopped, times out, or the app shuts down +public sealed record StepContext(StepReport Report, CancellationToken Token); \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Steps/WorkshopStep.cs b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Steps/WorkshopStep.cs new file mode 100644 index 0000000..99b1f88 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Steps/WorkshopStep.cs @@ -0,0 +1,37 @@ +namespace HackerNews; + +// Every step is one async await mistake in the Top stories page. +// Its tab in the workshop guide tells you what to fix, and Run() renders your News page the way Blazor does for a browser tab, logs what happens, +// and records an expected result, with a hint, for everything that should be true once the mistake is fixed. +public abstract class WorkshopStep +{ + public abstract int Number { get; } + + // What this step fixes, in a few words + public abstract string Scenario { get; } + + // The members this step exercises + public abstract string Title { get; } + + // What goes wrong in production, and why it slipped through + public abstract string Story { get; } + + // How to watch this mistake happen on the Top stories page + public abstract string SeeItInTheApp { get; } + + // Where the mistake lives + public abstract string FileToChange { get; } + + // What this step asks you to change. Some of it cannot be measured from outside, so not every task has an expected result. + public abstract IReadOnlyList Tasks { get; } + + // Shown only when you ask for them + public abstract IReadOnlyList Clues { get; } + + // What is most likely waiting forever when this step does not finish in time + public abstract string TimeoutHint { get; } + + public string SourceFile => $"Steps/{GetType().Name}.cs"; + + public abstract Task Run(StepContext context); +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/ActiveStepRun.cs b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/ActiveStepRun.cs new file mode 100644 index 0000000..419eb90 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/ActiveStepRun.cs @@ -0,0 +1,3 @@ +namespace HackerNews; + +public sealed record ActiveStepRun(WorkshopStep Step, StepReport Report); \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/CapturingLogger.cs b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/CapturingLogger.cs new file mode 100644 index 0000000..99aed98 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/CapturingLogger.cs @@ -0,0 +1,39 @@ +using HackerNews.Components.Pages; + +namespace HackerNews; + +// Workshop plumbing: the ILogger a step gives your page, so it can check what the page logged on the server. +// Nothing is written to the terminal, so the errors a step causes on purpose do not look like errors in your app. +public sealed class CapturingLogger : ILogger +{ + readonly Lock _lock = new(); + readonly List _entries = []; + + public IReadOnlyList Entries + { + get + { + lock (_lock) + { + return [.. _entries]; + } + } + } + + public IReadOnlyList Errors => [.. Entries.Where(static entry => entry.Level >= LogLevel.Error)]; + + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + var entry = new LoggedEntry(logLevel, formatter(state, exception), exception); + + lock (_lock) + { + _entries.Add(entry); + } + } +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/CodeInspector.cs b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/CodeInspector.cs new file mode 100644 index 0000000..33792b3 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/CodeInspector.cs @@ -0,0 +1,117 @@ +using System.Reflection; +using System.Reflection.Emit; +using System.Runtime.CompilerServices; + +namespace HackerNews; + +// Workshop plumbing: reads a type's compiled code, including the lambdas and async state machines the compiler generates for it. +// A few requirements cannot always be seen from outside, such as a blocking wait that happens to run off Blazor's renderer, so a step reads the code instead. +public static class CodeInspector +{ + const BindingFlags _declaredMembers = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + + static readonly Dictionary _opCodes = typeof(OpCodes) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Select(static field => field.GetValue(null) is OpCode opCode ? opCode : throw new InvalidOperationException($"OpCodes.{field.Name} is not an OpCode")) + .ToDictionary(static opCode => opCode.Value); + + // Every method the type's code calls, written as Type.Method, once per call. A method called from two places is listed twice. + public static IReadOnlyList GetCalledMethods(Type type) + { + List calledMethods = []; + + foreach (var declaringType in GetTypeAndNestedTypes(type)) + { + var methods = declaringType.GetMethods(_declaredMembers).Cast().Concat(declaringType.GetConstructors(_declaredMembers)); + + foreach (var method in methods) + { + AddCalledMethods(method, calledMethods); + } + } + + return calledMethods; + } + + // async void methods, including async lambdas the compiler turned into async void methods + public static IReadOnlyList GetAsyncVoidMethods(Type type) => + [ + .. GetTypeAndNestedTypes(type) + .SelectMany(static declaringType => declaringType.GetMethods(_declaredMembers)) + .Where(static method => method.ReturnType == typeof(void) && method.IsDefined(typeof(AsyncStateMachineAttribute), false)) + .Select(static method => DescribeMethod(method.Name)), + ]; + + public static MethodInfo? FindMethod(Type type, string name) => type.GetMethods(_declaredMembers).FirstOrDefault(method => method.Name == name); + + // Task> rather than Task`1 + public static string DescribeType(Type type) => type.IsGenericType + ? $"{type.Name[..type.Name.IndexOf('`')]}<{string.Join(", ", type.GetGenericArguments().Select(DescribeType))}>" + : Type.GetTypeCode(type) switch + { + TypeCode.Boolean => "bool", + TypeCode.Int32 => "int", + TypeCode.Int64 => "long", + TypeCode.String => "string", + _ => type == typeof(void) ? "void" : type.Name, + }; + + // The compiler names a lambda b__12_0 + static string DescribeMethod(string name) => name.StartsWith('<') && name.IndexOf('>') is > 1 and var end + ? $"an async lambda in {name[1..end]}()" + : $"{name}()"; + + static IEnumerable GetTypeAndNestedTypes(Type type) => + type.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic).SelectMany(GetTypeAndNestedTypes).Prepend(type); + + static void AddCalledMethods(MethodBase method, List calledMethods) + { + if (method.GetMethodBody()?.GetILAsByteArray() is not { } il) + return; + + var offset = 0; + + while (offset < il.Length) + { + // Two byte opcodes start with 0xFE + short value = il[offset++]; + + if (value is 0xFE && offset < il.Length) + value = unchecked((short)(0xFE00 | il[offset++])); + + if (!_opCodes.TryGetValue(value, out var opCode)) + return; + + if (opCode.OperandType is OperandType.InlineMethod && ResolveMethod(method, BitConverter.ToInt32(il, offset)) is { } calledMethod) + calledMethods.Add(calledMethod.DeclaringType is { } declaringType ? $"{declaringType.Name}.{calledMethod.Name}" : calledMethod.Name); + + offset += opCode.OperandType switch + { + OperandType.InlineNone => 0, + OperandType.ShortInlineBrTarget or OperandType.ShortInlineI or OperandType.ShortInlineVar => 1, + OperandType.InlineVar => 2, + OperandType.InlineI8 or OperandType.InlineR => 8, + OperandType.InlineSwitch => 4 + (BitConverter.ToInt32(il, offset) * 4), + _ => 4, + }; + } + } + + static MethodBase? ResolveMethod(MethodBase method, int metadataToken) + { + var declaringType = method.DeclaringType; + + try + { + return method.Module.ResolveMethod( + metadataToken, + declaringType is { IsGenericType: true } ? declaringType.GetGenericArguments() : null, + method.IsGenericMethod ? method.GetGenericArguments() : null); + } + catch (ArgumentException) + { + // A call that cannot be resolved outside its generic context is not one a step looks for + return null; + } + } +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/FakeHackerNewsAPI.cs b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/FakeHackerNewsAPI.cs new file mode 100644 index 0000000..44909ec --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/FakeHackerNewsAPI.cs @@ -0,0 +1,83 @@ +namespace HackerNews; + +// Workshop plumbing: an in-memory Hacker News for the steps, so every check runs offline and gives the same answer on every machine. +// Every call waits a delay the step chooses and honors its CancellationToken, the way a real HTTP request does. +public sealed class FakeHackerNewsAPI(TimeSpan responseTime) : IHackerNewsAPI +{ + // More than the page asks for, like the real API + public const int TopStoryCount = 80; + + // What the fake puts in the exception when it is failing. None of it may ever reach the page. + public const string FailureDetail = "Response status code does not indicate success: 503 (Service Unavailable) from hacker-news.firebaseio.com edge node sjc-7"; + + readonly TaskCompletionSource _heldStoryReleased = new(TaskCreationOptions.RunContinuationsAsynchronously); + readonly TaskCompletionSource _everyOtherStoryReturned = new(TaskCreationOptions.RunContinuationsAsynchronously); + readonly DateTimeOffset _now = DateTimeOffset.UtcNow; + + int _topStoryIdRequests; + int _storyRequests; + int _storiesReturned; + + // When set, every call throws HttpRequestException, the way it does when Hacker News is down + public bool IsFailing { get; init; } + + // When set, this story is not returned until ReleaseHeldStory() is called, so a step can look at the page while it waits + public long? HeldStoryId { get; init; } + + public int TopStoryIdRequests => Volatile.Read(ref _topStoryIdRequests); + + public int StoryRequests => Volatile.Read(ref _storyRequests); + + public int StoriesReturned => Volatile.Read(ref _storiesReturned); + + // Completes once every story the page shows, apart from the held one, has been returned + public Task EveryOtherStoryReturned => _everyOtherStoryReturned.Task; + + // Ranks start at 1, the way the front page numbers them + public static long GetStoryId(int rank) => 45_000_000 + rank; + + // Scores are deliberately out of rank order, so the page has to sort them. 37 and 101 share no factors, so no two ranks get the same score. + public static int GetScore(int rank) => 100 + (rank * 37 % 101); + + public void ReleaseHeldStory() => _heldStoryReleased.TrySetResult(); + + public async Task> GetTopStoryIDs(CancellationToken token) + { + Interlocked.Increment(ref _topStoryIdRequests); + + await Task.Delay(responseTime, token).ConfigureAwait(false); + + if (IsFailing) + throw new HttpRequestException(FailureDetail); + + return [.. Enumerable.Range(1, TopStoryCount).Select(GetStoryId)]; + } + + public async Task GetStory(long storyId, CancellationToken token) + { + Interlocked.Increment(ref _storyRequests); + + await Task.Delay(responseTime, token).ConfigureAwait(false); + + if (IsFailing) + throw new HttpRequestException(FailureDetail); + + if (storyId == HeldStoryId) + await _heldStoryReleased.Task.WaitAsync(token).ConfigureAwait(false); + + var rank = (int)(storyId - GetStoryId(0)); + + if (Interlocked.Increment(ref _storiesReturned) is StoriesConstants.NumberOfStories - 1 && HeldStoryId is not null) + _everyOtherStoryReturned.TrySetResult(); + + // Posted between 1 and 80 minutes ago, so the stories on the page always count as recent + return new StoryModel( + "workshop", + storyId, + GetScore(rank), + _now.AddMinutes(-rank).ToUnixTimeSeconds(), + $"Workshop story {rank}", + "story", + $"https://example.com/stories/{rank}"); + } +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/LoggedEntry.cs b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/LoggedEntry.cs new file mode 100644 index 0000000..7225bda --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/LoggedEntry.cs @@ -0,0 +1,3 @@ +namespace HackerNews; + +public sealed record LoggedEntry(LogLevel Level, string Message, Exception? Exception); \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/NewsPageProbe.cs b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/NewsPageProbe.cs new file mode 100644 index 0000000..24d3545 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/NewsPageProbe.cs @@ -0,0 +1,18 @@ +using HackerNews.Components.Pages; + +namespace HackerNews; + +// Workshop plumbing: your News page, plus a few read-only views of its state for the steps. +// It adds no behavior, so rendering it runs exactly the code the Top stories page runs. +// Read these members through NewsPageSession, which reads them on Blazor's renderer, the same way the page does. +public sealed class NewsPageProbe : News +{ + public IReadOnlyList Stories => [.. TopStoryCollection]; + + public bool IsRefreshing => IsListRefreshing; + + public string? ErrorMessage => RefreshErrorMessage; + + // What the Refresh button calls + public Task Refresh() => RefreshAsync(); +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/NewsPageSession.cs b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/NewsPageSession.cs new file mode 100644 index 0000000..db88c59 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/NewsPageSession.cs @@ -0,0 +1,174 @@ +using HackerNews.Components.Pages; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.Web.HtmlRendering; + +namespace HackerNews; + +// Workshop plumbing: renders your News page in memory with Blazor's HtmlRenderer, the way Blazor renders it for a browser tab. +// The renderer runs component code one piece of work at a time, just like the renderer behind every browser tab of a Blazor Server app. +// Every session has its own services and its own FakeHackerNewsAPI, so it never touches the app's singletons or the real Hacker News. +public sealed class NewsPageSession : IAsyncDisposable +{ + // Every wait on your page gives up after this long, so a page that never answers fails its check instead of hanging the step + public static readonly TimeSpan RenderTimeout = TimeSpan.FromSeconds(10); + + readonly ServiceProvider _serviceProvider; + readonly HtmlRenderer _renderer; + readonly NewsPageActivator _activator = new(); + + HtmlRootComponent? _rootComponent; + bool _isClosed; + + public NewsPageSession(FakeHackerNewsAPI hackerNewsApi) + { + HackerNewsApi = hackerNewsApi; + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton>(Logger); + services.AddSingleton(hackerNewsApi); + services.AddSingleton(); + services.AddSingleton(_activator); + + _serviceProvider = services.BuildServiceProvider(); + _renderer = new HtmlRenderer(_serviceProvider, _serviceProvider.GetRequiredService()); + } + + public FakeHackerNewsAPI HackerNewsApi { get; } + + public CapturingLogger Logger { get; } = new(); + + public Dispatcher Dispatcher => _renderer.Dispatcher; + + // Completes when Blazor considers the page initialized: when the Task returned by its lifecycle methods completes + public Task InitializationTask => RootComponent.QuiescenceTask; + + HtmlRootComponent RootComponent => _rootComponent ?? throw new InvalidOperationException("The News page has not been rendered yet"); + + NewsPageProbe Page => _activator.Page ?? throw new InvalidOperationException("The News page has not been rendered yet"); + + // Steps that render your page stop here while it still has an async void method: an exception that escapes one + // can end the whole app, and a step must never do that + public static bool HasAsyncVoidMethods(StepReport report) + { + var asyncVoidMethods = CodeInspector.GetAsyncVoidMethods(typeof(NewsPageBase)); + + if (asyncVoidMethods.Count is 0) + return false; + + report.Expect( + "NewsPageBase has no async void methods", + "none", + string.Join(", ", asyncVoidMethods), + false, + "This step renders your page, and it will not while an exception could escape an async void method and end the app. Step 1 explains why, so make it pass again first."); + + return true; + } + + public static int CountStoriesOnPage(string html) => html.Split("
count is 1 ? "1 story" : $"{count} stories"; + + // Renders the page for the first time. OnInitialized() and OnInitializedAsync() run up to their first incomplete await before this returns. + public async Task BeginRendering(CancellationToken token) + { + _rootComponent = await _renderer.Dispatcher.InvokeAsync(() => _renderer.BeginRenderingComponent()).WaitAsync(RenderTimeout, token).ConfigureAwait(false); + } + + // Waits for Blazor to finish initializing the page. False when it did not finish in time. + public async Task WaitForInitialization(CancellationToken token) + { + try + { + await InitializationTask.WaitAsync(RenderTimeout, token).ConfigureAwait(false); + return true; + } + catch (TimeoutException) + { + return false; + } + } + + // The HTML of the page as it was last rendered. Changing component state without a render does not change it. + public Task GetHtml(CancellationToken token) => Read(_ => RootComponent.ToHtmlString(), token); + + // Reads the page's state on Blazor's renderer, where the page reads and writes it + public Task Read(Func read, CancellationToken token) => _renderer.Dispatcher.InvokeAsync(() => read(Page)).WaitAsync(RenderTimeout, token); + + // Presses Refresh: the refresh starts on Blazor's renderer, and the returned Task completes when the refresh does + public Task PressRefresh() => _renderer.Dispatcher.InvokeAsync(Page.Refresh); + + // Closes the page, the way Blazor does when the user navigates away or closes the tab: the renderer disposes every component on it + public async Task ClosePage() + { + if (_isClosed) + return; + + _isClosed = true; + + try + { + await _renderer.DisposeAsync().AsTask().WaitAsync(RenderTimeout).ConfigureAwait(false); + } + catch (TimeoutException) + { + // Blazor's renderer is still blocked by your page. Abandon it rather than hang the step. + } + } + + // Nothing fails on the fake Hacker News unless a step asks it to, so any error during a refresh came from the page itself + public async Task ExpectNoErrors(StepReport report, CancellationToken token) + { + var errorMessage = await Read(static page => page.ErrorMessage, token).ConfigureAwait(false); + var errors = Logger.Errors; + + foreach (var error in errors) + { + report.Log($"Your page logged an error: \"{error.Message}\" ({error.Exception?.GetType().Name ?? "no exception"})"); + } + + var loggedExceptions = string.Join(", ", errors.Select(static error => error.Exception?.GetType().Name ?? "an error").Distinct()); + + var actual = (errors.Count, errorMessage) switch + { + (0, null) => "no errors", + (0, _) => "an error message on the page", + (_, null) => $"logged {loggedExceptions}", + _ => $"logged {loggedExceptions}, and an error message on the page", + }; + + report.Expect( + "A refresh against a healthy Hacker News logs no errors and shows no error message", + "no errors", + actual, + errors.Count is 0 && errorMessage is null, + errors.Any(static error => error.Exception is InvalidOperationException) + ? "Your page logged an InvalidOperationException. Blazor throws it when a component renders off its renderer, which is where the code after ConfigureAwait(false) runs. Make those state changes, and StateHasChanged(), through InvokeAsync(...)." + : "Nothing failed on the Hacker News side, so the error came from your page. The step's log lists every message your page logged, with its exception type. Compare it with the catch blocks in RefreshAsync(CancellationToken)."); + } + + public async ValueTask DisposeAsync() + { + await ClosePage().ConfigureAwait(false); + await _serviceProvider.DisposeAsync().ConfigureAwait(false); + } + + // Blazor asks this for every component it creates, which is how the session gets hold of the page it rendered + sealed class NewsPageActivator : IComponentActivator + { + public NewsPageProbe? Page { get; private set; } + + public IComponent CreateInstance(Type componentType) + { + if (Activator.CreateInstance(componentType) is not IComponent component) + throw new ArgumentException($"{componentType.Name} is not a component", nameof(componentType)); + + if (component is NewsPageProbe page) + Page = page; + + return component; + } + } +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/StepCheck.cs b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/StepCheck.cs new file mode 100644 index 0000000..df8a338 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/StepCheck.cs @@ -0,0 +1,3 @@ +namespace HackerNews; + +public sealed record StepCheck(string Description, string Expected, string Actual, bool Passed, string Hint); \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/StepLogEntry.cs b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/StepLogEntry.cs new file mode 100644 index 0000000..b1fdd01 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/StepLogEntry.cs @@ -0,0 +1,3 @@ +namespace HackerNews; + +public sealed record StepLogEntry(TimeSpan Elapsed, int ThreadId, bool IsThreadPoolThread, string Message); \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/StepReport.cs b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/StepReport.cs new file mode 100644 index 0000000..35e26da --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/StepReport.cs @@ -0,0 +1,82 @@ +using System.Diagnostics; + +namespace HackerNews; + +// Collects what a step logged and every expected result it checked. +// Steps write to it from thread pool threads while the workshop guide reads it, so every member takes the lock. +public sealed class StepReport +{ + readonly Lock _lock = new(); + readonly Stopwatch _stopwatch = Stopwatch.StartNew(); + readonly List _entries = []; + readonly List _checks = []; + + bool _isComplete; + + public IReadOnlyList Entries + { + get + { + lock (_lock) + { + return [.. _entries]; + } + } + } + + public IReadOnlyList Checks + { + get + { + lock (_lock) + { + return [.. _checks]; + } + } + } + + public void Log(string message) + { + var threadId = Environment.CurrentManagedThreadId; + var isThreadPoolThread = Thread.CurrentThread.IsThreadPoolThread; + + lock (_lock) + { + if (!_isComplete) + _entries.Add(new StepLogEntry(_stopwatch.Elapsed, threadId, isThreadPoolThread, message)); + } + } + + // The hint is shown under the expected result only when it does not match + public void Expect(string description, T expected, T actual, string hint) + { + Expect(description, Format(expected), Format(actual), EqualityComparer.Default.Equals(expected, actual), hint); + } + + public void Expect(string description, string expected, string actual, bool passed, string hint) + { + lock (_lock) + { + if (!_isComplete) + _checks.Add(new StepCheck(description, expected, actual, passed, hint)); + } + } + + // Called when the run ends, including a run that timed out or was stopped while its step is still blocked somewhere. + // Anything that abandoned step writes later is ignored, so the result in the workshop guide can no longer change. + public void Complete() + { + lock (_lock) + { + _isComplete = true; + _stopwatch.Stop(); + } + } + + static string Format(T value) => value switch + { + null => "null", + int or long => $"{value:N0}", + _ => value.ToString() ?? "null", + }; +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/StepState.cs b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/StepState.cs new file mode 100644 index 0000000..8c2557e --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/StepState.cs @@ -0,0 +1,9 @@ +namespace HackerNews; + +// ExceptionType is set for Crashed. Only the name is kept: exception messages and stack traces stay in the terminal. +// CompletedAt is when the run that produced this state ended, so the workshop guide can show that a click really ran again. +public sealed record StepState( + StepStatus Status, + StepReport? Report, + string? ExceptionType = null, + DateTimeOffset? CompletedAt = null); \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/StepStatus.cs b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/StepStatus.cs new file mode 100644 index 0000000..9a2cf59 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/StepStatus.cs @@ -0,0 +1,12 @@ +namespace HackerNews; + +public enum StepStatus +{ + NotRun, + Running, + Passed, + Failed, + TimedOut, + Crashed, + Stopped, +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/StepVerifier.cs b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/StepVerifier.cs new file mode 100644 index 0000000..b2c0053 --- /dev/null +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/Verification/StepVerifier.cs @@ -0,0 +1,232 @@ +namespace HackerNews; + +// Workshop plumbing: runs the steps against your News page and keeps their results. +// Every step renders its own copy of the page against an in-memory Hacker News, so a check never touches the Top stories page you have open, or the real API. +// Only one step runs at a time, so a step that times Blazor's renderer never competes with another run for thread pool threads. +public sealed class StepVerifier(ILogger logger, IHostApplicationLifetime applicationLifetime) : BackgroundService +{ + public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); + + readonly Lock _lock = new(); + readonly StepState[] _states = [.. Enumerable.Repeat(new StepState(StepStatus.NotRun, null), 5)]; + + // Both start at 1: the app checks every step on startup, and nothing else may run before that finishes + int _isBusy = 1; + int _isCheckingAllSteps = 1; + + ActiveStepRun? _activeRun; + TaskCompletionSource? _stopRequested; + + public IReadOnlyList Steps { get; } = + [ + new Step1LoadStoriesDuringInitialization(), + new Step2KeepTheRendererFree(), + new Step3StopWhenThePageGoesAway(), + new Step4StreamStories(), + new Step5SkipUnneededStateMachines(), + ]; + + public bool IsBusy => Volatile.Read(ref _isBusy) is 1; + + public bool IsCheckingAllSteps => Volatile.Read(ref _isCheckingAllSteps) is 1; + + public ActiveStepRun? ActiveRun + { + get + { + lock (_lock) + { + return _activeRun; + } + } + } + + public WorkshopStep? FindStep(int number) => Steps.FirstOrDefault(step => step.Number == number); + + public StepState GetState(int number) + { + lock (_lock) + { + return _states[number - 1]; + } + } + + public bool IsUnlocked(int number) => number is 1 || GetState(number - 1).Status is StepStatus.Passed; + + // Re-runs every step, the same way startup does + public async Task CheckAllSteps() + { + if (Interlocked.CompareExchange(ref _isBusy, 1, 0) is not 0) + return; + + Volatile.Write(ref _isCheckingAllSteps, 1); + + try + { + await CheckSteps(applicationLifetime.ApplicationStopping).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isCheckingAllSteps, 0); + Volatile.Write(ref _isBusy, 0); + } + } + + public async Task RunStep(WorkshopStep step) + { + if (!IsUnlocked(step.Number) || Interlocked.CompareExchange(ref _isBusy, 1, 0) is not 0) + return GetState(step.Number); + + try + { + return await Run(step, applicationLifetime.ApplicationStopping).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isBusy, 0); + } + } + + public void StopRun() + { + lock (_lock) + { + _stopRequested?.TrySetResult(); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await CheckSteps(stoppingToken).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isCheckingAllSteps, 0); + Volatile.Write(ref _isBusy, 0); + } + } + + async Task CheckSteps(CancellationToken token) + { + try + { + // Results from before the latest change to your code are not trusted + lock (_lock) + { + Array.Fill(_states, new StepState(StepStatus.NotRun, null)); + } + + logger.LogInformation("Checking your News page against every step. Open the app in your browser: the workshop guide beside the Top stories page shows what to fix next"); + + foreach (var step in Steps) + { + var state = await Run(step, token).ConfigureAwait(false); + + if (state.Status is not StepStatus.Passed) + { + logger.LogInformation("Stopped checking at Step {StepNumber}. Open it in the app to see what to change, with a hint for every result that did not match. Later steps unlock after it passes", step.Number); + return; + } + } + + logger.LogInformation("Every step passed. Pause here for the group review"); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The app is shutting down + } + catch (Exception e) + { + logger.LogError(e, "Checking the steps failed unexpectedly"); + } + } + + async Task Run(WorkshopStep step, CancellationToken token) + { + var report = new StepReport(); + var stopRequested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var previousState = GetState(step.Number); + + lock (_lock) + { + _activeRun = new ActiveStepRun(step, report); + _stopRequested = stopRequested; + _states[step.Number - 1] = new StepState(StepStatus.Running, report); + } + + // Cancelled when the run ends for any reason, so a step that is still working can stop + using var runCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + + var state = previousState; + + try + { + state = await Execute(step, report, stopRequested.Task, runCancellationTokenSource.Token, token).ConfigureAwait(false); + } + finally + { + await runCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + report.Complete(); + + lock (_lock) + { + _activeRun = null; + _stopRequested = null; + _states[step.Number - 1] = state with { CompletedAt = DateTimeOffset.Now }; + } + } + + return state; + } + + async Task Execute(WorkshopStep step, StepReport report, Task stopRequested, CancellationToken runToken, CancellationToken token) + { + try + { + // A dedicated thread runs the step until its first await. If a deadlock blocks it forever, + // a thread pool thread is not lost for the rest of the app's life. + var stepTask = Task.Factory.StartNew(() => step.Run(new StepContext(report, runToken)), token, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); + + var finished = await Task.WhenAny(stepTask, stopRequested).WaitAsync(Timeout, token).ConfigureAwait(false); + + if (finished == stopRequested) + { + logger.LogInformation("Step {StepNumber} was stopped", step.Number); + return new StepState(StepStatus.Stopped, report); + } + + await stepTask.ConfigureAwait(false); + + var checks = report.Checks; + var passedChecks = checks.Count(static check => check.Passed); + var status = checks.Count > 0 && passedChecks == checks.Count ? StepStatus.Passed : StepStatus.Failed; + + logger.LogInformation("Step {StepNumber} {Result} ({PassedChecks}/{TotalChecks} checks)", step.Number, status is StepStatus.Passed ? "passed" : "failed", passedChecks, checks.Count); + + // The hints belong in the terminal too, next to anything the app logged while the step ran + foreach (var check in checks.Where(static check => !check.Passed)) + { + logger.LogWarning("Step {StepNumber}: {Description}. Expected {Expected}, actual {Actual}. Hint: {Hint}", step.Number, check.Description, check.Expected, check.Actual, check.Hint); + } + + return new StepState(status, report); + } + catch (TimeoutException) + { + logger.LogWarning("Step {StepNumber} did not finish within {Timeout}. {TimeoutHint}", step.Number, Timeout, step.TimeoutHint); + return new StepState(StepStatus.TimedOut, report); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + return new StepState(StepStatus.NotRun, null); + } + catch (Exception e) + { + logger.LogError(e, "Step {StepNumber} threw an unexpected exception", step.Number); + return new StepState(StepStatus.Crashed, report, e.GetType().Name); + } + } +} \ No newline at end of file diff --git a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/wwwroot/app.css b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/wwwroot/app.css index 7a1430d..4095b01 100644 --- a/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/wwwroot/app.css +++ b/2. Correcting Common Async Await Mistakes/2. Finish/HackerNews/wwwroot/app.css @@ -1,19 +1,27 @@ +/* HackerNews: the front page, with the workshop guide docked beside it as the pull request's code review */ + :root { - --canvas: #eef3f1; - --panel: #ffffff; - --panel-strong: #112a2d; - --ink: #18211f; - --muted: #62706d; - --line: #d7e0dd; - --accent: #f46a1f; - --accent-dark: #b94713; - --teal: #0f766e; - --gold: #c59d35; - --shadow: 0 18px 55px rgb(28 45 42 / 14%); + --hn-orange: #ff6600; + --hn-beige: #f6f6ef; + --hn-ink: #000000; + --hn-muted: #828282; + --review-canvas: #f6f8fa; + --review-panel: #ffffff; + --review-line: #d0d7de; + --review-ink: #1f2328; + --review-muted: #59636e; + --review-blue: #0969da; + --review-purple: #8250df; + --good: #1a7f37; + --good-strong: #1f883d; + --good-dark: #1c8139; + --bad: #cf222e; + --warn: #9a6700; + --guide-width: 460px; color-scheme: light; - font-family: "Aptos", "Avenir Next", "Segoe UI", sans-serif; - background: var(--canvas); - color: var(--ink); + font-family: Verdana, Geneva, sans-serif; + background: #e9e9e2; + color: var(--hn-ink); } * { @@ -23,119 +31,116 @@ body { margin: 0; overflow-x: clip; - background: - linear-gradient(135deg, rgb(15 118 110 / 14%), transparent 32%), - radial-gradient(circle at top right, rgb(244 106 31 / 16%), transparent 30%), - var(--canvas); + background: #e9e9e2; } -.shell { - min-height: 100vh; +code { + font-family: ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace; + font-size: 0.88em; } -.news-page { - width: min(1120px, calc(100% - 32px)); - margin: 0 auto; - padding: 32px 0 56px; +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; } -.hero-panel { - display: grid; - grid-template-columns: 1fr auto; - gap: 28px; - align-items: end; - margin-bottom: 22px; - padding: 30px; - border: 1px solid rgb(36 35 31 / 10%); - border-radius: 8px; - background: linear-gradient(135deg, var(--panel-strong), #15463e 58%, #6f3b17); - box-shadow: var(--shadow); - color: #f8fffc; +@keyframes spin { + to { + transform: rotate(360deg); + } } -.hero-copy { - max-width: 680px; -} +/* The app and the guide share the screen */ -.eyebrow { - margin: 0 0 10px; - font-size: 0.78rem; - font-weight: 800; - letter-spacing: 0; - text-transform: uppercase; - color: #ffd29d; +.workspace { + display: flex; + min-height: 100vh; } -h1 { - margin: 0; - font-family: "Iowan Old Style", "Sitka Text", Georgia, serif; - font-size: 4.45rem; - font-weight: 700; - line-height: 0.95; +.app-pane { + flex: 1 1 auto; + min-width: 0; + padding: 10px 16px 40px; } -.hero-summary { - max-width: 560px; - margin: 16px 0 0; - font-size: 1rem; - line-height: 1.6; - color: #dcefeb; +/* The front page */ + +.hn { + width: min(980px, 100%); + margin: 0 auto; + background: var(--hn-beige); } -.hero-actions { - display: grid; - gap: 12px; - min-width: 220px; +.hn-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + padding: 4px 8px; + background: var(--hn-orange); + font-size: 0.82rem; } -.status-tile { +.hn-logo { display: grid; - gap: 2px; - padding: 16px; - border: 1px solid rgb(255 255 255 / 18%); - border-radius: 8px; - background: rgb(255 250 240 / 10%); - backdrop-filter: blur(10px); + place-items: center; + width: 20px; + height: 20px; + border: 1px solid #fff; + color: #fff; + font-weight: 700; } -.status-value { - font-family: "Iowan Old Style", "Sitka Text", Georgia, serif; - font-size: 2.35rem; +.hn-name { font-weight: 700; - line-height: 1; } -.status-label { - font-size: 0.78rem; - font-weight: 700; - color: #dcefeb; +.hn-links { + display: flex; + gap: 4px; +} + +.hn-link + .hn-link::before { + content: "| "; +} + +.hn-link.current { + color: #fff; +} + +.hn-count { + margin-left: auto; } .refresh-button { display: inline-flex; align-items: center; - justify-content: center; - gap: 10px; - min-height: 44px; - border: 0; - border-radius: 8px; - background: var(--accent); - color: #fff; - cursor: pointer; + gap: 6px; + padding: 2px 10px; + border: 1px solid rgb(0 0 0 / 35%); + border-radius: 2px; + background: var(--hn-beige); + color: var(--hn-ink); font: inherit; - font-weight: 800; - padding: 10px 16px; - box-shadow: 0 10px 24px rgb(0 0 0 / 20%); + cursor: pointer; +} + +.refresh-button:hover:not(:disabled) { + background: #fff; } .refresh-button:disabled { cursor: progress; - opacity: 0.7; + opacity: 0.75; } .refresh-icon { - width: 15px; - height: 15px; + width: 11px; + height: 11px; border: 2px solid currentcolor; border-left-color: transparent; border-radius: 50%; @@ -145,32 +150,26 @@ h1 { animation: spin 900ms linear infinite; } +.hn-body { + padding: 10px 8px 24px; +} + .story-list { display: grid; - gap: 10px; + gap: 6px; } .story-card { display: grid; - grid-template-columns: 42px 1fr; - gap: 16px; - align-items: start; - padding: 16px; - border: 1px solid var(--line); - border-radius: 8px; - background: rgb(255 255 255 / 88%); - box-shadow: 0 8px 24px rgb(28 45 42 / 8%); + grid-template-columns: 34px 1fr; + gap: 4px; + align-items: baseline; } .story-rank { - display: inline-grid; - place-items: center; - width: 42px; - height: 42px; - border-radius: 8px; - background: #e4efeb; - color: var(--accent-dark); - font-weight: 900; + color: var(--hn-muted); + font-size: 0.86rem; + text-align: right; } .story-content { @@ -178,38 +177,37 @@ h1 { } .story-title { - color: var(--ink); - font-size: 1.02rem; - font-weight: 800; + color: var(--hn-ink); + font-size: 0.9rem; line-height: 1.35; text-decoration: none; } +.story-title:visited { + color: var(--hn-muted); +} + .story-title:hover { - color: var(--accent-dark); text-decoration: underline; - text-decoration-thickness: 2px; - text-underline-offset: 3px; } .story-meta { - margin: 6px 0 0; - font-size: 0.84rem; - font-weight: 650; - color: var(--muted); + margin: 2px 0 0; + color: var(--hn-muted); + font-size: 0.7rem; } .error-panel { + margin: 0 0 12px; + padding: 8px 10px; border: 1px solid #cc4b24; - border-radius: 8px; background: #fff4ee; - margin-bottom: 18px; - padding: 14px 16px; color: #7f1d1d; + font-size: 0.82rem; } .error-panel p { - margin: 6px 0 0; + margin: 4px 0 0; line-height: 1.45; } @@ -219,21 +217,21 @@ h1 { .skeleton-line { display: block; - height: 14px; - border-radius: 999px; - background: linear-gradient(90deg, #dbe6e2, #f7fbf9, #dbe6e2); + height: 11px; + border-radius: 2px; + background: linear-gradient(90deg, #e2e2d8, #fbfbf6, #e2e2d8); background-size: 220% 100%; animation: shimmer 1.4s ease-in-out infinite; } .title-line { - width: min(620px, 80%); - height: 18px; + width: min(560px, 80%); + height: 13px; } .meta-line { - width: min(360px, 58%); - margin-top: 10px; + width: min(300px, 50%); + margin-top: 5px; } @keyframes shimmer { @@ -246,39 +244,663 @@ h1 { } } -@keyframes spin { +/* The workshop guide: a pull request review, docked beside the front page */ + +.guide { + position: sticky; + top: 0; + flex: 0 0 var(--guide-width); + height: 100vh; + overflow-y: auto; + border-left: 1px solid var(--review-line); + background: var(--review-canvas); + color: var(--review-ink); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif; + font-size: 0.9rem; +} + +.guide.collapsed { + flex-basis: 72px; + display: grid; + align-content: start; + justify-items: center; + gap: 12px; + padding: 12px 0; +} + +button.action { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 5px 16px; + border: 1px solid rgb(31 35 40 / 15%); + border-radius: 6px; + background: var(--good-strong); + color: #fff; + font: inherit; + font-weight: 600; + line-height: 20px; + cursor: pointer; +} + +button.action:hover:not(:disabled) { + background: var(--good-dark); +} + +button.action:disabled { + border-color: var(--review-line); + background: #94d3a2; + cursor: not-allowed; +} + +button.action.secondary { + background: var(--review-canvas); + color: var(--review-ink); +} + +button.action.secondary:hover:not(:disabled) { + background: #eef1f4; +} + +.spinner { + display: inline-block; + width: 12px; + height: 12px; + margin-right: 8px; + border: 2px solid rgb(255 255 255 / 40%); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.banner .spinner { + border-color: rgb(154 103 0 / 25%); + border-top-color: var(--warn); + vertical-align: -2px; +} + +.guide-header { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 16px 20px 12px; + border-bottom: 1px solid var(--review-line); + background: var(--review-panel); +} + +.guide-eyebrow { + margin: 0 0 4px; + color: var(--review-muted); + font-size: 0.75rem; + font-weight: 600; +} + +.guide-title { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + font-size: 1.35rem; + font-weight: 600; +} + +.guide-title::after { + content: "Open"; + padding: 2px 10px; + border-radius: 999px; + background: var(--good-strong); + color: #fff; + font-size: 0.75rem; + font-weight: 600; +} + +.guide-subtitle { + margin: 6px 0 0; + color: var(--review-muted); +} + +.guide-toggle { + flex: none; + align-self: start; + display: grid; + justify-items: center; + gap: 8px; + min-width: 32px; + padding: 4px 8px; + border: 1px solid var(--review-line); + border-radius: 6px; + background: var(--review-canvas); + color: var(--review-ink); + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.guide-toggle:hover { + background: #eef1f4; +} + +.guide-rail-label { + color: var(--review-muted); + font-size: 0.72rem; + font-weight: 600; + writing-mode: vertical-rl; +} + +.guide-rail { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.guide-rail-step { + display: grid; + justify-items: center; + gap: 2px; + width: 48px; + padding: 6px 0; + border: 1px solid var(--review-line); + border-radius: 6px; + background: var(--review-panel); + color: var(--review-ink); + font: inherit; + cursor: pointer; +} + +.guide-rail-number { + font-weight: 600; +} + +.guide-progress { + display: grid; + gap: 8px; + padding: 12px 20px; + border-bottom: 1px solid var(--review-line); + background: var(--review-panel); +} + +.progress-meter { + display: flex; + gap: 2px; + overflow: hidden; + border-radius: 6px; +} + +.progress-segment { + flex: 1; + height: 8px; + background: var(--review-line); +} + +.progress-segment.done { + background: var(--good-strong); +} + +.guide-progress-row { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.guide-progress-count { + margin: 0; + color: var(--review-muted); +} + +.guide-progress-count strong { + color: var(--review-ink); +} + +.guide-tabs { + display: grid; + margin: 16px 20px 0; + overflow: hidden; + border: 1px solid var(--review-line); + border-radius: 6px; + background: var(--review-panel); +} + +.guide-tab { + display: grid; + grid-template-columns: 24px 1fr auto; + gap: 10px; + align-items: center; + padding: 8px 12px; + border: 0; + border-top: 1px solid var(--review-line); + background: transparent; + color: var(--review-ink); + font: inherit; + text-align: left; + cursor: pointer; +} + +.guide-tab:first-child { + border-top: 0; +} + +.guide-tab:hover { + background: var(--review-canvas); +} + +.guide-tab.selected { + box-shadow: inset 3px 0 0 var(--hn-orange); + background: #fff8f2; + font-weight: 600; +} + +.guide-tab-number { + display: grid; + place-items: center; + width: 22px; + height: 22px; + border-radius: 50%; + background: #eef1f4; + color: var(--review-muted); + font-size: 0.78rem; + font-weight: 600; +} + +.guide-tab.selected .guide-tab-number { + background: var(--hn-orange); + color: #fff; +} + +.guide-body { + display: grid; + gap: 12px; + padding: 16px 20px 40px; +} + +.guide-section { + padding: 14px 16px; + border: 1px solid var(--review-line); + border-radius: 6px; + background: var(--review-panel); +} + +.guide-section h3, +.guide-section h4 { + margin: -14px -16px 12px; + padding: 8px 16px; + border-bottom: 1px solid var(--review-line); + border-radius: 6px 6px 0 0; + background: var(--review-canvas); + font-size: 0.88rem; + font-weight: 600; +} + +.guide-section p { + margin: 0 0 10px; + line-height: 1.5; +} + +.guide-section p:last-child { + margin-bottom: 0; +} + +.guide-note { + color: var(--review-muted); + font-size: 0.85rem; +} + +.step-header { + display: grid; + gap: 10px; +} + +.step-eyebrow { + margin: 0; + color: var(--review-muted); + font-size: 0.75rem; + font-weight: 600; +} + +.step-title { + margin: 2px 0 4px; + font-size: 1.25rem; + font-weight: 600; +} + +.step-members { + padding: 2px 6px; + border-radius: 6px; + background: #eef1f4; + color: var(--review-ink); + overflow-wrap: anywhere; +} + +.step-actions { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.step-buttons { + display: flex; + gap: 8px; +} + +.how-it-works, +.task-list { + margin: 0 0 12px; + padding-left: 20px; + line-height: 1.5; +} + +.how-it-works li + li, +.task-list li + li { + margin-top: 6px; +} + +.how-it-works { + margin-bottom: 0; +} + +.facts { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 12px; + margin: 0 0 12px; +} + +.facts dt { + color: var(--review-muted); + font-weight: 600; +} + +.facts dd { + margin: 0; + overflow-wrap: anywhere; +} + +.see-it { + padding: 8px 12px; + border-left: 3px solid var(--hn-orange); + border-radius: 6px; + background: #fff8f2; +} + +.ai-note { + padding: 8px 12px; + border-left: 3px solid var(--review-purple); + border-radius: 6px; + background: #fbefff; + color: #512a97; +} + +.clues { + padding: 8px 12px; + border: 1px solid var(--review-line); + border-radius: 6px; + background: var(--review-canvas); +} + +.clues summary, +.guide-log summary { + font-weight: 600; + cursor: pointer; +} + +.clues ul { + margin: 10px 0 0; + padding-left: 20px; + line-height: 1.5; +} + +.guide-log-count { + margin-left: 6px; + color: var(--review-muted); + font-weight: 400; +} + +/* Step results, shown the way a pull request shows its checks */ + +.badge { + display: inline-block; + padding: 1px 8px; + border: 1px solid var(--review-line); + border-radius: 999px; + background: var(--review-panel); + color: var(--review-muted); + font-size: 0.75rem; + font-weight: 600; + white-space: nowrap; +} + +.badge-compact { + padding: 0; + border: 0; + background: transparent; +} + +.badge-passed { + border-color: rgb(31 136 61 / 40%); + background: #dafbe1; + color: var(--good); +} + +.badge-failed { + border-color: rgb(207 34 46 / 40%); + background: #ffebe9; + color: var(--bad); +} + +.badge-running { + border-color: rgb(154 103 0 / 40%); + background: #fff8c5; + color: var(--warn); +} + +.badge-compact.badge-passed, +.badge-compact.badge-failed, +.badge-compact.badge-running { + background: transparent; +} + +.banner { + display: grid; + justify-items: start; + gap: 6px; + padding: 10px 14px; + border: 1px solid var(--review-line); + border-radius: 6px; + background: var(--review-panel); + line-height: 1.5; +} + +.banner-passed { + border-color: rgb(31 136 61 / 40%); + background: #dafbe1; + color: #116329; +} + +.banner-failed { + border-color: rgb(207 34 46 / 40%); + background: #ffebe9; + color: #a40e26; +} + +.banner-running { + border-color: rgb(154 103 0 / 40%); + background: #fff8c5; + color: #7d4e00; +} + +.banner-locked { + color: var(--review-muted); +} + +.banner-note { + font-size: 0.8rem; + opacity: 0.85; +} + +/* @key recreates the banner for every new result, so this animation plays again after each click */ +.banner-fresh { + animation: banner-in 0.35s ease-out; +} + +@keyframes banner-in { + from { + opacity: 0; + transform: translateY(-6px); + } + to { - transform: rotate(360deg); + opacity: 1; + transform: none; } } -@media (max-width: 720px) { - .news-page { - width: min(100% - 24px, 1120px); - padding-top: 18px; +.link-button { + padding: 0; + border: 0; + background: none; + color: var(--review-blue); + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.link-button:hover { + text-decoration: underline; +} + +.check-list { + display: grid; + margin: 0; + padding: 0; + overflow: hidden; + border: 1px solid var(--review-line); + border-radius: 6px; + list-style: none; +} + +.check { + display: grid; + grid-template-columns: 22px 1fr; + gap: 8px; + padding: 10px 12px; + border-top: 1px solid var(--review-line); +} + +.check:first-child { + border-top: 0; +} + +.check-body { + min-width: 0; +} + +.guide-section .check-description { + margin: 0 0 4px; + font-weight: 600; + line-height: 1.4; +} + +.check-values { + display: grid; + grid-template-columns: max-content 1fr; + gap: 2px 10px; + margin: 0; + color: var(--review-muted); + font-size: 0.82rem; +} + +.check-values dd { + margin: 0; + color: var(--review-ink); + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} + +/* A hint reads like a reviewer's comment on the line that failed */ +.guide-section .check-hint { + position: relative; + margin: 10px 0 0; + padding: 8px 12px; + border: 1px solid var(--review-line); + border-radius: 6px; + background: var(--review-canvas); + line-height: 1.5; +} + +.table-scroll { + margin-top: 10px; + overflow-x: auto; +} + +.step-log { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} + +.step-log th, +.step-log td { + padding: 6px 8px; + border-bottom: 1px solid var(--review-line); + text-align: left; + vertical-align: top; +} + +.step-log th { + color: var(--review-muted); +} + +.step-log td:nth-child(-n + 3) { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* Narrow screens stack the guide under the app */ + +@media (max-width: 1080px) { + :root { + --guide-width: 380px; } +} - .hero-panel { - grid-template-columns: 1fr; - padding: 22px; +@media (max-width: 860px) { + .workspace { + flex-direction: column; } - h1 { - font-size: 2.45rem; + .guide, + .guide.collapsed { + position: static; + flex-basis: auto; + height: auto; + border-top: 1px solid var(--review-line); + border-left: 0; } - .hero-actions { - min-width: 0; + .guide.collapsed { + grid-auto-flow: column; + justify-content: start; + padding: 12px 16px; } - .story-card { - grid-template-columns: 34px 1fr; - gap: 12px; - padding: 14px; + .guide.collapsed .guide-rail { + grid-auto-flow: column; } - .story-rank { - width: 34px; - height: 34px; + .guide-rail-label { + writing-mode: horizontal-tb; } -} \ No newline at end of file + + .app-pane { + padding: 0; + } + + .hn-count { + margin-left: 0; + } +} diff --git a/2. Correcting Common Async Await Mistakes/AsyncAwaitBestPractices.pptx b/2. Correcting Common Async Await Mistakes/AsyncAwaitBestPractices.pptx index 09b0c3e..18ba52c 100644 Binary files a/2. Correcting Common Async Await Mistakes/AsyncAwaitBestPractices.pptx and b/2. Correcting Common Async Await Mistakes/AsyncAwaitBestPractices.pptx differ diff --git a/2. Correcting Common Async Await Mistakes/README.md b/2. Correcting Common Async Await Mistakes/README.md index 42fafda..43a99c5 100644 --- a/2. Correcting Common Async Await Mistakes/README.md +++ b/2. Correcting Common Async Await Mistakes/README.md @@ -9,14 +9,24 @@ The **1. Start** folder contains the intentionally imperfect code you will edit. 1. Using File Explorer on Windows or Finder on macOS, navigate to **BecomeAnExpertWithAsyncAwait/2. Correcting Common Async Await Mistakes/1. Start**. 2. Open **HackerNews.slnx** in your IDE. 3. Build the project once so you can see the starting warnings and confirm your environment is ready. +4. Run the app and open it in your browser. +The starter app runs at [http://localhost:5001](http://localhost:5001). The finished app runs at [http://localhost:5002](http://localhost:5002), so you can run both at the same time and compare them later. + +The app opens on the **Top stories** page, with the **workshop guide** docked beside it. Every step in the guide is one async/await mistake in the Top stories page, and every time the app starts it checks your code against them. Right now the guide says it stopped at Step 1, and the terminal running the app shows the same result, with a hint. + +The steps render your News page against an in-memory Hacker News, so they work offline and on networks that block Hacker News. The **Top stories** page itself still loads the real Hacker News API. ## 2. Inspect the Starting Code 1. Open **HackerNews/Components/Pages/News.razor.cs**. -2. Find each `// ToDo Refactor` comment. +2. Find each `// ToDo Refactor (Step N)` comment. The step number tells you which step checks that line. 3. Read `OnInitialized()`, `RefreshAsync(CancellationToken)`, `GetTopStories(...)`, `GetStory(...)`, and `GetTopStoryIDs(...)` before changing anything. 4. Notice which code runs during Blazor component initialization and which code updates component state. +5. Open the **HackerNews/Steps** folder. Each `Step*.cs` file renders your News page the way Blazor does for a browser tab, with comments that explain what each expected result checks and why. These files are your guide. +6. Skim **HackerNews/Verification** and **HackerNews/Components/Layout/WorkshopGuide.razor**. They are the workshop plumbing that renders your page against an in-memory Hacker News, runs the steps, and draws the guide. You do not need to change them. + +Now reload **Top stories** and watch the count of loaded stories in the orange bar: it sits at 0 behind the loading skeleton, then jumps straight to 50. Press **Refresh**, then reload the browser tab before the refresh finishes. The page is gone, but its refresh keeps running on the server. Pay attention to these clues: @@ -28,35 +38,44 @@ Pay attention to these clues: 6. Some methods create async state machines even though they only wrap another async call. 7. UI state changes must be marshaled through Blazor's renderer when continuations run away from the captured context. +The app walks you through the challenge one step at a time: + +1. Step 1 loads the stories as part of initialization, Step 2 keeps Blazor's renderer free, Step 3 stops refresh work when the page goes away, Step 4 streams stories as they arrive, and Step 5 skips the state machines you don't need. +2. A step unlocks only after the step before it passes. Select a step in the guide to read the story behind the mistake, how to see it on the Top stories page, which file to change, and a task list for that step. If you get stuck, it has clues. +3. Each step renders your real News page in memory against an in-memory Hacker News whose delays the step controls, and shows a checklist of every expected result next to what actually happened. Every result that does not match comes with a hint, and the same hint is printed in the terminal running the app. +4. Every time the app starts, it checks your code against every step, so the guide always reflects the code you have now. +5. Stop and run the app again after each change. If your IDE applied the change with Hot Reload, click **Run every step** at the top of the guide instead. + ## 3. Challenge: Refactor the Refresh Flow Recommended time: 35 minutes. > **Note:** Please avoid letting AI Agents solve the challenges for you. You're smart. You got this. Use them to understand the existing code, clarify async/await concepts, interpret errors, and ask questions that help you decide what to change. The goal is to practice the reasoning yourself. -Refactor **News.razor.cs** so the refresh pipeline follows the async/await and Blazor practices covered in this workshop. +Refactor **News.razor.cs** so the refresh pipeline follows the async/await and Blazor practices covered in this workshop. Leave **News.razor**, the **Services** folder, and **Program.cs** alone. -Requirements: +Requirements, in the order the steps check them: 1. Do not leave an unobserved task in component initialization. 2. Avoid `async void` unless the framework API truly requires it. 3. Use safe fire-and-forget only when the caller cannot return `Task`. -4. Forward the supplied `CancellationToken` to cancellable async APIs. +4. Replace blocking waits with `await`. 5. Use `ConfigureAwait(false)` only where the continuation does not need the captured context. 6. Marshal UI updates through `InvokeAsync(...)` when the continuation may not be on Blazor's renderer context. -7. Replace blocking waits with `await`. -8. Stream stories with `IAsyncEnumerable`. -9. Return `Task` directly from methods that only wrap another task. -10. Use `ValueTask` only where the hot path can complete synchronously. -11. Log full exceptions server-side and show users a generic actionable refresh error. +7. Forward the supplied `CancellationToken` to cancellable async APIs. +8. Log full exceptions server-side and show users a generic actionable refresh error. +9. Stream stories with `IAsyncEnumerable`. +10. Return `Task` directly from methods that only wrap another task. +11. Use `ValueTask` only where the hot path can complete synchronously. Acceptance checks: 1. **HackerNews.slnx** builds. -2. The app can refresh stories in the browser. -3. The refresh indicator stops after the minimum refresh delay. -4. Navigating away or disposing the component does not keep unnecessary refresh work alive. -5. Your code is ready to compare with **2. Finish/HackerNews**. +2. The workshop guide shows 5 of 5 steps pass after the app starts. +3. The Top stories page can refresh stories in the browser, and they appear as they arrive. +4. The refresh indicator stops after the minimum refresh delay. +5. Navigating away or disposing the component does not keep unnecessary refresh work alive. +6. Your code is ready to compare with **2. Finish/HackerNews**. ## 4. Review the Solution diff --git a/2. Correcting Common Async Await Mistakes/SOLUTION.md b/2. Correcting Common Async Await Mistakes/SOLUTION.md index 2f5f05a..f0a0355 100644 --- a/2. Correcting Common Async Await Mistakes/SOLUTION.md +++ b/2. Correcting Common Async Await Mistakes/SOLUTION.md @@ -2,9 +2,11 @@ Use this during the guided walkthrough after the challenge and group review in [README.md](README.md). -The completed project is **2. Finish/HackerNews**. Compare your decisions with the finished Blazor sample as we rebuild the solution step by step. +The completed project is **2. Finish/HackerNews**. Compare your decisions with the finished Blazor sample as we rebuild the solution step by step. Each section below names the workshop step it makes pass, so you can follow along in the workshop guide. -## 1. Prefer Blazor Lifecycle Tasks +**1. Start** and **2. Finish** share the same workshop plumbing: the `Steps` and `Verification` folders, and the layout with its workshop guide. The only file you change is `Components/Pages/News.razor.cs`. + +## 1. Prefer Blazor Lifecycle Tasks (Step 1) The starter project begins refresh work from `OnInitialized()` without observing the returned task: @@ -13,14 +15,15 @@ protected override void OnInitialized() { IsListRefreshing = true; - //ToDo Refactor + // ToDo Refactor (Step 1): this starts the refresh and forgets it. OnInitialized() returns straight away, + // so Blazor treats the page as initialized while the stories are still loading, and nothing observes the task. RefreshAsync(_disposeCancellationTokenSource.Token); } ``` -Using `async void` can appear to solve the compiler warning, but it creates a different problem: callers cannot await it, exceptions cannot be observed through a returned `Task`, and Blazor cannot track the asynchronous lifecycle work. Another option, when async/await is not available in synchronous code paths, an extension method like [`.SafeFireAndForget()`](https://www.nuget.org/packages/AsyncAwaitBestPractices/) can also be used. +Using `async void` can appear to solve the compiler warning, but it creates a different problem: callers cannot await it, exceptions cannot be observed through a returned `Task`, and Blazor cannot track the asynchronous lifecycle work. When async/await is not available in a synchronous code path, another option is an extension method like [`.SafeFireAndForget()`](https://www.nuget.org/packages/AsyncAwaitBestPractices/). -However, since the Blazor offers an asynchronous version of `OnInitializedAsync()`, let's use that: +However, Blazor offers `OnInitializedAsync()`, the asynchronous version of `OnInitialized()`, so let's use that: ```cs protected override async Task OnInitializedAsync() @@ -32,17 +35,9 @@ protected override async Task OnInitializedAsync() [Safe fire-and-forget](https://www.nuget.org/packages/AsyncAwaitBestPractices/) still has a place when an API truly cannot return `Task`, but Blazor lifecycle methods already have `Task`-returning alternatives. -## 2. Forward Cancellation Tokens - -When an async method receives a `CancellationToken`, pass it to async APIs that support cancellation: - -```cs -var minimumRefreshTimeTask = Task.Delay(TimeSpan.FromSeconds(2), token); -``` - -The component cancels `_disposeCancellationTokenSource` when it is disposed. Forwarding that token lets refresh work stop when the user navigates away. +Step 1 renders the page the way prerendering does and waits for Blazor to finish initializing it. The starter finishes initializing with 0 of 50 stories loaded; with `OnInitializedAsync()`, initialization ends after the last story arrives. The step also reads the compiled page for `async void` methods, including async lambdas, and refuses to render the page while one exists, because an exception that escapes one can end the whole app. -## 3. Use ConfigureAwait Intentionally +## 2. Use ConfigureAwait Intentionally (Step 2) Use `ConfigureAwait(false)` when the continuation does not need the captured context: @@ -56,7 +51,9 @@ After `ConfigureAwait(false)`, do not update component state directly. Use `Invo await InvokeAsync(TopStoryCollection.Clear); ``` -## 4. Replace Blocking Waits +In the starter, the `await GetTopStories(...)` in `RefreshAsync(CancellationToken)` captures Blazor's context, so everything after it, including the `finally` block, runs on the renderer for that browser tab. Steps 1, 2, and 4 also fail when a refresh against the in-memory Hacker News logs an error, and it names the `InvalidOperationException` Blazor throws when `StateHasChanged()` runs off the renderer. + +## 3. Replace Blocking Waits (Step 2) Do not use `.Wait()` or `.Result` inside async code. Await the task instead: @@ -66,6 +63,28 @@ await minimumRefreshTimeTask.ConfigureAwait(ConfigureAwaitOptions.None | Configu Blocking waits can cause thread starvation, deadlocks, and poor responsiveness. +In the starter, `minimumRefreshTimeTask.Wait()` runs on Blazor's renderer, so nothing else for that browser tab can run until the 2 second delay ends. Step 2 presses Refresh and posts a tiny piece of work to the renderer every 25 milliseconds: a refresh that still calls `Wait()` makes one of them wait almost 2 seconds, and the finished page picks each one up within a millisecond or two. Timing alone would miss a `.Wait()` that `ConfigureAwait(false)` has moved onto a thread pool thread, where it still blocks a thread for every refresh, so the step also reads the compiled page for `Wait()`, `Result` and `GetAwaiter().GetResult()`. It checks that the refresh indicator still stays up for the full 2 second minimum, so deleting the wait does not pass either. + +`ConfigureAwaitOptions.None` is the equivalent of `ConfigureAwait(false)`. The `SuppressThrowing` half of that line matters once the delay can be canceled, in the next section. + +## 4. Forward Cancellation Tokens (Step 3) + +When an async method receives a `CancellationToken`, pass it to async APIs that support cancellation: + +```cs +var minimumRefreshTimeTask = Task.Delay(TimeSpan.FromSeconds(2), token); +``` + +The component cancels `_disposeCancellationTokenSource` when it is disposed. Forwarding that token lets refresh work stop when the user navigates away. + +Step 3 presses Refresh, then closes the page while stories are still loading. The starter keeps its refresh alive for the rest of its 2 second minimum; with the token forwarded, the refresh ends within milliseconds and asks Hacker News for nothing more. + +Once the delay can be canceled, awaiting it throws when the page goes away, and that `await` is in a `finally` block: + +```cs +await minimumRefreshTimeTask.ConfigureAwait(ConfigureAwaitOptions.None | ConfigureAwaitOptions.SuppressThrowing); +``` + `ConfigureAwaitOptions` is the .NET 8 overload of `ConfigureAwait` for when you need more than a context decision: - `ConfigureAwaitOptions.None` is the equivalent of `ConfigureAwait(false)`: the continuation does not capture the current context. @@ -75,7 +94,19 @@ The delay is canceled when the component is disposed, so `SuppressThrowing` repl `SuppressThrowing` is only supported on the non-generic `Task`. Using it on a `Task` throws `ArgumentOutOfRangeException`, because there would be no result to return. -## 5. Stream Stories +Step 3 fails when any exception escapes the refresh after the page closes. The other cancellable calls in the refresh are handled by catching `OperationCanceledException` only when the refresh's own token was canceled: + +```cs +catch (OperationCanceledException e) when (token.IsCancellationRequested) +{ + Logger.LogError(e, "Refresh timed out."); + await InvokeAsync(() => RefreshErrorMessage = "Unable to refresh top stories. Check your connection and try again."); +} +``` + +The step also loads the page while every Hacker News call throws `HttpRequestException`. The page has to show a generic, actionable message with no exception text in it, log the full exception on the server, and turn the refresh indicator off. The starter already does all three, and the step makes sure your refactor keeps it that way. + +## 5. Stream Stories (Step 4) The completed refresh pipeline fetches top story IDs, clears the UI through Blazor's renderer, and then streams stories into the list: @@ -149,7 +180,9 @@ async IAsyncEnumerable GetTopStories(IReadOnlyList topStoryIds } ``` -## 6. Return Task Directly +Step 4 holds back the 50th story and looks at the rendered page once every other story has arrived. The starter still shows the loading skeleton; the streaming page already shows 49 stories. When the last story arrives, the step checks that the list has all 50 stories, sorted by score, with none twice. + +## 6. Return Task Directly (Step 5) When a method only forwards another task, return that task directly: @@ -159,7 +192,7 @@ Task GetStory(long storyId, CancellationToken token) => HackerNewsAp This avoids an unnecessary async state machine. -## 7. Use ValueTask for the Synchronous Hot Path +## 7. Use ValueTask for the Synchronous Hot Path (Step 5) `GetTopStoryIDs(CancellationToken)` can return cached IDs synchronously when the current data is recent: @@ -185,7 +218,11 @@ async ValueTask> GetTopStoryIDs(CancellationToken token) Use `ValueTask` when the synchronous path is common enough to avoid allocating a `Task` for that path. -## 8. Compare Against Finish +Step 5 reads the compiled page: `GetStory(...)` must return `Task` without an async state machine, and `GetTopStoryIDs(...)` must return `ValueTask>`. It then loads the page and calls `GetTopStoryIDs(...)` the way a second refresh does, and checks that it completed synchronously without calling Hacker News. + +## 8. Run It and Compare Against Finish + +Run the finished app on [http://localhost:5002](http://localhost:5002). The workshop guide shows 5 of 5 steps pass once the checks that run after the app starts have finished. On **Top stories** beside it, press **Refresh**, and watch the stories stream into place. Compare your implementation with the completed file: diff --git a/3. .NET Internals/1. Thread Static/ThreadStaticExample.slnx b/3. .NET Internals/1. Thread Static/ThreadStaticExample.slnx deleted file mode 100644 index d18ecd8..0000000 --- a/3. .NET Internals/1. Thread Static/ThreadStaticExample.slnx +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/3. .NET Internals/1. Thread Static/ThreadStaticExample/Program.cs b/3. .NET Internals/1. Thread Static/ThreadStaticExample/Program.cs deleted file mode 100644 index 5db388c..0000000 --- a/3. .NET Internals/1. Thread Static/ThreadStaticExample/Program.cs +++ /dev/null @@ -1,41 +0,0 @@ -static class Program -{ - // Apply ThreadStatic attribute to make this variable thread-local. - [ThreadStatic] - static int _threadSpecificValue; - - static void Main(string[] args) - { - // Initializing thread-specific value for the main thread - _threadSpecificValue = 100; - - // Output from the main thread - Console.WriteLine($"Main thread - threadSpecificValue: {_threadSpecificValue}"); - - // Create two new threads - Thread thread1 = new Thread(ThreadMethod); - Thread thread2 = new Thread(ThreadMethod); - - // Start the threads - thread1.Start(); - thread2.Start(); - - // Wait for threads to finish - thread1.Join(); - thread2.Join(); - - // Output from the main thread after the other threads have finished - Console.WriteLine($"Main thread after threads finished - threadSpecificValue: {_threadSpecificValue}"); - } - - // Method to be run by each thread - static void ThreadMethod() - { - // Initialize thread-specific value for this thread - _threadSpecificValue = Random.Shared.Next(1, 100); - - // Output from each thread - Console.WriteLine( - $"Thread {Environment.CurrentManagedThreadId} {nameof(_threadSpecificValue)}: {_threadSpecificValue}"); - } -} \ No newline at end of file diff --git a/3. .NET Internals/1. Thread Static/ThreadStaticExample/ThreadStaticExample.csproj b/3. .NET Internals/1. Thread Static/ThreadStaticExample/ThreadStaticExample.csproj deleted file mode 100644 index c2bab22..0000000 --- a/3. .NET Internals/1. Thread Static/ThreadStaticExample/ThreadStaticExample.csproj +++ /dev/null @@ -1,8 +0,0 @@ - - - - Exe - $(NetVersion) - - - diff --git a/3. .NET Internals/2. ExecutionContext/ExecutionContextExample.slnx b/3. .NET Internals/2. ExecutionContext/ExecutionContextExample.slnx deleted file mode 100644 index 97f1e99..0000000 --- a/3. .NET Internals/2. ExecutionContext/ExecutionContextExample.slnx +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/3. .NET Internals/2. ExecutionContext/ExecutionContextExample/ExecutionContextExample.csproj b/3. .NET Internals/2. ExecutionContext/ExecutionContextExample/ExecutionContextExample.csproj deleted file mode 100644 index c2bab22..0000000 --- a/3. .NET Internals/2. ExecutionContext/ExecutionContextExample/ExecutionContextExample.csproj +++ /dev/null @@ -1,8 +0,0 @@ - - - - Exe - $(NetVersion) - - - diff --git a/3. .NET Internals/2. ExecutionContext/ExecutionContextExample/Program.cs b/3. .NET Internals/2. ExecutionContext/ExecutionContextExample/Program.cs deleted file mode 100644 index a61bcf2..0000000 --- a/3. .NET Internals/2. ExecutionContext/ExecutionContextExample/Program.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System.Globalization; -using System.Security.Claims; -using System.Security.Principal; - -namespace ExecutionContextExample; - -public static class Program -{ - static readonly AsyncLocal _asyncLocalData = new(); - - public static async Task Main() - { - Console.WriteLine("Main thread starts"); - - // Assign data controlled by ExecutionContext - CultureInfo.CurrentCulture = new CultureInfo("es-ES"); - Thread.CurrentPrincipal = new ClaimsPrincipal(); - _asyncLocalData.Value = "Initial Value"; - - PrintThreadValues(); - - var mainThreadExecutionContext = ExecutionContext.Capture() ?? throw new InvalidOperationException("ExecutionContext only null when suppressed"); - - var thread = new Thread(() => - { - CultureInfo.CurrentCulture = new CultureInfo("en-UK"); - Thread.CurrentPrincipal = new CustomPrincipal(); - _asyncLocalData.Value = "AsyncLocalData in Thread"; - - Console.WriteLine("Background Thread after assigning values"); - PrintThreadValues(); - - ExecutionContext.Run(mainThreadExecutionContext, _ => - { - Console.WriteLine("Same Background Thread, but using MainThread's ExecutionContext"); - PrintThreadValues(); - }, null); - }); - - // Execute Thread - thread.Start(); - - // Wait for Thread to Complete - thread.Join(); - - // Print Main Thread values again - Console.WriteLine("Main Thread Values"); - PrintThreadValues(); - - await Task.Run(() => - { - Console.WriteLine("Print Values from Task.Run()"); - PrintThreadValues(); - }); - - Task suppressedExecutionContextTask; - using (ExecutionContext.SuppressFlow()) - { - suppressedExecutionContextTask = Task.Run(() => - { - Console.WriteLine("Print Values from Task.Run() With Execution Context Suppressed"); - PrintThreadValues(); - }); - } - - await suppressedExecutionContextTask; - } - - static void PrintThreadValues() - { - Console.WriteLine($"Thread ID: {Environment.CurrentManagedThreadId}"); - Console.WriteLine($"Culture: {CultureInfo.CurrentCulture.DisplayName}"); - Console.WriteLine($"Principal: {Thread.CurrentPrincipal?.GetType()}"); - Console.WriteLine($"AsyncLocalData: {_asyncLocalData.Value}"); - - Console.WriteLine(); - } -} - -sealed class CustomPrincipal() : GenericPrincipal(new CustomIdentity(), null) -{ - sealed class CustomIdentity : IIdentity - { - public string AuthenticationType => "Fake"; - public bool IsAuthenticated => true; - public string Name => nameof(CustomIdentity); - } -} \ No newline at end of file diff --git a/3. .NET Internals/3. Principal/PrincipalExample.slnx b/3. .NET Internals/3. Principal/PrincipalExample.slnx deleted file mode 100644 index ffa2c56..0000000 --- a/3. .NET Internals/3. Principal/PrincipalExample.slnx +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/3. .NET Internals/3. Principal/PrincipalExample/Controllers/HomeController.cs b/3. .NET Internals/3. Principal/PrincipalExample/Controllers/HomeController.cs deleted file mode 100644 index 5ca9140..0000000 --- a/3. .NET Internals/3. Principal/PrincipalExample/Controllers/HomeController.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using PrincipalExample.Models; - -namespace PrincipalExample.Controllers; - -public class HomeController(IHttpContextAccessor httpContextAccessor) : Controller -{ - public IActionResult Index() => View(); - - [Authorize] - public async Task RunExperiment() - { - var checkpoints = new List(); - var signedInUser = HttpContext.User; - - checkpoints.Add(Observe("1. Start of the action")); - - Thread.CurrentPrincipal = signedInUser; - - // Yields the current thread: the rest of this method runs later as a continuation on the thread pool - await Task.Yield(); - - checkpoints.Add(Observe("2. After await Task.Yield()")); - - checkpoints.Add(await Task.Run(() => Observe("3. Inside Task.Run(...)"))); - - Task suppressedFlowTask; - using (ExecutionContext.SuppressFlow()) - { - suppressedFlowTask = Task.Run(() => Observe("4. Inside Task.Run(...) started while ExecutionContext flow is suppressed")); - } - - checkpoints.Add(await suppressedFlowTask); - - return View(nameof(Index), checkpoints); - - // Asks for the signed-in user's name four different ways, from whichever thread is running this code - Checkpoint Observe(string checkpoint) => new Checkpoint( - checkpoint, - Environment.CurrentManagedThreadId, - Thread.CurrentPrincipal?.Identity?.Name, - httpContextAccessor.HttpContext?.User.Identity?.Name, - HttpContext.User.Identity?.Name, - signedInUser.Identity?.Name); - } -} \ No newline at end of file diff --git a/3. .NET Internals/3. Principal/PrincipalExample/PrincipalExample.csproj b/3. .NET Internals/3. Principal/PrincipalExample/PrincipalExample.csproj deleted file mode 100644 index f196967..0000000 --- a/3. .NET Internals/3. Principal/PrincipalExample/PrincipalExample.csproj +++ /dev/null @@ -1,8 +0,0 @@ - - - - Exe - $(NetVersion) - - - diff --git a/3. .NET Internals/3. Principal/PrincipalExample/Program.cs b/3. .NET Internals/3. Principal/PrincipalExample/Program.cs deleted file mode 100644 index 4967c1a..0000000 --- a/3. .NET Internals/3. Principal/PrincipalExample/Program.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Microsoft.AspNetCore.Authentication.Cookies; - -var builder = WebApplication.CreateBuilder(args); - -builder.Services.AddControllersWithViews(); -builder.Services.AddHttpContextAccessor(); -builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) - .AddCookie(static options => options.LoginPath = "/"); - -var app = builder.Build(); - -// Reads the sign-in cookie on every request and assigns the signed-in user to HttpContext.User -app.UseAuthentication(); -app.UseAuthorization(); - -app.MapDefaultControllerRoute(); - -app.Run(); \ No newline at end of file diff --git a/3. .NET Internals/3. Principal/PrincipalExample/Properties/launchSettings.json b/3. .NET Internals/3. Principal/PrincipalExample/Properties/launchSettings.json deleted file mode 100644 index 4d7f84e..0000000 --- a/3. .NET Internals/3. Principal/PrincipalExample/Properties/launchSettings.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "profiles": { - "PrincipalExample": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:5000", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/3. .NET Internals/3. Principal/PrincipalExample/Views/Home/Index.cshtml b/3. .NET Internals/3. Principal/PrincipalExample/Views/Home/Index.cshtml deleted file mode 100644 index c29e5b3..0000000 --- a/3. .NET Internals/3. Principal/PrincipalExample/Views/Home/Index.cshtml +++ /dev/null @@ -1,111 +0,0 @@ -@model IReadOnlyList? -@{ - var isSignedIn = User.Identity?.IsAuthenticated is true; -} - - - - - - Principal Challenge - - - -
-

Who is the signed-in user?

-

3. .NET Internals · Principal Challenge

- -
-
-

Step 1: Sign in

- @if (isSignedIn) - { -

You are signed in as @User.Identity?.Name

-
- -
- } - else - { -

There is no password. The app stores the name you type in a sign-in cookie.

-
- - -
- } -
- -
-

Step 2: Run the experiment

- @if (!isSignedIn) - { -

Sign in first.

- } - else if (Model is null) - { -

HomeController.RunExperiment() asks for your name four different ways at four checkpoints. Write down your predictions before you click.

- Run the experiment - } - else - { -

Compare the results below with your predictions, then select Run it again.

- Run it again - } -
-
- - @if (Model is not null) - { -
-

Results

-

Each cell shows the user name that expression returned at that checkpoint, or null.

-
- - - - - - - - - - - - - @foreach (var checkpoint in Model) - { - - - - - - - - - } - -
CheckpointThreadThread.CurrentPrincipalstatic propertyhttpContextAccessor.HttpContext?.Userinjected IHttpContextAccessorHttpContext.Usercontroller propertysignedInUserlocal variable
@checkpoint.Name@checkpoint.ThreadId@(checkpoint.ThreadCurrentPrincipal ?? "null")@(checkpoint.HttpContextAccessorUser ?? "null")@(checkpoint.ControllerHttpContextUser ?? "null")@(checkpoint.SignedInUserVariable ?? "null")
-
-
- } -
- - \ No newline at end of file diff --git a/3. .NET Internals/3. Principal/PrincipalExample/Views/_ViewImports.cshtml b/3. .NET Internals/3. Principal/PrincipalExample/Views/_ViewImports.cshtml deleted file mode 100644 index 04ea02c..0000000 --- a/3. .NET Internals/3. Principal/PrincipalExample/Views/_ViewImports.cshtml +++ /dev/null @@ -1,3 +0,0 @@ -@using PrincipalExample -@using PrincipalExample.Models -@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers \ No newline at end of file diff --git a/3. .NET Internals/4. SynchronizationContext/HackerNews.slnx b/3. .NET Internals/4. SynchronizationContext/HackerNews.slnx deleted file mode 100644 index 0b8676d..0000000 --- a/3. .NET Internals/4. SynchronizationContext/HackerNews.slnx +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/3. .NET Internals/4. SynchronizationContext/HackerNews/Components/Layout/MainLayout.razor b/3. .NET Internals/4. SynchronizationContext/HackerNews/Components/Layout/MainLayout.razor deleted file mode 100644 index e5f4720..0000000 --- a/3. .NET Internals/4. SynchronizationContext/HackerNews/Components/Layout/MainLayout.razor +++ /dev/null @@ -1,5 +0,0 @@ -@inherits LayoutComponentBase - -
- @Body -
\ No newline at end of file diff --git a/3. .NET Internals/4. SynchronizationContext/HackerNews/Components/Pages/Error.razor b/3. .NET Internals/4. SynchronizationContext/HackerNews/Components/Pages/Error.razor deleted file mode 100644 index 1a103ca..0000000 --- a/3. .NET Internals/4. SynchronizationContext/HackerNews/Components/Pages/Error.razor +++ /dev/null @@ -1,8 +0,0 @@ -@page "/Error" - -Refresh Failed - -
-

Refresh failed

-

The app hit an unexpected error while loading Hacker News.

-
\ No newline at end of file diff --git a/3. .NET Internals/4. SynchronizationContext/HackerNews/Components/Pages/News.razor b/3. .NET Internals/4. SynchronizationContext/HackerNews/Components/Pages/News.razor deleted file mode 100644 index 01d0c79..0000000 --- a/3. .NET Internals/4. SynchronizationContext/HackerNews/Components/Pages/News.razor +++ /dev/null @@ -1,75 +0,0 @@ -@page "/" -@inherits NewsPageBase - -Top Stories - -
-
-
-

Hacker News

-

Top stories

-

Fresh links from the front page, ranked by score and ready to scan.

-
- -
-
- @TopStoryCollection.Count - of @StoriesConstants.NumberOfStories stories loaded -
- - -
-
- - @if (RefreshErrorMessage is not null) - { - - } - - @if (TopStoryCollection.Count is 0 && IsListRefreshing) - { -
- @for (var index = 0; index < 8; index++) - { -
- @(index + 1) -
- - -
-
- } -
- } - else - { -
- @for (var index = 0; index < TopStoryCollection.Count; index++) - { - var story = TopStoryCollection[index]; - -
- @(index + 1) -
- @if (string.IsNullOrWhiteSpace(story.Url)) - { - @story.Title - } - else - { - @story.Title - } - - -
-
- } -
- } -
\ No newline at end of file diff --git a/3. .NET Internals/4. SynchronizationContext/HackerNews/Components/Pages/News.razor.cs b/3. .NET Internals/4. SynchronizationContext/HackerNews/Components/Pages/News.razor.cs deleted file mode 100644 index b69bb6b..0000000 --- a/3. .NET Internals/4. SynchronizationContext/HackerNews/Components/Pages/News.razor.cs +++ /dev/null @@ -1,163 +0,0 @@ -using System.Diagnostics; -using System.Runtime.CompilerServices; -using Microsoft.AspNetCore.Components; - -namespace HackerNews.Components.Pages; - -public partial class NewsPageBase : ComponentBase, IDisposable -{ - readonly CancellationTokenSource _disposeCancellationTokenSource = new(); - - [Inject] - public required HackerNewsAPIService HackerNewsApiService { get; init; } - - [Inject] - public required ILogger Logger { get; init; } - - protected List TopStoryCollection { get; } = []; - protected bool IsListRefreshing { get; set; } - protected string? RefreshErrorMessage { get; set; } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected static void InsertIntoSortedList(List collection, Comparison comparison, T modelToInsert) - { - if (collection.Count is 0) - { - collection.Add(modelToInsert); - return; - } - - var index = 0; - foreach (var model in collection) - { - if (comparison(model, modelToInsert) >= 0) - { - collection.Insert(index, modelToInsert); - return; - } - - index++; - } - - collection.Insert(index, modelToInsert); - } - - protected override async Task OnInitializedAsync() - { - IsListRefreshing = true; - await RefreshAsync(_disposeCancellationTokenSource.Token); - } - - protected Task RefreshAsync() => RefreshAsync(_disposeCancellationTokenSource.Token); - - protected virtual void Dispose(bool disposing) - { - if (!disposing) - { - return; - } - - _disposeCancellationTokenSource.Cancel(); - _disposeCancellationTokenSource.Dispose(); - } - - async Task RefreshAsync(CancellationToken token) - { - var thread = Thread.CurrentThread; - var synchronizationContext = SynchronizationContext.Current; - Logger.LogInformation("Before ConfigureAwait(false) | Thread {ThreadId} | SynchronizationContext: {SynchronizationContext}", thread.ManagedThreadId, synchronizationContext?.GetType().Name ?? ""); - - IsListRefreshing = true; - RefreshErrorMessage = null; - await InvokeAsync(StateHasChanged); - - var minimumRefreshTimeTask = Task.Delay(TimeSpan.FromSeconds(2), token); - - try - { - var topStoryIds = await GetTopStoryIDs(token).ConfigureAwait(false); - - var threadAfterConfigureAwaitFalse = Thread.CurrentThread; - var synchronizationContextAfterConfigureAwaitFalse = SynchronizationContext.Current; - Logger.LogInformation("After ConfigureAwait(false) | Thread {ThreadId} | SynchronizationContext: {SynchronizationContext}", threadAfterConfigureAwaitFalse.ManagedThreadId, synchronizationContextAfterConfigureAwaitFalse?.GetType().Name ?? ""); - - await InvokeAsync(TopStoryCollection.Clear); - - await foreach (var story in GetTopStories(topStoryIds, StoriesConstants.NumberOfStories, token).ConfigureAwait(false)) - { - await InvokeAsync(() => - { - if (!TopStoryCollection.Any(x => x.Title.Equals(story.Title, StringComparison.Ordinal))) - { - InsertIntoSortedList(TopStoryCollection, (a, b) => b.Score.CompareTo(a.Score), story); - } - - StateHasChanged(); - }); - } - } - catch (OperationCanceledException e) when (token.IsCancellationRequested) - { - Logger.LogError(e, "Refresh timed out."); - await InvokeAsync(() => RefreshErrorMessage = "Unable to refresh top stories. Check your connection and try again."); - } - catch (Exception e) - { - Logger.LogError(e, "Failed to refresh Hacker News top stories."); - await InvokeAsync(() => RefreshErrorMessage = "Unable to refresh top stories. Check your connection and try again."); - } - finally - { - await minimumRefreshTimeTask.ConfigureAwait(ConfigureAwaitOptions.None | ConfigureAwaitOptions.SuppressThrowing); - await InvokeAsync(() => - { - IsListRefreshing = false; - if (!token.IsCancellationRequested) - StateHasChanged(); - }); - } - } - - async IAsyncEnumerable GetTopStories(IReadOnlyList topStoryIds, int storyCount, [EnumeratorCancellation] CancellationToken token) - { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(storyCount); - - var storyIds = topStoryIds.Take(storyCount).ToList(); - var getTopStoryTaskList = storyIds.Select(id => GetStory(id, token)).ToList(); - - foreach (var topStoryTask in getTopStoryTaskList) - { - token.ThrowIfCancellationRequested(); - - yield return await topStoryTask.ConfigureAwait(false); - } - } - - Task GetStory(long storyId, CancellationToken token) => HackerNewsApiService.GetStory(storyId, token); - - async ValueTask> GetTopStoryIDs(CancellationToken token) - { - if (IsDataRecent(TimeSpan.FromHours(1))) - { - return TopStoryCollection.Select(x => x.Id).ToList(); - } - - try - { - return await HackerNewsApiService.GetTopStoryIDs(token).ConfigureAwait(false); - } - catch (Exception e) - { - Trace.WriteLine(e.Message); - throw; - } - } - - bool IsDataRecent(TimeSpan timeSpan) => TopStoryCollection.Any() - && (DateTimeOffset.UtcNow - TopStoryCollection.Max(x => x.CreatedAt)) < timeSpan; -} \ No newline at end of file diff --git a/3. .NET Internals/4. SynchronizationContext/HackerNews/Components/Pages/NotFound.razor b/3. .NET Internals/4. SynchronizationContext/HackerNews/Components/Pages/NotFound.razor deleted file mode 100644 index e053a50..0000000 --- a/3. .NET Internals/4. SynchronizationContext/HackerNews/Components/Pages/NotFound.razor +++ /dev/null @@ -1,8 +0,0 @@ -@page "/not-found" - -Page Not Found - -
-

Page not found

-

The page you requested does not exist.

-
diff --git a/3. .NET Internals/4. SynchronizationContext/HackerNews/Components/_Imports.razor b/3. .NET Internals/4. SynchronizationContext/HackerNews/Components/_Imports.razor deleted file mode 100644 index 9899baa..0000000 --- a/3. .NET Internals/4. SynchronizationContext/HackerNews/Components/_Imports.razor +++ /dev/null @@ -1,6 +0,0 @@ -@using HackerNews -@using HackerNews.Components -@using HackerNews.Components.Layout -@using Microsoft.AspNetCore.Components.Routing -@using Microsoft.AspNetCore.Components.Web -@using static Microsoft.AspNetCore.Components.Web.RenderMode \ No newline at end of file diff --git a/3. .NET Internals/4. SynchronizationContext/HackerNews/Constants/StoriesConstants.cs b/3. .NET Internals/4. SynchronizationContext/HackerNews/Constants/StoriesConstants.cs deleted file mode 100644 index 8857d61..0000000 --- a/3. .NET Internals/4. SynchronizationContext/HackerNews/Constants/StoriesConstants.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace HackerNews; - -public static class StoriesConstants -{ - public const int NumberOfStories = 50; -} \ No newline at end of file diff --git a/3. .NET Internals/4. SynchronizationContext/HackerNews/HackerNews.csproj b/3. .NET Internals/4. SynchronizationContext/HackerNews/HackerNews.csproj deleted file mode 100644 index e931306..0000000 --- a/3. .NET Internals/4. SynchronizationContext/HackerNews/HackerNews.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - $(NetVersion) - HackerNews - - - - - - - - diff --git a/3. .NET Internals/4. SynchronizationContext/HackerNews/Models/StoryModel.cs b/3. .NET Internals/4. SynchronizationContext/HackerNews/Models/StoryModel.cs deleted file mode 100644 index f82a05d..0000000 --- a/3. .NET Internals/4. SynchronizationContext/HackerNews/Models/StoryModel.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Text.Json.Serialization; - -namespace HackerNews; - -public record StoryModel([property: JsonPropertyName("by")] string Author, long Id, int Score, long Time, string Title, string Type, string? Url) -{ - public DateTimeOffset CreatedAt { get; } = DateTimeOffset.FromUnixTimeSeconds(Time); - - public string Description => ToString(); - - public override string ToString() => $"{Score} Points by {Author}, {GetAgeOfStory(CreatedAt)} ago"; - - static string GetAgeOfStory(in DateTimeOffset storyCreatedAt) - { - var timespanSinceStoryCreated = DateTimeOffset.UtcNow - storyCreatedAt; - - return timespanSinceStoryCreated switch - { - _ when timespanSinceStoryCreated < TimeSpan.FromHours(1) => $"{Math.Ceiling(timespanSinceStoryCreated.TotalMinutes)} minutes", - _ when timespanSinceStoryCreated >= TimeSpan.FromHours(1) && timespanSinceStoryCreated < TimeSpan.FromHours(2) => $"{Math.Floor(timespanSinceStoryCreated.TotalHours)} hour", - _ when timespanSinceStoryCreated >= TimeSpan.FromHours(2) && timespanSinceStoryCreated < TimeSpan.FromHours(24) => $"{Math.Floor(timespanSinceStoryCreated.TotalHours)} hours", - _ when timespanSinceStoryCreated >= TimeSpan.FromHours(24) && timespanSinceStoryCreated < TimeSpan.FromHours(48) => $"{Math.Floor(timespanSinceStoryCreated.TotalDays)} day", - _ when timespanSinceStoryCreated >= TimeSpan.FromHours(48) => $"{Math.Floor(timespanSinceStoryCreated.TotalDays)} days", - _ => string.Empty, - }; - } -} \ No newline at end of file diff --git a/3. .NET Internals/4. SynchronizationContext/HackerNews/Program.cs b/3. .NET Internals/4. SynchronizationContext/HackerNews/Program.cs deleted file mode 100644 index c07eaab..0000000 --- a/3. .NET Internals/4. SynchronizationContext/HackerNews/Program.cs +++ /dev/null @@ -1,52 +0,0 @@ -using HackerNews.Components; -using Microsoft.Extensions.Http.Resilience; -using Polly; -using Refit; - -namespace HackerNews; - -public static class Program -{ - public static void Main(string[] args) - { - var builder = WebApplication.CreateBuilder(args); - - builder.Services.AddRazorComponents() - .AddInteractiveServerComponents(); - - builder.Services.AddSingleton(); - - builder.Services.AddRefitClient() - .ConfigureHttpClient(static client => client.BaseAddress = new Uri("https://hacker-news.firebaseio.com/v0")) - .AddStandardResilienceHandler(static options => options.Retry = new WebHttpRetryStrategyOptions()); - - var app = builder.Build(); - - if (!app.Environment.IsDevelopment()) - { - app.UseExceptionHandler("/Error"); - app.UseHsts(); - app.UseHttpsRedirection(); - } - - app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); - app.UseAntiforgery(); - - app.MapStaticAssets(); - app.MapRazorComponents() - .AddInteractiveServerRenderMode(); - - app.Run(); - } - - sealed class WebHttpRetryStrategyOptions : HttpRetryStrategyOptions - { - public WebHttpRetryStrategyOptions() - { - BackoffType = DelayBackoffType.Exponential; - MaxRetryAttempts = 3; - UseJitter = true; - Delay = TimeSpan.FromSeconds(2); - } - } -} \ No newline at end of file diff --git a/3. .NET Internals/4. SynchronizationContext/HackerNews/Services/EnumerableExtensions.cs b/3. .NET Internals/4. SynchronizationContext/HackerNews/Services/EnumerableExtensions.cs deleted file mode 100644 index 0e62d3b..0000000 --- a/3. .NET Internals/4. SynchronizationContext/HackerNews/Services/EnumerableExtensions.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Collections; - -namespace HackerNews; - -static class EnumerableExtensions -{ - public static bool IsNullOrEmpty(this IEnumerable? enumerable) - { - if (enumerable is null) - return false; - - var enumerator = enumerable.GetEnumerator() ?? throw new InvalidOperationException("Enumerator not found"); - using var disposable = (IDisposable)enumerator; - return !enumerator.MoveNext(); - } -} \ No newline at end of file diff --git a/3. .NET Internals/4. SynchronizationContext/HackerNews/Services/HackerNewsAPIService.cs b/3. .NET Internals/4. SynchronizationContext/HackerNews/Services/HackerNewsAPIService.cs deleted file mode 100644 index 1c099ef..0000000 --- a/3. .NET Internals/4. SynchronizationContext/HackerNews/Services/HackerNewsAPIService.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace HackerNews; - -public class HackerNewsAPIService(IHackerNewsAPI hackerNewsClient) -{ - readonly IHackerNewsAPI _hackerNewsClient = hackerNewsClient; - - public Task GetStory(long storyId, CancellationToken token) => _hackerNewsClient.GetStory(storyId, token); - public Task> GetTopStoryIDs(CancellationToken token) => _hackerNewsClient.GetTopStoryIDs(token); -} \ No newline at end of file diff --git a/3. .NET Internals/4. SynchronizationContext/HackerNews/Services/IHackerNewsAPI.cs b/3. .NET Internals/4. SynchronizationContext/HackerNews/Services/IHackerNewsAPI.cs deleted file mode 100644 index fc99692..0000000 --- a/3. .NET Internals/4. SynchronizationContext/HackerNews/Services/IHackerNewsAPI.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using Refit; - -namespace HackerNews; - -public interface IHackerNewsAPI -{ - [Get("/topstories.json?print=pretty")] - Task> GetTopStoryIDs(CancellationToken token); - - [Get("/item/{storyId}.json?print=pretty")] - Task GetStory(long storyId, CancellationToken token); -} \ No newline at end of file diff --git a/3. .NET Internals/4. SynchronizationContext/HackerNews/wwwroot/app.css b/3. .NET Internals/4. SynchronizationContext/HackerNews/wwwroot/app.css deleted file mode 100644 index 7a1430d..0000000 --- a/3. .NET Internals/4. SynchronizationContext/HackerNews/wwwroot/app.css +++ /dev/null @@ -1,284 +0,0 @@ -:root { - --canvas: #eef3f1; - --panel: #ffffff; - --panel-strong: #112a2d; - --ink: #18211f; - --muted: #62706d; - --line: #d7e0dd; - --accent: #f46a1f; - --accent-dark: #b94713; - --teal: #0f766e; - --gold: #c59d35; - --shadow: 0 18px 55px rgb(28 45 42 / 14%); - color-scheme: light; - font-family: "Aptos", "Avenir Next", "Segoe UI", sans-serif; - background: var(--canvas); - color: var(--ink); -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - overflow-x: clip; - background: - linear-gradient(135deg, rgb(15 118 110 / 14%), transparent 32%), - radial-gradient(circle at top right, rgb(244 106 31 / 16%), transparent 30%), - var(--canvas); -} - -.shell { - min-height: 100vh; -} - -.news-page { - width: min(1120px, calc(100% - 32px)); - margin: 0 auto; - padding: 32px 0 56px; -} - -.hero-panel { - display: grid; - grid-template-columns: 1fr auto; - gap: 28px; - align-items: end; - margin-bottom: 22px; - padding: 30px; - border: 1px solid rgb(36 35 31 / 10%); - border-radius: 8px; - background: linear-gradient(135deg, var(--panel-strong), #15463e 58%, #6f3b17); - box-shadow: var(--shadow); - color: #f8fffc; -} - -.hero-copy { - max-width: 680px; -} - -.eyebrow { - margin: 0 0 10px; - font-size: 0.78rem; - font-weight: 800; - letter-spacing: 0; - text-transform: uppercase; - color: #ffd29d; -} - -h1 { - margin: 0; - font-family: "Iowan Old Style", "Sitka Text", Georgia, serif; - font-size: 4.45rem; - font-weight: 700; - line-height: 0.95; -} - -.hero-summary { - max-width: 560px; - margin: 16px 0 0; - font-size: 1rem; - line-height: 1.6; - color: #dcefeb; -} - -.hero-actions { - display: grid; - gap: 12px; - min-width: 220px; -} - -.status-tile { - display: grid; - gap: 2px; - padding: 16px; - border: 1px solid rgb(255 255 255 / 18%); - border-radius: 8px; - background: rgb(255 250 240 / 10%); - backdrop-filter: blur(10px); -} - -.status-value { - font-family: "Iowan Old Style", "Sitka Text", Georgia, serif; - font-size: 2.35rem; - font-weight: 700; - line-height: 1; -} - -.status-label { - font-size: 0.78rem; - font-weight: 700; - color: #dcefeb; -} - -.refresh-button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 10px; - min-height: 44px; - border: 0; - border-radius: 8px; - background: var(--accent); - color: #fff; - cursor: pointer; - font: inherit; - font-weight: 800; - padding: 10px 16px; - box-shadow: 0 10px 24px rgb(0 0 0 / 20%); -} - -.refresh-button:disabled { - cursor: progress; - opacity: 0.7; -} - -.refresh-icon { - width: 15px; - height: 15px; - border: 2px solid currentcolor; - border-left-color: transparent; - border-radius: 50%; -} - -.refresh-button:disabled .refresh-icon { - animation: spin 900ms linear infinite; -} - -.story-list { - display: grid; - gap: 10px; -} - -.story-card { - display: grid; - grid-template-columns: 42px 1fr; - gap: 16px; - align-items: start; - padding: 16px; - border: 1px solid var(--line); - border-radius: 8px; - background: rgb(255 255 255 / 88%); - box-shadow: 0 8px 24px rgb(28 45 42 / 8%); -} - -.story-rank { - display: inline-grid; - place-items: center; - width: 42px; - height: 42px; - border-radius: 8px; - background: #e4efeb; - color: var(--accent-dark); - font-weight: 900; -} - -.story-content { - min-width: 0; -} - -.story-title { - color: var(--ink); - font-size: 1.02rem; - font-weight: 800; - line-height: 1.35; - text-decoration: none; -} - -.story-title:hover { - color: var(--accent-dark); - text-decoration: underline; - text-decoration-thickness: 2px; - text-underline-offset: 3px; -} - -.story-meta { - margin: 6px 0 0; - font-size: 0.84rem; - font-weight: 650; - color: var(--muted); -} - -.error-panel { - border: 1px solid #cc4b24; - border-radius: 8px; - background: #fff4ee; - margin-bottom: 18px; - padding: 14px 16px; - color: #7f1d1d; -} - -.error-panel p { - margin: 6px 0 0; - line-height: 1.45; -} - -.skeleton-card { - pointer-events: none; -} - -.skeleton-line { - display: block; - height: 14px; - border-radius: 999px; - background: linear-gradient(90deg, #dbe6e2, #f7fbf9, #dbe6e2); - background-size: 220% 100%; - animation: shimmer 1.4s ease-in-out infinite; -} - -.title-line { - width: min(620px, 80%); - height: 18px; -} - -.meta-line { - width: min(360px, 58%); - margin-top: 10px; -} - -@keyframes shimmer { - 0% { - background-position: 120% 0; - } - - 100% { - background-position: -120% 0; - } -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -@media (max-width: 720px) { - .news-page { - width: min(100% - 24px, 1120px); - padding-top: 18px; - } - - .hero-panel { - grid-template-columns: 1fr; - padding: 22px; - } - - h1 { - font-size: 2.45rem; - } - - .hero-actions { - min-width: 0; - } - - .story-card { - grid-template-columns: 34px 1fr; - gap: 12px; - padding: 14px; - } - - .story-rank { - width: 34px; - height: 34px; - } -} \ No newline at end of file diff --git a/3. .NET Internals/Internals.pptx b/3. .NET Internals/Internals.pptx index 696e074..f14154a 100644 Binary files a/3. .NET Internals/Internals.pptx and b/3. .NET Internals/Internals.pptx differ diff --git a/3. .NET Internals/InternalsLab.slnx b/3. .NET Internals/InternalsLab.slnx new file mode 100644 index 0000000..a115ec7 --- /dev/null +++ b/3. .NET Internals/InternalsLab.slnx @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/3. .NET Internals/4. SynchronizationContext/HackerNews/Components/App.razor b/3. .NET Internals/InternalsLab/Components/App.razor similarity index 98% rename from 3. .NET Internals/4. SynchronizationContext/HackerNews/Components/App.razor rename to 3. .NET Internals/InternalsLab/Components/App.razor index 6b6c9b1..36e718d 100644 --- a/3. .NET Internals/4. SynchronizationContext/HackerNews/Components/App.razor +++ b/3. .NET Internals/InternalsLab/Components/App.razor @@ -14,4 +14,4 @@ - \ No newline at end of file + diff --git a/3. .NET Internals/InternalsLab/Components/Layout/MainLayout.razor b/3. .NET Internals/InternalsLab/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..7c188d6 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Components/Layout/MainLayout.razor @@ -0,0 +1,12 @@ +@inherits LayoutComponentBase + +
+ +
+ +
+ @Body +
diff --git a/3. .NET Internals/InternalsLab/Components/Layout/StepBadge.razor b/3. .NET Internals/InternalsLab/Components/Layout/StepBadge.razor new file mode 100644 index 0000000..3d8a2e0 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Components/Layout/StepBadge.razor @@ -0,0 +1,3 @@ +@inherits StepBadgeBase + +@Icon @Label diff --git a/3. .NET Internals/InternalsLab/Components/Layout/StepBadge.razor.cs b/3. .NET Internals/InternalsLab/Components/Layout/StepBadge.razor.cs new file mode 100644 index 0000000..e8c77e7 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Components/Layout/StepBadge.razor.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Components; + +namespace InternalsLab.Components.Layout; + +public partial class StepBadgeBase : ComponentBase +{ + [Parameter, EditorRequired] + public StepStatus Status { get; set; } + + [Parameter] + public bool IsUnlocked { get; set; } = true; + + public string Icon => !IsUnlocked ? "🔒" : Status switch + { + StepStatus.Predicted => "📝", + StepStatus.Ran => "🧪", + StepStatus.Passed => "✅", + _ => "•", + }; + + public string Label => !IsUnlocked ? "Locked" : Status switch + { + StepStatus.Predicted => "Predicted", + StepStatus.Ran => "Ran", + StepStatus.Passed => "Passed", + _ => "Not started", + }; + + public string CssClass => !IsUnlocked ? "badge-locked" : Status switch + { + StepStatus.Predicted => "badge-predicted", + StepStatus.Ran => "badge-ran", + StepStatus.Passed => "badge-passed", + _ => "badge-idle", + }; +} \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Components/Layout/StepNav.razor b/3. .NET Internals/InternalsLab/Components/Layout/StepNav.razor new file mode 100644 index 0000000..93cc1ae --- /dev/null +++ b/3. .NET Internals/InternalsLab/Components/Layout/StepNav.razor @@ -0,0 +1,15 @@ +@inherits StepNavBase + + diff --git a/3. .NET Internals/InternalsLab/Components/Layout/StepNav.razor.cs b/3. .NET Internals/InternalsLab/Components/Layout/StepNav.razor.cs new file mode 100644 index 0000000..388a873 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Components/Layout/StepNav.razor.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Components; + +namespace InternalsLab.Components.Layout; + +public partial class StepNavBase : ComponentBase +{ + // A parameter rather than [Inject]: a reference-type parameter makes the nav re-render every time its page does + [Parameter, EditorRequired] + public required LabNotebook Notebook { get; set; } +} \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Components/Pages/Error.razor b/3. .NET Internals/InternalsLab/Components/Pages/Error.razor new file mode 100644 index 0000000..90ce88e --- /dev/null +++ b/3. .NET Internals/InternalsLab/Components/Pages/Error.razor @@ -0,0 +1,13 @@ +@page "/Error" + +Error + +
+
+
+

InternalsLab

+

Something went wrong

+

An error occurred while processing your request. The terminal running the app has the details. Go back to the workshop steps.

+
+
+
diff --git a/3. .NET Internals/InternalsLab/Components/Pages/Home.razor b/3. .NET Internals/InternalsLab/Components/Pages/Home.razor new file mode 100644 index 0000000..c9c16f7 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Components/Pages/Home.razor @@ -0,0 +1,71 @@ +@page "/" +@inherits HomePageBase + +InternalsLab + +
+
+
+

.NET Internals

+

InternalsLab

+

Four experiments about where .NET keeps the state your async code depends on. Predict every checkpoint, run the experiment, and explain what you see.

+
+ +
+ @if (NextStep is { } nextStep) + { + @(PassedStepCount is 0 && Notebook.GetStatus(1) is StepStatus.NotStarted ? "Start" : "Continue with") Step @nextStep.Number + } + +
+ @PassedStepCount of @Notebook.Steps.Count + steps pass +
+
+
+ +
+ + +
+ @if (NextStep is null) + { + + } + +
+

Your challenge

+

Investigate four pieces of ambient state that asynchronous code depends on: [ThreadStatic] fields, ExecutionContext, the signed-in user, and SynchronizationContext. Nothing in this app is broken, so there is nothing to fix. Your job is to predict, observe and explain.

+

For every step, read the experiment's code, predict what every checkpoint will see, run the experiment, compare the results with your predictions, and answer the explain questions. Your predictions do not have to be right. Your explanations do.

+

Recommended time: 40 minutes. Step 1 takes 8 minutes, Step 2 takes 12, Step 3 takes 10, and Step 4 takes 10. Work through the steps in order, and when every step passes, pause for the group review.

+

Want to watch it happen? Set a breakpoint in any experiment, run the app from your IDE's debugger, and inspect the threads and values at each checkpoint.

+

Please avoid letting AI Agents solve the challenges for you. You're smart. You got this. Use them to understand the experiment code, clarify runtime concepts, and interpret what you observe.

+
+ +
+

How it works

+
    +
  1. Every step below is one experiment. Its code is in the Experiments folder, except Step 3, which is Controllers/PrincipalController.cs. Every // Step N: comment marks something to read before you predict.
  2. +
  3. Choose a prediction for every checkpoint. Run the experiment unlocks when every prediction is chosen, and your predictions lock in when it runs.
  4. +
  5. The results show what really happened next to what you predicted. Every result that does not match comes with a hint about what to look at.
  6. +
  7. Answer the explain questions. A wrong answer shows a hint, and you can try again. A step passes when its experiment has run and every question is answered correctly, and the next step unlocks.
  8. +
  9. Your lab notebook keeps everything for as long as the app runs, so reloading a page is safe. Stopping the app starts a fresh notebook, so explore the // Try it: comments with Hot Reload, or after the group review.
  10. +
+
+ + +
+
+
diff --git a/3. .NET Internals/InternalsLab/Components/Pages/Home.razor.cs b/3. .NET Internals/InternalsLab/Components/Pages/Home.razor.cs new file mode 100644 index 0000000..d6cd582 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Components/Pages/Home.razor.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Components; + +namespace InternalsLab.Components.Pages; + +public partial class HomePageBase : ComponentBase +{ + [Inject] + public required LabNotebook Notebook { get; init; } + + public int PassedStepCount => Notebook.Steps.Count(step => Notebook.GetStatus(step.Number) is StepStatus.Passed); + + // The first step that has not passed yet, which is always unlocked, or null when every step passed + public WorkshopStep? NextStep => Notebook.Steps.FirstOrDefault(step => Notebook.GetStatus(step.Number) is not StepStatus.Passed); +} \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Components/Pages/NotFound.razor b/3. .NET Internals/InternalsLab/Components/Pages/NotFound.razor new file mode 100644 index 0000000..97257b9 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Components/Pages/NotFound.razor @@ -0,0 +1,13 @@ +@page "/not-found" + +Not found + +
+
+
+

InternalsLab

+

Not found

+

Sorry, there is nothing at this address. Go back to the workshop steps.

+
+
+
diff --git a/3. .NET Internals/InternalsLab/Components/Pages/Step.razor b/3. .NET Internals/InternalsLab/Components/Pages/Step.razor new file mode 100644 index 0000000..0efefa0 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Components/Pages/Step.razor @@ -0,0 +1,374 @@ +@page "/steps/{Number:int}" +@inherits StepPageBase + +@(Step is null ? "Step not found" : $"Step {Step.Number} · InternalsLab") + +
+ @if (Step is null) + { +
+
+

InternalsLab

+

Step not found

+

There is no Step @Number. Go back to every step.

+
+
+ } + else + { + var progress = Notebook.GetProgress(Step.Number); + var status = Notebook.GetStatus(Step.Number); + var isUnlocked = Notebook.IsUnlocked(Step.Number); + var run = progress.LatestRun; + var nextStep = NextStep; + var runBlockedReason = RunBlockedReason; + +
+
+

Step @Step.Number · @Step.RecommendedMinutes minutes

+

@Step.Scenario

+

@Step.Title

+
+ +
+ +
+
+ +
+ + +
+ @if (!isUnlocked) + { + + } + else if (status is StepStatus.Passed) + { + + } + + @if (ErrorMessage is not null) + { + + } + +
+

The story

+

@Step.Story

+
+
Experiment
+
@Step.ExperimentFile
+
Read
+
Every // Step @Step.Number: comment, before you predict
+
Recommended time
+
@Step.RecommendedMinutes minutes
+
+
+ Try it, after this step passes +

These are the // Try it: comments in the experiment code. Nothing here is needed to pass.

+
    + @foreach (var suggestion in Step.TryIt) + { +
  • @suggestion
  • + } +
+
+
+ + @if (Step.ExperimentUrl is not null) + { +
+

Sign in

+ @if (SignedInUserName is null) + { +

This experiment reads the signed-in user, so sign in first. There is no password: the app stores the name you type in a sign-in cookie.

+ Sign in + } + else + { +

You are signed in as @SignedInUserName. Every cell in the experiment shows either that name or null.

+

Sign out or switch user

+ } +
+ } + +
+

1. Predict

+ @if (CanPredict) + { +

Read the experiment code, then choose what you expect at every checkpoint. Your predictions lock in when the experiment runs.

+ } + else if (run is not null) + { +

Your predictions locked in when the experiment ran.

+ } + +
+ + + + + @foreach (var column in Step.Columns) + { + + } + + + + @foreach (var checkpoint in Step.Checkpoints) + { + + + @foreach (var column in Step.Columns) + { + var key = WorkshopStep.PredictionKey(checkpoint.Number, column.Id); + + + } + + } + +
Checkpoint@BreakAfterDots(column.Header)
@checkpoint.Name + +
+
+
+ +
+

2. Run the experiment

+
+ + + @if (runBlockedReason is not null) + { + @runBlockedReason + } + else if (run is not null) + { + Run @run.RunNumber finished at @run.RanAt.ToString("HH:mm:ss"). Run it as often as you like. + } +
+ + @if (run is not null) + { + var previousRun = progress.PreviousRun; + +
+ @if (Step.Columns.Count is 1) + { + var column = Step.Columns[0]; + + + + + + + + + + + + + @foreach (var checkpoint in Step.Checkpoints) + { + var result = run.Results.FirstOrDefault(result => result.Checkpoint == checkpoint.Number); + var predictedId = run.Predictions.GetValueOrDefault(WorkshopStep.PredictionKey(checkpoint.Number, column.Id)); + var predicted = column.Choices.FirstOrDefault(choice => choice.Id == predictedId); + var actual = result?.Values.GetValueOrDefault(column.Id); + var matches = actual is not null && actual.ChoiceId == predictedId; + + + + + + + + + + @if (!matches) + { + + + + + } + } + +
Matches your predictionCheckpointThreadPool threadActual
@(matches ? "✅" : "❌")@checkpoint.Name@(result?.ThreadId.ToString() ?? "–")@(result?.IsThreadPoolThread switch { true => "yes", false => "no", null => "–" }) + @(actual?.Display ?? "Not recorded") + @if (result?.Details.Count > 0) + { + + @foreach (var detail in result.Details) + { + @detail + } + + } + @if (!matches) + { + You predicted: @(predicted?.Label ?? "–") + } +
Hint: @Step.GetHint(checkpoint.Number, column.Id)
+ } + else + { + + + + + + @foreach (var column in Step.Columns) + { + + } + + + + @foreach (var checkpoint in Step.Checkpoints) + { + var result = run.Results.FirstOrDefault(result => result.Checkpoint == checkpoint.Number); + var previousResult = previousRun?.Results.FirstOrDefault(result => result.Checkpoint == checkpoint.Number); + var mismatchedColumns = Step.Columns + .Where(column => result?.Values.GetValueOrDefault(column.Id)?.ChoiceId != run.Predictions.GetValueOrDefault(WorkshopStep.PredictionKey(checkpoint.Number, column.Id))) + .ToList(); + + + + + @foreach (var column in Step.Columns) + { + var predictedId = run.Predictions.GetValueOrDefault(WorkshopStep.PredictionKey(checkpoint.Number, column.Id)); + var predicted = column.Choices.FirstOrDefault(choice => choice.Id == predictedId); + var actual = result?.Values.GetValueOrDefault(column.Id); + var matches = actual is not null && actual.ChoiceId == predictedId; + + + } + + + @foreach (var column in mismatchedColumns) + { + + + + } + } + +
CheckpointThread@BreakAfterDots(column.Header)
@checkpoint.Name + @(result?.ThreadId.ToString() ?? "–") + @if (previousResult is not null) + { + previous run: @previousResult.ThreadId + } + + + @(matches ? "✅" : "❌") + @(actual?.Display ?? "Not recorded") + + @if (!matches) + { + You predicted: @(predicted?.Label ?? "–") + } +
Hint: @column.Header: @Step.GetHint(checkpoint.Number, column.Id)
+ } +
+ } +
+ + @if (Step is Step2ExecutionContext && run is not null) + { + var outcome = progress.TryItOutcome; + +
+

Try it: await inside the using block

+

AwaitInsideSuppressFlowAsync(), at the bottom of @Step.ExperimentFile, is the end of RunAsync() with one change: it awaits the Task.Run(...) task inside using (ExecutionContext.SuppressFlow()) instead of after the block. Predict what happens, then click Try it.

+
+ +
+ + @if (outcome is not null && !IsTryingIt) + { + @if (outcome.ExceptionType is null) + { + + } + else + { + + } + } +
+ } + +
+

3. Explain

+ @if (run is null) + { +

Run the experiment first. These questions are about what it shows.

+ } + else + { +

Answer every question correctly to pass this step. A wrong answer shows a hint, and you can choose again.

+ + @foreach (var question in Step.Questions) + { + var selected = question.FindAnswer(progress.Answers.GetValueOrDefault(question.Id)); + var isAnsweredCorrectly = selected?.IsCorrect is true; + +
+ @question.Question + @foreach (var answer in question.Answers) + { + + } + + @if (selected is not null) + { + + } +
+ } + } +
+
+
+ } +
diff --git a/3. .NET Internals/InternalsLab/Components/Pages/Step.razor.cs b/3. .NET Internals/InternalsLab/Components/Pages/Step.razor.cs new file mode 100644 index 0000000..f283a23 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Components/Pages/Step.razor.cs @@ -0,0 +1,204 @@ +using System.Net; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; + +namespace InternalsLab.Components.Pages; + +public partial class StepPageBase : ComponentBase, IAsyncDisposable +{ + // An experiment can finish faster than anyone can see a spinner + static readonly TimeSpan _minimumActivityIndicatorTime = TimeSpan.FromMilliseconds(600); + + readonly CancellationTokenSource _disposeCancellationTokenSource = new(); + + [Parameter] + public int Number { get; set; } + + [Inject] + public required LabNotebook Notebook { get; init; } + + [Inject] + public required NavigationManager NavigationManager { get; init; } + + [Inject] + public required AuthenticationStateProvider AuthenticationStateProvider { get; init; } + + [Inject] + public required ILogger Logger { get; init; } + + public WorkshopStep? Step { get; private set; } + + public string? SignedInUserName { get; private set; } + + public string? ErrorMessage { get; private set; } + + public bool IsRunning { get; private set; } + + public bool IsTryingIt { get; private set; } + + public WorkshopStep? NextStep => Notebook.FindStep(Number + 1); + + // Why Run the experiment is disabled, or null when it is not + public string? RunBlockedReason + { + get + { + if (Step is null || IsRunning) + return null; + + if (!Notebook.IsUnlocked(Number)) + return $"Finish Step {Number - 1} first."; + + var progress = Notebook.GetProgress(Number); + var predictionCount = Step.CountPredictions(progress.Predictions); + var requiredPredictionCount = Step.Checkpoints.Count * Step.Columns.Count; + + if (predictionCount < requiredPredictionCount) + return $"Choose a prediction for every checkpoint first: {predictionCount} of {requiredPredictionCount} chosen. They do not have to be right."; + + if (Step.ExperimentUrl is not null && SignedInUserName is null) + return "Sign in first. The experiment reads the signed-in user."; + + return null; + } + } + + public bool CanRun => Step is not null && !IsRunning && RunBlockedReason is null; + + public bool CanPredict => Notebook.IsUnlocked(Number) && Notebook.GetProgress(Number).LatestRun is null; + + public async ValueTask DisposeAsync() + { + // Stops waiting for an experiment that is still running. The experiment itself finishes on its own. + await _disposeCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + _disposeCancellationTokenSource.Dispose(); + + GC.SuppressFinalize(this); + } + + // Lets a long expression such as httpContextAccessor.HttpContext?.User wrap after each dot in a narrow table header + protected static MarkupString BreakAfterDots(string code) => new MarkupString(WebUtility.HtmlEncode(code).Replace(".", ".", StringComparison.Ordinal)); + + // Blazor reuses this page when navigating from one step to another, so reset per-step state whenever Number changes + protected override async Task OnParametersSetAsync() + { + if (Step?.Number == Number) + return; + + Step = Notebook.FindStep(Number); + ErrorMessage = null; + + if (Step?.ExperimentUrl is not null) + { + // The circuit's user comes from the sign-in cookie on the request that loaded this page + var authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); + SignedInUserName = authenticationState.User.Identity?.IsAuthenticated is true ? authenticationState.User.Identity.Name : null; + } + } + + protected void Predict(int checkpoint, string columnId, ChangeEventArgs e) => Notebook.Predict(Number, checkpoint, columnId, e.Value?.ToString() ?? string.Empty); + + protected void Answer(string questionId, string answerId) => Notebook.Answer(Number, questionId, answerId); + + protected async Task RunExperimentAsync() + { + if (Step is null || !CanRun) + return; + + // Step 3's experiment is an MVC request, so leave this Blazor circuit with a full page load. PrincipalController comes back to this page. + if (Step.ExperimentUrl is { } experimentUrl) + { + NavigationManager.NavigateTo(experimentUrl, forceLoad: true); + return; + } + + var token = _disposeCancellationTokenSource.Token; + var step = Step; + string? errorMessage = null; + + ErrorMessage = null; + IsRunning = true; + + var minimumActivityIndicatorTask = Task.Delay(_minimumActivityIndicatorTime, token); + + try + { + // Nothing above has awaited yet, so this still runs on Blazor's renderer, where every click handler starts. + // Step 4's experiment depends on that. + var results = await step.RunAsync(InvokeAsync, token).ConfigureAwait(false); + + Notebook.RecordRun(step.Number, results); + + await minimumActivityIndicatorTask.ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The page was closed while the experiment was running + return; + } + catch (TimeoutException e) + { + Logger.LogWarning(e, "Step {StepNumber}'s experiment did not finish within {Timeout}", step.Number, WorkshopStep.Timeout); + + errorMessage = $"The experiment did not finish within {WorkshopStep.Timeout.TotalSeconds} seconds. If you changed the experiment code, undo the change and run the app again."; + } + catch (Exception e) + { + Logger.LogError(e, "Step {StepNumber}'s experiment threw an unexpected exception", step.Number); + + errorMessage = "The experiment threw an unexpected exception. The terminal running the app has the details."; + } + + // The continuation is off Blazor's renderer, so every component state change goes back through it + await InvokeAsync(() => + { + ErrorMessage = errorMessage; + IsRunning = false; + + StateHasChanged(); + }).ConfigureAwait(false); + } + + protected async Task TryItAsync() + { + if (Step is not Step2ExecutionContext step || IsTryingIt) + return; + + var token = _disposeCancellationTokenSource.Token; + string? errorMessage = null; + + ErrorMessage = null; + IsTryingIt = true; + + var minimumActivityIndicatorTask = Task.Delay(_minimumActivityIndicatorTime, token); + + try + { + var outcome = await step.TryAwaitingInsideTheUsingBlock(Logger, token).ConfigureAwait(false); + + Notebook.RecordTryIt(step.Number, outcome); + + await minimumActivityIndicatorTask.ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The page was closed while Try it was running + return; + } + catch (Exception e) + { + Logger.LogError(e, "Try it on Step {StepNumber} threw an unexpected exception", step.Number); + + errorMessage = "Try it threw an unexpected exception. The terminal running the app has the details."; + } + + await InvokeAsync(() => + { + ErrorMessage = errorMessage; + IsTryingIt = false; + + StateHasChanged(); + }).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/3. .NET Internals/4. SynchronizationContext/HackerNews/Components/Routes.razor b/3. .NET Internals/InternalsLab/Components/Routes.razor similarity index 96% rename from 3. .NET Internals/4. SynchronizationContext/HackerNews/Components/Routes.razor rename to 3. .NET Internals/InternalsLab/Components/Routes.razor index 088a11a..ebc312b 100644 --- a/3. .NET Internals/4. SynchronizationContext/HackerNews/Components/Routes.razor +++ b/3. .NET Internals/InternalsLab/Components/Routes.razor @@ -3,4 +3,4 @@ - \ No newline at end of file + diff --git a/3. .NET Internals/InternalsLab/Components/_Imports.razor b/3. .NET Internals/InternalsLab/Components/_Imports.razor new file mode 100644 index 0000000..a8e71e2 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Components/_Imports.razor @@ -0,0 +1,6 @@ +@using InternalsLab +@using InternalsLab.Components +@using InternalsLab.Components.Layout +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode diff --git a/3. .NET Internals/3. Principal/PrincipalExample/Controllers/AccountController.cs b/3. .NET Internals/InternalsLab/Controllers/AccountController.cs similarity index 75% rename from 3. .NET Internals/3. Principal/PrincipalExample/Controllers/AccountController.cs rename to 3. .NET Internals/InternalsLab/Controllers/AccountController.cs index fefd5da..5c1fb95 100644 --- a/3. .NET Internals/3. Principal/PrincipalExample/Controllers/AccountController.cs +++ b/3. .NET Internals/InternalsLab/Controllers/AccountController.cs @@ -3,10 +3,13 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Mvc; -namespace PrincipalExample.Controllers; +namespace InternalsLab.Controllers; public class AccountController : Controller { + // The Blazor pages cannot render an antiforgery token for a form that posts to MVC, so signing in happens on this small MVC view + public IActionResult SignIn() => View(); + [HttpPost] [ValidateAntiForgeryToken] public async Task Login(string? name) @@ -19,7 +22,7 @@ public async Task Login(string? name) await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity)); } - return RedirectToAction(nameof(HomeController.Index), "Home"); + return Redirect("/steps/3"); } [HttpPost] @@ -28,6 +31,6 @@ public async Task Logout() { await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); - return RedirectToAction(nameof(HomeController.Index), "Home"); + return RedirectToAction(nameof(SignIn)); } } \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Controllers/PrincipalController.cs b/3. .NET Internals/InternalsLab/Controllers/PrincipalController.cs new file mode 100644 index 0000000..2d4a170 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Controllers/PrincipalController.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace InternalsLab.Controllers; + +// Step 3: Principal +// The Run the experiment button on the Step 3 page leaves the Blazor app and loads /Principal/RunExperiment, a normal ASP.NET Core request. +// Every Observe(string) call is one checkpoint on the Step 3 page. Predict all four columns of every checkpoint before you run it. +public class PrincipalController(IHttpContextAccessor httpContextAccessor, LabNotebook notebook) : Controller +{ + // Step 3: [Authorize] sends you to the sign-in page first if the request has no sign-in cookie + [Authorize] + public async Task RunExperiment() + { + var checkpoints = new List(); + + // Step 3: an ordinary local variable that holds the signed-in user + var signedInUser = HttpContext.User; + + // Step 3: checkpoint 1. Nothing in this action has awaited yet + checkpoints.Add(Observe("1. Start of the action")); + + // Try it: delete this line, apply the change with Hot Reload, and click Run it again. Which cells change at checkpoints 2 and 3? + Thread.CurrentPrincipal = signedInUser; + + // Yields the current thread: the rest of this method runs later as a continuation on the thread pool + // Try it: replace this line with await Task.Delay(1).ConfigureAwait(false) and apply it with Hot Reload. Does httpContextAccessor still find the HttpContext at checkpoint 2? + await Task.Yield(); + + // Step 3: checkpoint 2. The continuation, usually on a different thread + checkpoints.Add(Observe("2. After await Task.Yield()")); + + // Step 3: checkpoint 3. A thread pool thread running the Task.Run(...) lambda + checkpoints.Add(await Task.Run(() => Observe("3. Inside Task.Run(...)"))); + + // Step 3: checkpoint 4. The task is created while ExecutionContext flow is suppressed, and awaited only after the using block ends + Task suppressedFlowTask; + using (ExecutionContext.SuppressFlow()) + { + suppressedFlowTask = Task.Run(() => Observe("4. Inside Task.Run(...) started while ExecutionContext flow is suppressed")); + } + + checkpoints.Add(await suppressedFlowTask); + + // Workshop plumbing: saves the checkpoints in the lab notebook, then goes back to the Step 3 page to compare them with your predictions + notebook.RecordPrincipalRun(checkpoints); + + return Redirect("/steps/3"); + + // Asks for the signed-in user's name four different ways, from whichever thread is running this code + Checkpoint Observe(string checkpoint) => new Checkpoint( + checkpoint, + Environment.CurrentManagedThreadId, + Thread.CurrentPrincipal?.Identity?.Name, + httpContextAccessor.HttpContext?.User.Identity?.Name, + HttpContext.User.Identity?.Name, + signedInUser.Identity?.Name); + } +} \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Experiments/CustomPrincipal.cs b/3. .NET Internals/InternalsLab/Experiments/CustomPrincipal.cs new file mode 100644 index 0000000..d5e81b6 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Experiments/CustomPrincipal.cs @@ -0,0 +1,14 @@ +using System.Security.Principal; + +namespace InternalsLab; + +// Step 2: the background thread's principal, so its values are easy to tell apart from the main thread's ClaimsPrincipal +sealed class CustomPrincipal() : GenericPrincipal(new CustomIdentity(), null) +{ + sealed class CustomIdentity : IIdentity + { + public string AuthenticationType => "Fake"; + public bool IsAuthenticated => true; + public string Name => nameof(CustomIdentity); + } +} \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Experiments/ExecutionContextExperiment.cs b/3. .NET Internals/InternalsLab/Experiments/ExecutionContextExperiment.cs new file mode 100644 index 0000000..81c158f --- /dev/null +++ b/3. .NET Internals/InternalsLab/Experiments/ExecutionContextExperiment.cs @@ -0,0 +1,92 @@ +using System.Globalization; +using System.Security.Claims; + +namespace InternalsLab; + +// Step 2: ExecutionContext +// RunAsync() plays the part of a console app's Main method: the Step 2 page starts it on a new dedicated thread, which acts as the main thread. +// That thread starts while ExecutionContext flow is suppressed, so it begins with default values, and nothing it assigns reaches the rest of this app. +// Every RecordThreadValues(...) call is one checkpoint on the Step 2 page. Predict whose values each one sees before you run it. +public sealed class ExecutionContextExperiment(CheckpointLog log) +{ + static readonly AsyncLocal _asyncLocalData = new(); + + public async Task RunAsync() + { + // Assign data controlled by ExecutionContext + CultureInfo.CurrentCulture = new CultureInfo("es-ES"); + Thread.CurrentPrincipal = new ClaimsPrincipal(); + _asyncLocalData.Value = "Initial Value"; + + // Step 2: checkpoint 1. The main thread, right after assigning its values + RecordThreadValues(1); + + var mainThreadExecutionContext = ExecutionContext.Capture() ?? throw new InvalidOperationException("ExecutionContext only null when suppressed"); + + var thread = new Thread(() => + { + // Try it: comment out these three assignments and apply the change with Hot Reload, then click Run it again. What does checkpoint 2 see now? + CultureInfo.CurrentCulture = new CultureInfo("en-GB"); + Thread.CurrentPrincipal = new CustomPrincipal(); + _asyncLocalData.Value = "AsyncLocalData in Thread"; + + // Step 2: checkpoint 2. The background thread, after assigning its own values + RecordThreadValues(2); + + ExecutionContext.Run(mainThreadExecutionContext, _ => + { + // Step 2: checkpoint 3. The same background thread, but running with the main thread's ExecutionContext + RecordThreadValues(3); + }, null); + }); + + // Execute Thread + thread.Start(); + + // Wait for Thread to Complete + thread.Join(); + + // Step 2: checkpoint 4. The main thread's values again + RecordThreadValues(4); + + await Task.Run(() => + { + // Step 2: checkpoint 5. Inside Task.Run() + RecordThreadValues(5); + }); + + Task suppressedExecutionContextTask; + using (ExecutionContext.SuppressFlow()) + { + suppressedExecutionContextTask = Task.Run(() => + { + // Step 2: checkpoint 6. Inside Task.Run() started while ExecutionContext flow is suppressed + RecordThreadValues(6); + }); + } + + // Step 2: the task is created inside the using block, but awaited only after the block ends + await suppressedExecutionContextTask; + } + + // Try it: the Try it button on the Step 2 page runs this method. It is the end of RunAsync() with one change: + // it awaits inside the using block instead of after it. Predict what happens before you click the button. + public async Task AwaitInsideSuppressFlowAsync() + { + using (ExecutionContext.SuppressFlow()) + { + // Step 2: the thread that starts the using block + RecordThreadValues(1); + + await Task.Run(() => RecordThreadValues(2)); + + // Step 2: the thread that runs the continuation, which is also the thread that ends the using block + RecordThreadValues(3); + } + } + + void RecordThreadValues(int checkpoint) => log.Record(checkpoint, new ExecutionContextValues( + CultureInfo.CurrentCulture.Name, + Thread.CurrentPrincipal?.GetType().Name, + _asyncLocalData.Value)); +} \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Experiments/ExecutionContextValues.cs b/3. .NET Internals/InternalsLab/Experiments/ExecutionContextValues.cs new file mode 100644 index 0000000..f03bbc2 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Experiments/ExecutionContextValues.cs @@ -0,0 +1,4 @@ +namespace InternalsLab; + +// Everything Step 2 reads from ExecutionContext at a checkpoint: CultureInfo.CurrentCulture.Name, Thread.CurrentPrincipal's type name, and the AsyncLocal value +public sealed record ExecutionContextValues(string Culture, string? PrincipalType, string? AsyncLocalData); \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Experiments/SimulatedStoryFeed.cs b/3. .NET Internals/InternalsLab/Experiments/SimulatedStoryFeed.cs new file mode 100644 index 0000000..192c352 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Experiments/SimulatedStoryFeed.cs @@ -0,0 +1,45 @@ +namespace InternalsLab; + +// Step 4: stands in for the Hacker News API from Correcting Common Async Await Mistakes, so this app runs offline. +// Every request waits a moment, like a real network call, except a request for a story that is already in the cache. +public sealed class SimulatedStoryFeed +{ + static readonly TimeSpan _networkDelay = TimeSpan.FromMilliseconds(150); + + static readonly IReadOnlyList _topStories = + [ + new Story(1001, "Show HN: A tiny async state machine visualizer", 412), + new Story(1002, "What ConfigureAwait(false) really does", 287), + new Story(1003, "Ask HN: Does Blazor Server have a UI thread?", 164), + ]; + + // Loading the front page already downloaded the top story + // Try it: remove the top story from this cache and apply the change with Hot Reload. What does checkpoint 3 see now? + readonly Dictionary _cache = new Dictionary + { + [_topStories[0].Id] = _topStories[0], + }; + + public async Task> GetTopStoryIDs(CancellationToken token) + { + await Task.Delay(_networkDelay, token).ConfigureAwait(false); + + return [.. _topStories.Select(static story => story.Id)]; + } + + // A cached story comes back in a ValueTask that has already completed, so awaiting it never has to wait + public ValueTask GetStory(long storyId, CancellationToken token) + { + if (_cache.TryGetValue(storyId, out var cachedStory)) + return ValueTask.FromResult(cachedStory); + + return new ValueTask(DownloadStory(storyId, token)); + } + + static async Task DownloadStory(long storyId, CancellationToken token) + { + await Task.Delay(_networkDelay, token).ConfigureAwait(false); + + return _topStories.First(story => story.Id == storyId); + } +} \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Experiments/Story.cs b/3. .NET Internals/InternalsLab/Experiments/Story.cs new file mode 100644 index 0000000..a5f84eb --- /dev/null +++ b/3. .NET Internals/InternalsLab/Experiments/Story.cs @@ -0,0 +1,3 @@ +namespace InternalsLab; + +public sealed record Story(long Id, string Title, int Score); \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Experiments/SynchronizationContextExperiment.cs b/3. .NET Internals/InternalsLab/Experiments/SynchronizationContextExperiment.cs new file mode 100644 index 0000000..141e3c0 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Experiments/SynchronizationContextExperiment.cs @@ -0,0 +1,55 @@ +namespace InternalsLab; + +// Step 4: SynchronizationContext +// RefreshAsync() is shaped like RefreshAsync() in Correcting Common Async Await Mistakes, with a simulated story feed instead of Hacker News. +// The Step 4 page calls it straight from its Run the experiment click handler, so it starts on Blazor's renderer, like any event handler. +// Every RecordSynchronizationContext(...) call is one checkpoint on the Step 4 page. Predict SynchronizationContext.Current at each one before you run it. +public sealed class SynchronizationContextExperiment(CheckpointLog log) +{ + readonly SimulatedStoryFeed _storyFeed = new(); + + // Stands in for the page's TopStoryCollection: component state, so only the renderer may change it + public List TopStories { get; } = []; + + public async Task RefreshAsync(Func invokeAsync, CancellationToken token) + { + // Step 4: checkpoint 1. Before the first await, still inside the click handler + RecordSynchronizationContext(1); + + // Step 4: a plain await. GetTopStoryIDs() has to wait for the simulated network. + var topStoryIds = await _storyFeed.GetTopStoryIDs(token); + + // Step 4: checkpoint 2. After a plain await + RecordSynchronizationContext(2); + + // Step 4: the top story is already in the feed's cache, so the ValueTask has completed before the await looks at it + var topStory = await _storyFeed.GetStory(topStoryIds[0], token).ConfigureAwait(false); + + // Step 4: checkpoint 3. After ConfigureAwait(false) on a story that was already cached + RecordSynchronizationContext(3); + + // Step 4: this story is not cached, so the await has to wait for the simulated network + // Try it: remove ConfigureAwait(false) from this await and apply the change with Hot Reload. Which checkpoints change? + var secondStory = await _storyFeed.GetStory(topStoryIds[1], token).ConfigureAwait(false); + + // Step 4: checkpoint 4. After ConfigureAwait(false) on a story that had to download + RecordSynchronizationContext(4); + + // Step 4: a plain await again, but it runs after checkpoint 4 + var thirdStory = await _storyFeed.GetStory(topStoryIds[2], token); + + // Step 4: checkpoint 5. After a plain await that started where checkpoint 4 left off + RecordSynchronizationContext(5); + + // Step 4: InvokeAsync() runs the lambda through Blazor's renderer, which is how RefreshAsync() safely changes component state + await invokeAsync(() => + { + TopStories.AddRange([topStory, secondStory, thirdStory]); + + // Step 4: checkpoint 6. Inside InvokeAsync() + RecordSynchronizationContext(6); + }); + } + + void RecordSynchronizationContext(int checkpoint) => log.Record(checkpoint, SynchronizationContext.Current?.GetType().Name); +} \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Experiments/ThreadStaticExperiment.cs b/3. .NET Internals/InternalsLab/Experiments/ThreadStaticExperiment.cs new file mode 100644 index 0000000..9a63543 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Experiments/ThreadStaticExperiment.cs @@ -0,0 +1,57 @@ +namespace InternalsLab; + +// Step 1: ThreadStatic +// RunAsync() plays the part of a console app's Main method: the Step 1 page starts it on a new dedicated thread, which acts as the main thread. +// Every log.Record(...) call is one checkpoint on the Step 1 page. Predict what each one records before you run it. +public sealed class ThreadStaticExperiment(CheckpointLog log) +{ + // Step 1: [ThreadStatic] makes this static field thread-local + // Try it: remove [ThreadStatic], then stop and run the app again. Restarting starts a fresh notebook, so try this one after the group review. Which checkpoints change, and which threads share the value now? + // Try it: replace this field with static readonly AsyncLocal _threadSpecificValue = new(), read and write its Value, and run the app again. Step 2 explains what changes. + [ThreadStatic] + static int _threadSpecificValue; + + public async Task RunAsync() + { + // Initializing thread-specific value for the main thread + _threadSpecificValue = 100; + + // Step 1: checkpoint 1. The main thread, right after assigning 100 + log.Record(1, _threadSpecificValue); + + // Create two new threads. Thread 1 records checkpoints 2 and 3, and thread 2 records checkpoints 4 and 5. + var thread1 = new Thread(() => ThreadMethod(2, 3)); + var thread2 = new Thread(() => ThreadMethod(4, 5)); + + // Start the threads + thread1.Start(); + thread2.Start(); + + // Wait for threads to finish + thread1.Join(); + thread2.Join(); + + // Step 1: checkpoint 6. The main thread again, after the other threads have finished + log.Record(6, _threadSpecificValue); + + // Step 1: Task.Yield() always schedules the rest of this method as a continuation. + // This thread has no SynchronizationContext, so the continuation is queued to the thread pool. + await Task.Yield(); + + // Step 1: checkpoint 7. Whichever thread pool thread runs the continuation + log.Record(7, _threadSpecificValue); + } + + // Method to be run by each thread + void ThreadMethod(int checkpointBeforeAssigning, int checkpointAfterAssigning) + { + // Step 1: checkpoints 2 and 4. This thread has not assigned anything yet + log.Record(checkpointBeforeAssigning, _threadSpecificValue); + + // Initialize thread-specific value for this thread + _threadSpecificValue = Random.Shared.Next(1, 100); + + // Step 1: checkpoints 3 and 5. This thread, right after assigning its own random value + log.Record(checkpointAfterAssigning, _threadSpecificValue); + } +} \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/InternalsLab.csproj b/3. .NET Internals/InternalsLab/InternalsLab.csproj new file mode 100644 index 0000000..a9a7b76 --- /dev/null +++ b/3. .NET Internals/InternalsLab/InternalsLab.csproj @@ -0,0 +1,8 @@ + + + + $(NetVersion) + InternalsLab + + + \ No newline at end of file diff --git a/3. .NET Internals/3. Principal/PrincipalExample/Models/Checkpoint.cs b/3. .NET Internals/InternalsLab/Models/Checkpoint.cs similarity index 84% rename from 3. .NET Internals/3. Principal/PrincipalExample/Models/Checkpoint.cs rename to 3. .NET Internals/InternalsLab/Models/Checkpoint.cs index b922681..d198055 100644 --- a/3. .NET Internals/3. Principal/PrincipalExample/Models/Checkpoint.cs +++ b/3. .NET Internals/InternalsLab/Models/Checkpoint.cs @@ -1,4 +1,4 @@ -namespace PrincipalExample.Models; +namespace InternalsLab; public record Checkpoint( string Name, diff --git a/3. .NET Internals/InternalsLab/Notebook/CheckpointLog.cs b/3. .NET Internals/InternalsLab/Notebook/CheckpointLog.cs new file mode 100644 index 0000000..c32d199 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Notebook/CheckpointLog.cs @@ -0,0 +1,30 @@ +namespace InternalsLab; + +// Workshop plumbing: records what the experiment code sees at each checkpoint, together with the thread that saw it. +// Several threads record at the same time, so every member takes the lock. +public sealed class CheckpointLog +{ + readonly Lock _lock = new(); + readonly List> _observations = []; + + public IReadOnlyList> Observations + { + get + { + lock (_lock) + { + return [.. _observations.OrderBy(static observation => observation.Checkpoint)]; + } + } + } + + public void Record(int checkpoint, T value) + { + var observation = new Observation(checkpoint, Environment.CurrentManagedThreadId, Thread.CurrentThread.IsThreadPoolThread, value); + + lock (_lock) + { + _observations.Add(observation); + } + } +} \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Notebook/CheckpointResult.cs b/3. .NET Internals/InternalsLab/Notebook/CheckpointResult.cs new file mode 100644 index 0000000..8ee83ee --- /dev/null +++ b/3. .NET Internals/InternalsLab/Notebook/CheckpointResult.cs @@ -0,0 +1,10 @@ +namespace InternalsLab; + +// Values holds one ObservedValue per prediction column. IsThreadPoolThread is null when the experiment did not record it. +// Details are extra lines shown next to the result, such as the culture and principal Step 2 saw. +public sealed record CheckpointResult( + int Checkpoint, + int ThreadId, + bool? IsThreadPoolThread, + IReadOnlyDictionary Values, + IReadOnlyList Details); \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Notebook/ExperimentRun.cs b/3. .NET Internals/InternalsLab/Notebook/ExperimentRun.cs new file mode 100644 index 0000000..f4f42a4 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Notebook/ExperimentRun.cs @@ -0,0 +1,8 @@ +namespace InternalsLab; + +// Predictions is a copy of your predictions when the experiment ran, so the results always compare against what you predicted +public sealed record ExperimentRun( + int RunNumber, + IReadOnlyList Results, + IReadOnlyDictionary Predictions, + DateTimeOffset RanAt); \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Notebook/LabNotebook.cs b/3. .NET Internals/InternalsLab/Notebook/LabNotebook.cs new file mode 100644 index 0000000..08f6c01 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Notebook/LabNotebook.cs @@ -0,0 +1,148 @@ +namespace InternalsLab; + +// Workshop plumbing: your lab notebook. It keeps every step's predictions, latest results and answers for as long as the app runs. +// It is a singleton, so reloading a page, or leaving the Blazor app for the Step 3 experiment, never loses anything. +// Blazor circuits and MVC requests use it from different threads, so every public member takes the lock. +public sealed class LabNotebook +{ + readonly Lock _lock = new(); + readonly StepProgress[] _progress; + + public LabNotebook() + { + _progress = [.. Steps.Select(static _ => StepProgress.Empty)]; + } + + public IReadOnlyList Steps { get; } = + [ + new Step1ThreadStatic(), + new Step2ExecutionContext(), + new Step3Principal(), + new Step4SynchronizationContext(), + ]; + + public WorkshopStep? FindStep(int number) => Steps.FirstOrDefault(step => step.Number == number); + + public StepProgress GetProgress(int number) + { + lock (_lock) + { + return _progress[number - 1]; + } + } + + public StepStatus GetStatus(int number) + { + lock (_lock) + { + return StatusOf(number); + } + } + + public bool IsUnlocked(int number) + { + lock (_lock) + { + return IsUnlockedWhileLocked(number); + } + } + + // Predictions lock in once the experiment has run, so the results always compare against what you predicted first + public void Predict(int number, int checkpoint, string columnId, string choiceId) + { + var step = Steps[number - 1]; + var column = step.Columns.FirstOrDefault(column => column.Id == columnId); + + if (column is null || step.Checkpoints.All(existing => existing.Number != checkpoint)) + return; + + lock (_lock) + { + var progress = _progress[number - 1]; + + if (!IsUnlockedWhileLocked(number) || progress.LatestRun is not null) + return; + + var predictions = new Dictionary(progress.Predictions); + var key = WorkshopStep.PredictionKey(checkpoint, columnId); + + if (column.Choices.Any(choice => choice.Id == choiceId)) + predictions[key] = choiceId; + else + predictions.Remove(key); + + _progress[number - 1] = progress with { Predictions = predictions }; + } + } + + // Ignored until the step is unlocked and every prediction is chosen, even when the experiment was started some other way + public bool RecordRun(int number, IReadOnlyList results) + { + var step = Steps[number - 1]; + + lock (_lock) + { + var progress = _progress[number - 1]; + + if (!IsUnlockedWhileLocked(number) || !step.HasAllPredictions(progress.Predictions)) + return false; + + var runNumber = (progress.LatestRun?.RunNumber ?? 0) + 1; + var run = new ExperimentRun(runNumber, results, progress.Predictions, DateTimeOffset.Now); + + _progress[number - 1] = progress with { LatestRun = run, PreviousRun = progress.LatestRun }; + + return true; + } + } + + public bool RecordPrincipalRun(IReadOnlyList checkpoints) => RecordRun(Step3Principal.StepNumber, Step3Principal.ToResults(checkpoints)); + + // A correct answer stays correct, so answering a question can never lock a later step again + public void Answer(int number, string questionId, string answerId) + { + var step = Steps[number - 1]; + var question = step.Questions.FirstOrDefault(question => question.Id == questionId); + + if (question?.FindAnswer(answerId) is null) + return; + + lock (_lock) + { + var progress = _progress[number - 1]; + + if (!IsUnlockedWhileLocked(number) || progress.LatestRun is null || step.IsAnsweredCorrectly(question, progress.Answers)) + return; + + var answers = new Dictionary(progress.Answers) + { + [questionId] = answerId, + }; + + _progress[number - 1] = progress with { Answers = answers }; + } + } + + public void RecordTryIt(int number, TryItOutcome outcome) + { + lock (_lock) + { + _progress[number - 1] = _progress[number - 1] with { TryItOutcome = outcome }; + } + } + + // Callers already hold _lock + StepStatus StatusOf(int number) + { + var step = Steps[number - 1]; + var progress = _progress[number - 1]; + + if (progress.LatestRun is null) + return step.HasAllPredictions(progress.Predictions) ? StepStatus.Predicted : StepStatus.NotStarted; + + return step.Questions.All(question => step.IsAnsweredCorrectly(question, progress.Answers)) ? StepStatus.Passed : StepStatus.Ran; + } + + // Callers already hold _lock + bool IsUnlockedWhileLocked(int number) => number is 1 || StatusOf(number - 1) is StepStatus.Passed; +} \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Notebook/Observation.cs b/3. .NET Internals/InternalsLab/Notebook/Observation.cs new file mode 100644 index 0000000..f73cb32 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Notebook/Observation.cs @@ -0,0 +1,3 @@ +namespace InternalsLab; + +public sealed record Observation(int Checkpoint, int ThreadId, bool IsThreadPoolThread, T Value); \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Notebook/ObservedValue.cs b/3. .NET Internals/InternalsLab/Notebook/ObservedValue.cs new file mode 100644 index 0000000..3a6417a --- /dev/null +++ b/3. .NET Internals/InternalsLab/Notebook/ObservedValue.cs @@ -0,0 +1,4 @@ +namespace InternalsLab; + +// ChoiceId is the prediction this value matches. Display is what the experiment actually saw. +public sealed record ObservedValue(string ChoiceId, string Display); \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Notebook/StepProgress.cs b/3. .NET Internals/InternalsLab/Notebook/StepProgress.cs new file mode 100644 index 0000000..3dbdab4 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Notebook/StepProgress.cs @@ -0,0 +1,12 @@ +namespace InternalsLab; + +// Everything the lab notebook keeps for one step. Predictions and Answers are keyed by WorkshopStep.PredictionKey(...) and ExplainQuestion.Id. +public sealed record StepProgress( + IReadOnlyDictionary Predictions, + ExperimentRun? LatestRun, + ExperimentRun? PreviousRun, + IReadOnlyDictionary Answers, + TryItOutcome? TryItOutcome) +{ + public static StepProgress Empty { get; } = new StepProgress(new Dictionary(), null, null, new Dictionary(), null); +} \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Notebook/StepStatus.cs b/3. .NET Internals/InternalsLab/Notebook/StepStatus.cs new file mode 100644 index 0000000..ba6f3c4 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Notebook/StepStatus.cs @@ -0,0 +1,10 @@ +namespace InternalsLab; + +// Locked is not a status: a step is locked until the step before it passes, whatever it holds +public enum StepStatus +{ + NotStarted, + Predicted, + Ran, + Passed, +} \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Notebook/TryItOutcome.cs b/3. .NET Internals/InternalsLab/Notebook/TryItOutcome.cs new file mode 100644 index 0000000..44cae3f --- /dev/null +++ b/3. .NET Internals/InternalsLab/Notebook/TryItOutcome.cs @@ -0,0 +1,9 @@ +namespace InternalsLab; + +// What happened when the Try it button ran the version of Step 2 that awaits inside the using block. +// Only the exception type is kept: the message and stack trace stay in the terminal. +public sealed record TryItOutcome( + string? ExceptionType, + int? UsingBlockStartThreadId, + int? UsingBlockEndThreadId, + DateTimeOffset RanAt); \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Program.cs b/3. .NET Internals/InternalsLab/Program.cs new file mode 100644 index 0000000..a3d9e9e --- /dev/null +++ b/3. .NET Internals/InternalsLab/Program.cs @@ -0,0 +1,48 @@ +using InternalsLab.Components; +using Microsoft.AspNetCore.Authentication.Cookies; + +namespace InternalsLab; + +public static class Program +{ + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + + // Step 3: the Principal experiment is an MVC controller, because it has to observe a real ASP.NET Core request + builder.Services.AddControllersWithViews(); + builder.Services.AddHttpContextAccessor(); + builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(static options => options.LoginPath = "/Account/SignIn"); + + // Workshop plumbing: one LabNotebook keeps your predictions, results and answers for as long as the app runs, + // so reloading a page, or leaving the Blazor page for the Step 3 experiment, never loses them + builder.Services.AddSingleton(); + + var app = builder.Build(); + + if (!app.Environment.IsDevelopment()) + { + app.UseExceptionHandler("/Error"); + app.UseHsts(); + app.UseHttpsRedirection(); + } + + app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); + + // Step 3: reads the sign-in cookie on every request and assigns the signed-in user to HttpContext.User + app.UseAuthentication(); + app.UseAuthorization(); + app.UseAntiforgery(); + + app.MapStaticAssets(); + app.MapDefaultControllerRoute(); + app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + + app.Run(); + } +} \ No newline at end of file diff --git a/3. .NET Internals/4. SynchronizationContext/HackerNews/Properties/launchSettings.json b/3. .NET Internals/InternalsLab/Properties/launchSettings.json similarity index 74% rename from 3. .NET Internals/4. SynchronizationContext/HackerNews/Properties/launchSettings.json rename to 3. .NET Internals/InternalsLab/Properties/launchSettings.json index f1b088e..4b226c3 100644 --- a/3. .NET Internals/4. SynchronizationContext/HackerNews/Properties/launchSettings.json +++ b/3. .NET Internals/InternalsLab/Properties/launchSettings.json @@ -1,13 +1,13 @@ { "profiles": { - "HackerNews": { + "InternalsLab": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "applicationUrl": "http://localhost:5004", + "applicationUrl": "http://localhost:5003", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } } -} +} \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Steps/ExperimentCheckpoint.cs b/3. .NET Internals/InternalsLab/Steps/ExperimentCheckpoint.cs new file mode 100644 index 0000000..82c1459 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Steps/ExperimentCheckpoint.cs @@ -0,0 +1,3 @@ +namespace InternalsLab; + +public sealed record ExperimentCheckpoint(int Number, string Name); \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Steps/ExplainAnswer.cs b/3. .NET Internals/InternalsLab/Steps/ExplainAnswer.cs new file mode 100644 index 0000000..148a5a1 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Steps/ExplainAnswer.cs @@ -0,0 +1,4 @@ +namespace InternalsLab; + +// Feedback is shown as soon as you choose this answer: a confirmation when it is correct, and a hint when it is not +public sealed record ExplainAnswer(string Id, string Text, bool IsCorrect, string Feedback); \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Steps/ExplainQuestion.cs b/3. .NET Internals/InternalsLab/Steps/ExplainQuestion.cs new file mode 100644 index 0000000..3d195c9 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Steps/ExplainQuestion.cs @@ -0,0 +1,6 @@ +namespace InternalsLab; + +public sealed record ExplainQuestion(string Id, string Question, IReadOnlyList Answers) +{ + public ExplainAnswer? FindAnswer(string? answerId) => Answers.FirstOrDefault(answer => answer.Id == answerId); +} \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Steps/PredictionChoice.cs b/3. .NET Internals/InternalsLab/Steps/PredictionChoice.cs new file mode 100644 index 0000000..1acc205 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Steps/PredictionChoice.cs @@ -0,0 +1,3 @@ +namespace InternalsLab; + +public sealed record PredictionChoice(string Id, string Label); \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Steps/PredictionColumn.cs b/3. .NET Internals/InternalsLab/Steps/PredictionColumn.cs new file mode 100644 index 0000000..ed72911 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Steps/PredictionColumn.cs @@ -0,0 +1,4 @@ +namespace InternalsLab; + +// One thing to predict at every checkpoint. Most steps predict one value per checkpoint. Step 3 predicts four. +public sealed record PredictionColumn(string Id, string Header, IReadOnlyList Choices); \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Steps/Step1ThreadStatic.cs b/3. .NET Internals/InternalsLab/Steps/Step1ThreadStatic.cs new file mode 100644 index 0000000..a255c9e --- /dev/null +++ b/3. .NET Internals/InternalsLab/Steps/Step1ThreadStatic.cs @@ -0,0 +1,119 @@ +namespace InternalsLab; + +// This file holds the answers to Step 1. Run the experiment and answer the questions on the page before you read it. +public sealed class Step1ThreadStatic : WorkshopStep +{ + const string _valueColumn = "value"; + const string _mainThreadValue = "main"; + const string _defaultValue = "default"; + const string _ownValue = "own"; + + public override int Number => 1; + + public override string Scenario => "ThreadStatic"; + + public override string Title => "[ThreadStatic] static int _threadSpecificValue"; + + public override string Story => "Before async and await, ambient data such as the current user or an open database transaction usually lived in thread-local storage, and [ThreadStatic] is the simplest way to get it: " + + "a static field with a separate copy for every thread. The experiment is a small console-style program. Its main thread assigns 100, two background threads each read the field and then assign a random value of their own, " + + "and finally the main thread awaits Task.Yield(), which moves the rest of the method to a thread pool thread. Predict what the field holds at every checkpoint."; + + public override int RecommendedMinutes => 8; + + public override string ExperimentFile => "Experiments/ThreadStaticExperiment.cs"; + + public override IReadOnlyList TryIt { get; } = + [ + "Remove [ThreadStatic], then stop and run the app again. Restarting starts a fresh notebook, so try this one after the group review. Which checkpoints change, and which threads share the value now?", + "Replace the field with static readonly AsyncLocal and read and write its Value, then run the app again. Which checkpoints change? Step 2 explains why.", + ]; + + public override IReadOnlyList Checkpoints { get; } = + [ + new ExperimentCheckpoint(1, "1. Main thread, after assigning 100"), + new ExperimentCheckpoint(2, "2. Background thread 1, before assigning"), + new ExperimentCheckpoint(3, "3. Background thread 1, after assigning its random value"), + new ExperimentCheckpoint(4, "4. Background thread 2, before assigning"), + new ExperimentCheckpoint(5, "5. Background thread 2, after assigning its random value"), + new ExperimentCheckpoint(6, "6. Main thread, after both threads finish"), + new ExperimentCheckpoint(7, "7. After await Task.Yield()"), + ]; + + public override IReadOnlyList Columns { get; } = + [ + new PredictionColumn(_valueColumn, "_threadSpecificValue", + [ + new PredictionChoice(_mainThreadValue, "100 (the main thread's value)"), + new PredictionChoice(_defaultValue, "0 (the default for int)"), + new PredictionChoice(_ownValue, "Its own random value"), + ]), + ]; + + public override IReadOnlyList Questions { get; } = + [ + new ExplainQuestion("main-thread-keeps-its-value", "Why does the main thread still read 100 at checkpoint 6, after both background threads assigned their own values?", + [ + new ExplainAnswer("a", "Join() restores the main thread's value when the background threads finish.", false, + "Join() only waits for a thread to finish. Nothing is saved or restored, so ask which copy of the field the background threads wrote to."), + new ExplainAnswer("b", "The background threads ran after checkpoint 6, so their writes had not happened yet.", false, + "Join() waits for both threads before checkpoint 6 runs, so their writes had already happened. Look at the checkpoint order."), + new ExplainAnswer("c", "[ThreadStatic] gives every thread its own storage for the field, so the background threads wrote to their own copies and never touched the main thread's.", true, + "Right. A [ThreadStatic] field is really one field per thread, and each thread only ever reads and writes its own copy."), + new ExplainAnswer("d", "Every new thread gets a copy of the static fields when it starts, so the background threads changed their copies.", false, + "If a new thread copied the value when it started, checkpoints 2 and 4 would read 100. What did they read?"), + ]), + new ExplainQuestion("background-threads-start-at-zero", "Why do the background threads read 0 at checkpoints 2 and 4?", + [ + new ExplainAnswer("a", "Each thread's copy of a [ThreadStatic] field starts at the default for its type, and nothing copies another thread's value into it.", true, + "Right. A thread gets a fresh copy of the field, and for an int that copy starts at 0."), + new ExplainAnswer("b", "The main thread had not assigned 100 yet when they started.", false, + "The main thread assigned 100 at checkpoint 1, before it created either thread."), + new ExplainAnswer("c", "Thread.Start() resets every static field to its default value.", false, + "If it did, the main thread would read 0 at checkpoint 6 too."), + ]), + new ExplainQuestion("await-leaves-the-value-behind", "Why does checkpoint 7, after await Task.Yield(), read 0?", + [ + new ExplainAnswer("a", "await resets [ThreadStatic] fields to their default values.", false, + "await never touches the field. Compare the Thread column for checkpoints 6 and 7."), + new ExplainAnswer("b", "The continuation ran on a thread pool thread, which has its own copy of the field. [ThreadStatic] values stay with the thread, so they do not follow async code when it moves to another thread.", true, + "Right. That is why async code needs storage that follows the flow of the code instead of the thread. Step 2 shows it: ExecutionContext and AsyncLocal."), + new ExplainAnswer("c", "The continuation ran on the same thread, but Task.Yield() cleared the thread's static fields.", false, + "Compare the Thread column for checkpoints 6 and 7. Are they the same thread?"), + ]), + ]; + + public override string GetHint(int checkpoint, string columnId) => checkpoint switch + { + 1 => "Checkpoint 1 reads the field on the same thread that just assigned it. Look at the line right above log.Record(1, ...).", + 2 or 4 => "This thread has not assigned anything yet. The main thread assigned 100 before starting it, but whose copy of the field did that assignment change? What does a thread's own copy start as?", + 3 or 5 => "This thread just assigned Random.Shared.Next(1, 100) to the field. Compare its Thread column with checkpoint 1: whose copy is it reading?", + 6 => "Both background threads have written to _threadSpecificValue by now. Checkpoint 6 runs on the same thread as checkpoint 1, so ask which copy the background threads wrote to.", + _ => "Compare the Thread and Pool thread columns for checkpoints 6 and 7. The code after await Task.Yield() ran on a different thread, and [ThreadStatic] storage belongs to a thread, not to a method.", + }; + + public override async Task> RunAsync(Func invokeAsync, CancellationToken token) + { + var log = new CheckpointLog(); + var experiment = new ThreadStaticExperiment(log); + + await RunOnDedicatedThread(experiment.RunAsync, token).ConfigureAwait(false); + + return [.. log.Observations.Select(static observation => new CheckpointResult( + observation.Checkpoint, + observation.ThreadId, + observation.IsThreadPoolThread, + new Dictionary + { + [_valueColumn] = new ObservedValue(Classify(observation.Value), observation.Value.ToString()), + }, + []))]; + } + + // Random.Shared.Next(1, 100) never returns 0 or 100, so any other value is a thread's own random value + static string Classify(int value) => value switch + { + 100 => _mainThreadValue, + 0 => _defaultValue, + _ => _ownValue, + }; +} \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Steps/Step2ExecutionContext.cs b/3. .NET Internals/InternalsLab/Steps/Step2ExecutionContext.cs new file mode 100644 index 0000000..91c5b81 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Steps/Step2ExecutionContext.cs @@ -0,0 +1,160 @@ +using System.Security.Claims; + +namespace InternalsLab; + +// This file holds the answers to Step 2. Run the experiment and answer the questions on the page before you read it. +public sealed class Step2ExecutionContext : WorkshopStep +{ + const string _valuesColumn = "values"; + const string _mainThreadValues = "main"; + const string _backgroundThreadValues = "background"; + const string _defaultValues = "defaults"; + const string _mixedValues = "mixed"; + + public override int Number => 2; + + public override string Scenario => "ExecutionContext"; + + public override string Title => "ExecutionContext.Capture(), ExecutionContext.Run(...), Task.Run(...), ExecutionContext.SuppressFlow()"; + + public override string Story => "Step 1 showed that [ThreadStatic] values stay behind when async code moves to another thread. ExecutionContext is what .NET uses instead: " + + "it carries CultureInfo.CurrentCulture, Thread.CurrentPrincipal and every AsyncLocal value, and await, Task.Run(...) and new threads capture it and restore it wherever the code runs next. " + + "The experiment is the console app from the slides. Its main thread assigns a culture, a principal and an AsyncLocal value, a background thread assigns values of its own, " + + "and every checkpoint records what the code can see. Predict whose values each checkpoint sees."; + + public override int RecommendedMinutes => 12; + + public override string ExperimentFile => "Experiments/ExecutionContextExperiment.cs"; + + public override IReadOnlyList TryIt { get; } = + [ + "Click Try it after you run the experiment. It runs AwaitInsideSuppressFlowAsync(), which awaits inside the using block instead of after it.", + "Comment out the three assignments at the start of the background thread and apply the change with Hot Reload, then click Run it again. What does checkpoint 2 see now, and what does that tell you about new Thread(...)?", + ]; + + public override IReadOnlyList Checkpoints { get; } = + [ + new ExperimentCheckpoint(1, "1. Main thread, after assigning its values"), + new ExperimentCheckpoint(2, "2. Background thread, after assigning its own values"), + new ExperimentCheckpoint(3, "3. Same background thread, inside ExecutionContext.Run(mainThreadExecutionContext, ...)"), + new ExperimentCheckpoint(4, "4. Main thread, after the background thread finishes"), + new ExperimentCheckpoint(5, "5. Inside Task.Run(...)"), + new ExperimentCheckpoint(6, "6. Inside Task.Run(...) started while ExecutionContext flow is suppressed"), + ]; + + public override IReadOnlyList Columns { get; } = + [ + new PredictionColumn(_valuesColumn, "CultureInfo.CurrentCulture, Thread.CurrentPrincipal, _asyncLocalData.Value", + [ + new PredictionChoice(_mainThreadValues, "The main thread's values"), + new PredictionChoice(_backgroundThreadValues, "The background thread's own values"), + new PredictionChoice(_defaultValues, "Default values"), + ]), + ]; + + public override IReadOnlyList Questions { get; } = + [ + new ExplainQuestion("execution-context-run", "What changes when the background thread calls ExecutionContext.Run(mainThreadExecutionContext, ...)?", + [ + new ExplainAnswer("a", "The callback is handed to the main thread, which runs it.", false, + "Compare the Thread column for checkpoints 2 and 3."), + new ExplainAnswer("b", "The main thread's values are copied over the background thread's values for the rest of that thread's life.", false, + "The captured context only applies while the callback runs. What would the background thread see if it recorded its values again after ExecutionContext.Run(...) returns?"), + new ExplainAnswer("c", "The callback runs on the same background thread, but with the main thread's captured ExecutionContext, so it sees the main thread's culture, principal and AsyncLocal value.", true, + "Right. ExecutionContext.Run(...) applies the captured context for the duration of the callback, then puts the thread's own context back."), + ]), + new ExplainQuestion("task-run-flow", "Checkpoints 5 and 6 both run inside Task.Run(...). Why does checkpoint 5 see the main thread's values while checkpoint 6 sees defaults?", + [ + new ExplainAnswer("a", "Checkpoint 6 ran on a thread pool thread that had never seen the main thread's values.", false, + "The thread does not decide it: the same thread pool thread could run either checkpoint. Look at what each task captured when it was created."), + new ExplainAnswer("b", "Task.Run(...) captures the current ExecutionContext when it creates the task and restores it on the thread pool thread. Inside SuppressFlow() there is nothing to capture, so checkpoint 6 runs with the default, empty context.", true, + "Right. ExecutionContext belongs to the work item, not to the thread that happens to run it."), + new ExplainAnswer("c", "SuppressFlow() cleared the culture, principal and AsyncLocal value that the main thread assigned.", false, + "SuppressFlow() stops ExecutionContext from being captured. It does not clear anything: the code after the using block still has the main thread's values."), + ]), + new ExplainQuestion("await-after-the-using-block", "Why is the checkpoint 6 task created inside using (ExecutionContext.SuppressFlow()) but awaited only after the block ends?", + [ + new ExplainAnswer("a", "Awaiting inside the block would deadlock the main thread.", false, + "Nothing blocks a thread here. Click Try it to see what really happens when the await is inside the block."), + new ExplainAnswer("b", "The task does not start running until the using block ends.", false, + "Task.Run(...) queues the work right away. The using block only decides whether ExecutionContext is captured."), + new ExplainAnswer("c", "SuppressFlow() returns a thread-affine AsyncFlowControl. An await inside the block lets the continuation end the block, often on another thread and with flow no longer suppressed, and disposing the AsyncFlowControl there throws InvalidOperationException.", true, + "Right. Create the task inside the block, leave the block on the thread that entered it, and only then await."), + ]), + ]; + + public override string GetHint(int checkpoint, string columnId) => checkpoint switch + { + 1 => "Checkpoint 1 runs right after the main thread assigns CultureInfo.CurrentCulture, Thread.CurrentPrincipal and _asyncLocalData.Value. Look at the three lines above it.", + 2 => "The background thread assigned three values of its own just before checkpoint 2. Those assignments change the ExecutionContext of the thread that made them.", + 3 => "Look at the first argument to ExecutionContext.Run(...). The callback runs on the background thread (compare the Thread column with checkpoint 2), but with a context that was captured somewhere else.", + 4 => "The background thread assigned its own values and then finished. Did its assignments change the main thread's ExecutionContext, or only its own?", + 5 => "Task.Run(...) captured ExecutionContext when the task was created. Which thread created it, and which values did that thread have at the time?", + _ => "This task was created inside using (ExecutionContext.SuppressFlow()). What can Task.Run(...) capture while flow is suppressed?", + }; + + public override async Task> RunAsync(Func invokeAsync, CancellationToken token) + { + var log = new CheckpointLog(); + var experiment = new ExecutionContextExperiment(log); + + await RunOnDedicatedThread(experiment.RunAsync, token).ConfigureAwait(false); + + return [.. log.Observations.Select(observation => new CheckpointResult( + observation.Checkpoint, + observation.ThreadId, + observation.IsThreadPoolThread, + new Dictionary + { + [_valuesColumn] = Classify(observation.Value), + }, + [ + $"Culture: {observation.Value.Culture}", + $"Principal: {observation.Value.PrincipalType ?? "null"}", + $"AsyncLocal: {observation.Value.AsyncLocalData ?? "null"}", + ]))]; + } + + // Runs AwaitInsideSuppressFlowAsync() on its own dedicated thread, the same way RunAsync() starts the experiment, and never lets its exception escape + public async Task TryAwaitingInsideTheUsingBlock(ILogger logger, CancellationToken token) + { + var log = new CheckpointLog(); + var experiment = new ExecutionContextExperiment(log); + string? exceptionType = null; + + try + { + await RunOnDedicatedThread(experiment.AwaitInsideSuppressFlowAsync, token).ConfigureAwait(false); + } + catch (InvalidOperationException e) + { + // Expected: the full exception belongs in the terminal, not in the browser + logger.LogInformation(e, "Try it: awaiting inside using (ExecutionContext.SuppressFlow()) threw, as expected"); + exceptionType = e.GetType().Name; + } + + var observations = log.Observations; + + return new TryItOutcome( + exceptionType, + observations.FirstOrDefault(static observation => observation.Checkpoint is 1)?.ThreadId, + observations.FirstOrDefault(static observation => observation.Checkpoint is 3)?.ThreadId, + DateTimeOffset.Now); + } + + ObservedValue Classify(ExecutionContextValues values) + { + // The values ExecutionContextExperiment assigns on its main thread and on its background thread + var choiceId = values switch + { + { Culture: "es-ES", PrincipalType: nameof(ClaimsPrincipal), AsyncLocalData: "Initial Value" } => _mainThreadValues, + { Culture: "en-GB", PrincipalType: nameof(CustomPrincipal), AsyncLocalData: "AsyncLocalData in Thread" } => _backgroundThreadValues, + { PrincipalType: null, AsyncLocalData: null } => _defaultValues, + _ => _mixedValues, + }; + + var label = Columns[0].Choices.FirstOrDefault(choice => choice.Id == choiceId)?.Label ?? "A mix of values"; + + return new ObservedValue(choiceId, label); + } +} \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Steps/Step3Principal.cs b/3. .NET Internals/InternalsLab/Steps/Step3Principal.cs new file mode 100644 index 0000000..996ac2b --- /dev/null +++ b/3. .NET Internals/InternalsLab/Steps/Step3Principal.cs @@ -0,0 +1,122 @@ +namespace InternalsLab; + +// This file holds the answers to Step 3. Run the experiment and answer the questions on the page before you read it. +public sealed class Step3Principal : WorkshopStep +{ + public const int StepNumber = 3; + + const string _threadCurrentPrincipalColumn = "thread-current-principal"; + const string _httpContextAccessorColumn = "http-context-accessor"; + const string _controllerHttpContextColumn = "controller-http-context"; + const string _signedInUserColumn = "signed-in-user"; + const string _nameValue = "name"; + const string _nullValue = "null"; + + static readonly IReadOnlyList _nameOrNull = + [ + new PredictionChoice(_nameValue, "Your name"), + new PredictionChoice(_nullValue, "null"), + ]; + + public override int Number => StepNumber; + + public override string Scenario => "Principal"; + + public override string Title => "Thread.CurrentPrincipal, IHttpContextAccessor, HttpContext.User"; + + public override string Story => "Step 2 showed ExecutionContext flowing through a console app. This experiment shows the same mechanism inside an ASP.NET Core request, where the ambient values are the signed-in user and the current HttpContext. " + + "PrincipalController.RunExperiment() records four checkpoints, and at each one it asks for the signed-in user's name four different ways. " + + "Some of those values are still there after an await because ExecutionContext carried them. Others are simply object references the code already holds. Predict every cell as your name or null."; + + public override int RecommendedMinutes => 10; + + public override string ExperimentFile => "Controllers/PrincipalController.cs"; + + public override string? ExperimentUrl => "Principal/RunExperiment"; + + public override IReadOnlyList TryIt { get; } = + [ + "Delete Thread.CurrentPrincipal = signedInUser; and apply the change with Hot Reload, then click Run it again. Which cells change at checkpoints 2 and 3?", + "Replace await Task.Yield() with await Task.Delay(1).ConfigureAwait(false) and apply it with Hot Reload. Does httpContextAccessor still find the HttpContext at checkpoint 2?", + ]; + + public override IReadOnlyList Checkpoints { get; } = + [ + new ExperimentCheckpoint(1, "1. Start of the action"), + new ExperimentCheckpoint(2, "2. After await Task.Yield()"), + new ExperimentCheckpoint(3, "3. Inside Task.Run(...)"), + new ExperimentCheckpoint(4, "4. Inside Task.Run(...) started while ExecutionContext flow is suppressed"), + ]; + + public override IReadOnlyList Columns { get; } = + [ + new PredictionColumn(_threadCurrentPrincipalColumn, "Thread.CurrentPrincipal", _nameOrNull), + new PredictionColumn(_httpContextAccessorColumn, "httpContextAccessor.HttpContext?.User", _nameOrNull), + new PredictionColumn(_controllerHttpContextColumn, "HttpContext.User", _nameOrNull), + new PredictionColumn(_signedInUserColumn, "signedInUser", _nameOrNull), + ]; + + public override IReadOnlyList Questions { get; } = + [ + new ExplainQuestion("what-only-looks-like-it-flowed", "At checkpoint 4, why do HttpContext.User and signedInUser still show your name while Thread.CurrentPrincipal and httpContextAccessor.HttpContext?.User are null?", + [ + new ExplainAnswer("a", "ASP.NET Core copies HttpContext.User onto every thread pool thread that runs code for the request.", false, + "If it did, Thread.CurrentPrincipal would already hold your name at checkpoint 1. Ask which expressions read ambient state and which read objects the code already holds."), + new ExplainAnswer("b", "Suppressing flow only affects static properties, and HttpContext.User and signedInUser are instance values.", false, + "httpContextAccessor.HttpContext is an instance property too, and it came back null. What is behind that property?"), + new ExplainAnswer("c", "HttpContext.User is read through the controller instance and signedInUser is a captured local variable, so both are ordinary object references. The other two are looked up in AsyncLocal storage, which only ExecutionContext carries.", true, + "Right. A value that is still there after an await has not necessarily flowed. It may be an object reference the compiler kept for you."), + ]), + new ExplainQuestion("who-assigns-thread-current-principal", "Why is Thread.CurrentPrincipal null at checkpoint 1?", + [ + new ExplainAnswer("a", "Nothing in ASP.NET Core assigns it. The authentication middleware assigns HttpContext.User, and Thread.CurrentPrincipal only holds a user if your own code puts it there.", true, + "Right. In ASP.NET Core, HttpContext.User, also exposed as the controller's User property, is the principal to use."), + new ExplainAnswer("b", "The sign-in cookie is not read until the action's first await.", false, + "Three columns already show your name at checkpoint 1, so the cookie was read before the action ran. Open Program.cs to see what reads it."), + new ExplainAnswer("c", "[Authorize] clears Thread.CurrentPrincipal before the action runs.", false, + "[Authorize] only checks HttpContext.User. Look in Program.cs and AccountController.cs for anything that assigns Thread.CurrentPrincipal."), + ]), + new ExplainQuestion("run-to-run", "Run the experiment a few times. What changes from run to run, and why?", + [ + new ExplainAnswer("a", "The names and nulls change, depending on which thread happens to run each checkpoint.", false, + "Run it again and compare the two runs. The Thread column shows the previous run's thread IDs too. Did any name or null change?"), + new ExplainAnswer("b", "Nothing changes, because each checkpoint always runs on the same thread.", false, + "Run it again and compare the Thread column with the previous run's thread IDs."), + new ExplainAnswer("c", "Only the thread IDs change. The names and nulls never do, because ExecutionContext belongs to the work item, not to the thread that happens to run it.", true, + "Right. Read the columns, not the rows."), + ]), + ]; + + public static IReadOnlyList ToResults(IReadOnlyList checkpoints) => + [.. checkpoints.Select(static (checkpoint, index) => new CheckpointResult( + index + 1, + checkpoint.ThreadId, + null, + new Dictionary + { + [_threadCurrentPrincipalColumn] = Observe(checkpoint.ThreadCurrentPrincipal), + [_httpContextAccessorColumn] = Observe(checkpoint.HttpContextAccessorUser), + [_controllerHttpContextColumn] = Observe(checkpoint.ControllerHttpContextUser), + [_signedInUserColumn] = Observe(checkpoint.SignedInUserVariable), + }, + []))]; + + public override string GetHint(int checkpoint, string columnId) => (columnId, checkpoint) switch + { + (_threadCurrentPrincipalColumn, 1) => "Nothing in RunExperiment() has assigned Thread.CurrentPrincipal yet. Open Program.cs and AccountController.cs: does anything in ASP.NET Core assign it for you?", + (_threadCurrentPrincipalColumn, 2) => "The action assigned Thread.CurrentPrincipal right before await Task.Yield(), and the continuation usually runs on a different thread. What does await capture and restore on the new thread?", + (_threadCurrentPrincipalColumn, 3) => "Task.Run(...) captured ExecutionContext when the action created the task, after Thread.CurrentPrincipal was assigned.", + (_threadCurrentPrincipalColumn, _) => "This task was created inside using (ExecutionContext.SuppressFlow()). Thread.CurrentPrincipal is stored in ExecutionContext, so ask what the task captured.", + (_httpContextAccessorColumn, 1) => "The authentication middleware assigned HttpContext.User before the controller ran, and IHttpContextAccessor finds the HttpContext of the request that is running.", + (_httpContextAccessorColumn, 2 or 3) => "IHttpContextAccessor.HttpContext is looked up in an AsyncLocal, and the code is on a different thread now. What carries AsyncLocal values to that thread?", + (_httpContextAccessorColumn, _) => "The httpContextAccessor object is still reachable here, but its HttpContext property is an AsyncLocal lookup. What did a task created with flow suppressed capture?", + (_controllerHttpContextColumn, _) => "HttpContext is a property of the controller instance, which MVC assigned when it created the controller. Does reading a property of an object you hold depend on the thread, or on ExecutionContext?", + _ => "signedInUser is a local variable that the Observe local function uses, so the compiler keeps it in a closure object shared by the async state machine and both lambdas. Which threads can read it?", + }; + + // Step 3 runs in an MVC request, not in this Blazor circuit: the page loads ExperimentUrl, and PrincipalController records the results + public override Task> RunAsync(Func invokeAsync, CancellationToken token) => + throw new NotSupportedException($"Step {StepNumber} runs in an MVC request. Load {ExperimentUrl} instead."); + + static ObservedValue Observe(string? name) => name is null ? new ObservedValue(_nullValue, "null") : new ObservedValue(_nameValue, name); +} \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Steps/Step4SynchronizationContext.cs b/3. .NET Internals/InternalsLab/Steps/Step4SynchronizationContext.cs new file mode 100644 index 0000000..9e50c8a --- /dev/null +++ b/3. .NET Internals/InternalsLab/Steps/Step4SynchronizationContext.cs @@ -0,0 +1,123 @@ +namespace InternalsLab; + +// This file holds the answers to Step 4. Run the experiment and answer the questions on the page before you read it. +public sealed class Step4SynchronizationContext : WorkshopStep +{ + const string _contextColumn = "context"; + const string _rendererContext = "renderer"; + const string _noContext = "none"; + const string _aspNetContext = "aspnet"; + const string _otherContext = "other"; + + public override int Number => 4; + + public override string Scenario => "SynchronizationContext"; + + public override string Title => "SynchronizationContext.Current, ConfigureAwait(false), InvokeAsync(...)"; + + public override string Story => "Steps 2 and 3 showed the values that ride on ExecutionContext. await captures one more piece of ambient state: SynchronizationContext.Current, the place it posts the continuation back to. " + + "In Blazor Server that is the renderer's RendererSynchronizationContext, which is what makes it safe to change component state, and it is exactly what ConfigureAwait(false) opts out of. " + + "The experiment is a refresh shaped like RefreshAsync() from Correcting Common Async Await Mistakes, with a simulated story feed instead of Hacker News. " + + "Run the experiment calls it straight from this page's click handler, so it starts on Blazor's renderer. Predict SynchronizationContext.Current at every checkpoint."; + + public override int RecommendedMinutes => 10; + + public override string ExperimentFile => "Experiments/SynchronizationContextExperiment.cs"; + + public override IReadOnlyList TryIt { get; } = + [ + "Remove ConfigureAwait(false) from the await before checkpoint 4 and apply the change with Hot Reload, then click Run it again. Which checkpoints change?", + "Remove the top story from the cache in Experiments/SimulatedStoryFeed.cs and apply the change with Hot Reload. What does checkpoint 3 see now?", + ]; + + public override IReadOnlyList Checkpoints { get; } = + [ + new ExperimentCheckpoint(1, "1. Before the first await, in the click handler"), + new ExperimentCheckpoint(2, "2. After a plain await on GetTopStoryIDs()"), + new ExperimentCheckpoint(3, "3. After ConfigureAwait(false) on a story that was already cached"), + new ExperimentCheckpoint(4, "4. After ConfigureAwait(false) on a story that had to download"), + new ExperimentCheckpoint(5, "5. After a plain await that started where checkpoint 4 left off"), + new ExperimentCheckpoint(6, "6. Inside InvokeAsync(...)"), + ]; + + public override IReadOnlyList Columns { get; } = + [ + new PredictionColumn(_contextColumn, "SynchronizationContext.Current", + [ + new PredictionChoice(_rendererContext, "RendererSynchronizationContext (Blazor's renderer)"), + new PredictionChoice(_noContext, "null (no SynchronizationContext)"), + new PredictionChoice(_aspNetContext, "AspNetSynchronizationContext (the request's context)"), + ]), + ]; + + public override IReadOnlyList Questions { get; } = + [ + new ExplainQuestion("no-ui-thread", "Checkpoint 2 reports RendererSynchronizationContext, yet it can run on a different thread from checkpoint 1. What does that tell you about Blazor Server?", + [ + new ExplainAnswer("a", "The await lost the context, so Blazor started a new UI thread for the component.", false, + "If the context had been lost, checkpoint 2 would report null. Look at what it does report."), + new ExplainAnswer("b", "Blazor Server has no UI thread. The renderer's context runs its work one item at a time on thread pool threads, so a continuation posted to it comes back to the context, not to a particular thread.", true, + "Right. The context is what serializes access to component state. Which thread it happens to borrow does not matter."), + new ExplainAnswer("c", "Every circuit has one UI thread, and its managed thread ID changes after each await.", false, + "A thread's managed ID never changes. Different IDs mean different threads."), + ]), + new ExplainQuestion("synchronous-completion", "Checkpoint 3 comes after ConfigureAwait(false), but still reports RendererSynchronizationContext. Why?", + [ + new ExplainAnswer("a", "The cached story's ValueTask had already completed, so the await never scheduled a continuation. The method kept running on the same thread, in the same context.", true, + "Right. ConfigureAwait(false) only affects continuations that are actually scheduled, so it never guarantees that the code after it leaves the context."), + new ExplainAnswer("b", "ConfigureAwait(false) only works on Task, not on ValueTask.", false, + "Checkpoint 4 awaits a ValueTask from the same method, with ConfigureAwait(false), and does leave the context. What is different about the story it asks for?"), + new ExplainAnswer("c", "Blazor ignores ConfigureAwait(false) inside event handlers.", false, + "Checkpoint 4 is in the same event handler and does leave the context. What is different about the story it asks for?"), + ]), + new ExplainQuestion("plain-await-does-not-come-back", "Checkpoint 5 comes after a plain await, but reports null. Why doesn't that await bring you back to the renderer?", + [ + new ExplainAnswer("a", "The renderer was busy, so the await gave up waiting for it and used the thread pool instead.", false, + "An await never gives up on a context it captured. Ask what SynchronizationContext.Current was when that await ran."), + new ExplainAnswer("b", "ConfigureAwait(false) turns off context capture for every later await in the whole app.", false, + "Checkpoint 6 is back on the renderer, and the next click starts there again. Ask what SynchronizationContext.Current was when checkpoint 5's await ran."), + new ExplainAnswer("c", "A plain await captures whatever SynchronizationContext is current when that await runs. After checkpoint 4 there was none, so there was nothing to come back to. InvokeAsync(...) is how you get back.", true, + "Right. That is why code after ConfigureAwait(false) marshals every component state change through InvokeAsync(...)."), + ]), + ]; + + public override string GetHint(int checkpoint, string columnId) => checkpoint switch + { + 1 => "Blazor runs every event handler through its renderer, and RefreshAsync() has not awaited anything yet. What is SynchronizationContext.Current while an event handler runs?", + 2 => "A plain await captures SynchronizationContext.Current and posts the continuation back to it. Compare the Thread column with checkpoint 1: the thread can change while the context stays the same.", + 3 => "The top story was already in the feed's cache, so its ValueTask had completed before the await looked at it. ConfigureAwait(false) only changes where a continuation is scheduled. Was one scheduled?", + 4 => "This story had to wait for the simulated network, so the await scheduled a continuation, and ConfigureAwait(false) told it not to capture the context. What posts that continuation back to the renderer?", + 5 => "A plain await captures whatever SynchronizationContext is current when that await runs. What was current at checkpoint 4?", + _ => "InvokeAsync(...) runs the lambda through Blazor's renderer. Compare the Thread column with checkpoint 5: the thread can be the same while the context is not.", + }; + + public override async Task> RunAsync(Func invokeAsync, CancellationToken token) + { + var log = new CheckpointLog(); + var experiment = new SynchronizationContextExperiment(log); + + // No Task.Run and no ConfigureAwait(false) before this call: RefreshAsync() starts synchronously, + // on Blazor's renderer, exactly like the click handler that called this method + var refreshTask = experiment.RefreshAsync(invokeAsync, token); + + await refreshTask.WaitAsync(Timeout, token).ConfigureAwait(false); + + return [.. log.Observations.Select(static observation => new CheckpointResult( + observation.Checkpoint, + observation.ThreadId, + observation.IsThreadPoolThread, + new Dictionary + { + [_contextColumn] = new ObservedValue(Classify(observation.Value), observation.Value ?? "null"), + }, + []))]; + } + + static string Classify(string? synchronizationContextName) => synchronizationContextName switch + { + "RendererSynchronizationContext" => _rendererContext, + null => _noContext, + "AspNetSynchronizationContext" => _aspNetContext, + _ => _otherContext, + }; +} \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Steps/WorkshopStep.cs b/3. .NET Internals/InternalsLab/Steps/WorkshopStep.cs new file mode 100644 index 0000000..9cfda13 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Steps/WorkshopStep.cs @@ -0,0 +1,72 @@ +using System.Diagnostics; + +namespace InternalsLab; + +// Every step is one experiment about where .NET keeps the state that async code depends on. Nothing is broken, so there is nothing to fix. +// Its page asks you to predict every checkpoint, runs the experiment, shows what really happened next to your predictions, +// and asks you to explain what you saw. The step files hold the answers, so run the experiment before you read them. +public abstract class WorkshopStep +{ + // Long enough for any experiment, and short enough that a Try it change that blocks forever cannot hang the page. + // With a debugger attached there is no limit, so pausing at a breakpoint never turns into a timeout. + public static TimeSpan Timeout => Debugger.IsAttached ? System.Threading.Timeout.InfiniteTimeSpan : TimeSpan.FromSeconds(10); + + public abstract int Number { get; } + + // The piece of ambient state this step investigates, in a few words + public abstract string Scenario { get; } + + // The APIs the experiment exercises + public abstract string Title { get; } + + // Why this state matters, and what the experiment does + public abstract string Story { get; } + + public abstract int RecommendedMinutes { get; } + + // The code to read, and where the step's // Step N: comments are + public abstract string ExperimentFile { get; } + + // The same suggestions as the // Try it: comments in the experiment code + public abstract IReadOnlyList TryIt { get; } + + public abstract IReadOnlyList Checkpoints { get; } + + public abstract IReadOnlyList Columns { get; } + + public abstract IReadOnlyList Questions { get; } + + // Steps whose experiment is an MVC request return its URL, and the page loads it instead of calling RunAsync() + public virtual string? ExperimentUrl => null; + + public static string PredictionKey(int checkpoint, string columnId) => $"{checkpoint}:{columnId}"; + + public int CountPredictions(IReadOnlyDictionary predictions) => + Checkpoints.Sum(checkpoint => Columns.Count(column => predictions.ContainsKey(PredictionKey(checkpoint.Number, column.Id)))); + + public bool HasAllPredictions(IReadOnlyDictionary predictions) => CountPredictions(predictions) == Checkpoints.Count * Columns.Count; + + public bool IsAnsweredCorrectly(ExplainQuestion question, IReadOnlyDictionary answers) => + question.FindAnswer(answers.GetValueOrDefault(question.Id))?.IsCorrect is true; + + // What to look at when a result does not match your prediction. It points at the code, not at the answer. + public abstract string GetHint(int checkpoint, string columnId); + + // Runs the experiment inside this Blazor circuit and returns what every checkpoint saw. + // The page calls it straight from its click handler, so it starts on Blazor's renderer. + public abstract Task> RunAsync(Func invokeAsync, CancellationToken token); + + // Starts an experiment on a new dedicated thread, which plays the part of a console app's main thread. + // Flow is suppressed while the thread starts, so none of this Blazor circuit's ExecutionContext leaks into the experiment, + // and nothing the experiment assigns can leak back out. The task is awaited after the using block, on the thread that created it. + protected static async Task RunOnDedicatedThread(Func experiment, CancellationToken token) + { + Task experimentTask; + using (ExecutionContext.SuppressFlow()) + { + experimentTask = Task.Factory.StartNew(experiment, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); + } + + await experimentTask.WaitAsync(Timeout, token).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/3. .NET Internals/InternalsLab/Views/Account/SignIn.cshtml b/3. .NET Internals/InternalsLab/Views/Account/SignIn.cshtml new file mode 100644 index 0000000..18398e5 --- /dev/null +++ b/3. .NET Internals/InternalsLab/Views/Account/SignIn.cshtml @@ -0,0 +1,56 @@ +@{ + var isSignedIn = User.Identity?.IsAuthenticated is true; +} + + + + + + Sign in · InternalsLab + + + +
+ +
+ +
+
+
+
+

Step 3 · Principal

+

@(isSignedIn ? "You are signed in" : "Sign in")

+

There is no password. The app stores the name you type in a sign-in cookie, and every request after that reads it back.

+
+
+ + +
+
+ + diff --git a/3. .NET Internals/InternalsLab/Views/_ViewImports.cshtml b/3. .NET Internals/InternalsLab/Views/_ViewImports.cshtml new file mode 100644 index 0000000..da31c0b --- /dev/null +++ b/3. .NET Internals/InternalsLab/Views/_ViewImports.cshtml @@ -0,0 +1,2 @@ +@using InternalsLab +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/3. .NET Internals/3. Principal/PrincipalExample/appsettings.Development.json b/3. .NET Internals/InternalsLab/appsettings.Development.json similarity index 100% rename from 3. .NET Internals/3. Principal/PrincipalExample/appsettings.Development.json rename to 3. .NET Internals/InternalsLab/appsettings.Development.json diff --git a/3. .NET Internals/3. Principal/PrincipalExample/appsettings.json b/3. .NET Internals/InternalsLab/appsettings.json similarity index 100% rename from 3. .NET Internals/3. Principal/PrincipalExample/appsettings.json rename to 3. .NET Internals/InternalsLab/appsettings.json diff --git a/3. .NET Internals/InternalsLab/wwwroot/app.css b/3. .NET Internals/InternalsLab/wwwroot/app.css new file mode 100644 index 0000000..0762cd0 --- /dev/null +++ b/3. .NET Internals/InternalsLab/wwwroot/app.css @@ -0,0 +1,744 @@ +:root { + --canvas: #f1eff8; + --panel: #ffffff; + --panel-strong: #1d1233; + --ink: #1c1929; + --muted: #645d78; + --line: #ddd8ea; + --accent: #512bd4; + --accent-dark: #3d1fa8; + --link: #4b2bbf; + --good: #0f8a52; + --bad: #cf202f; + --warn: #c59d35; + --shadow: 0 18px 55px rgb(33 22 66 / 16%); + color-scheme: light; + font-family: "Aptos", "Avenir Next", "Segoe UI", sans-serif; + background: var(--canvas); + color: var(--ink); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + overflow-x: clip; + background: + linear-gradient(135deg, rgb(81 43 212 / 12%), transparent 32%), + radial-gradient(circle at top right, rgb(0 164 239 / 12%), transparent 30%), + var(--canvas); +} + +code { + font-family: "Cascadia Code", "SF Mono", Menlo, Consolas, monospace; + font-size: 0.92em; +} + +/* FocusOnNavigate moves focus to the page heading for screen readers; the heading is not interactive, so hide the ring */ +h1:focus { + outline: none; +} + +.shell { + min-height: 100vh; +} + +.page { + width: min(1180px, calc(100% - 32px)); + margin: 0 auto; + padding: 32px 0 56px; +} + +.top-bar { + border-bottom: 1px solid var(--line); + background: rgb(255 255 255 / 70%); +} + +.top-bar-links { + display: flex; + flex-wrap: wrap; + gap: 6px; + width: min(1180px, calc(100% - 32px)); + margin: 0 auto; + padding: 8px 0; +} + +.top-bar-link { + padding: 6px 12px; + border-radius: 6px; + color: var(--ink); + font-size: 0.9rem; + font-weight: 600; + text-decoration: none; +} + +.top-bar-link:hover, +.top-bar-link.active { + background: var(--canvas); + color: var(--accent); +} + +.hero-panel { + display: grid; + grid-template-columns: 1fr auto; + gap: 28px; + align-items: end; + margin-bottom: 22px; + padding: 30px; + border: 1px solid rgb(36 35 31 / 10%); + border-radius: 8px; + background: linear-gradient(135deg, var(--panel-strong), #33206b 58%, var(--accent)); + box-shadow: var(--shadow); + color: #f8f6ff; +} + +.hero-panel a { + color: #d9ceff; +} + +.hero-copy { + max-width: 720px; +} + +.eyebrow { + margin: 0 0 6px; + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: rgb(248 246 255 / 72%); +} + +.hero-panel h1 { + margin: 0 0 10px; + font-size: clamp(1.8rem, 3vw, 2.5rem); + line-height: 1.1; +} + +.hero-summary { + margin: 0; + color: rgb(248 246 255 / 82%); +} + +.hero-actions { + display: grid; + gap: 10px; + justify-items: end; +} + +.action { + display: inline-block; + padding: 12px 22px; + border: 0; + border-radius: 6px; + background: var(--accent); + color: #fff; + font: inherit; + font-weight: 600; + text-decoration: none; + cursor: pointer; +} + +.hero-panel a.action, +.panel a.action { + color: #fff; +} + +.action:hover:not(:disabled) { + background: var(--accent-dark); +} + +.action:disabled { + background: var(--muted); + cursor: not-allowed; +} + +.action.action-quiet { + background: var(--canvas); + color: var(--ink); + border: 1px solid var(--line); +} + +.action.action-quiet:hover:not(:disabled) { + background: var(--line); +} + +.hero-panel .action { + background: #fff; + color: var(--accent-dark); +} + +.hero-panel a.action { + color: var(--accent-dark); +} + +.hero-panel .action:hover { + background: #ece6ff; +} + +.status-tile { + display: grid; + gap: 4px; + justify-items: end; + padding: 14px 18px; + border: 1px solid rgb(248 246 255 / 22%); + border-radius: 8px; + background: rgb(248 246 255 / 10%); +} + +.status-value { + font-size: 1.9rem; + font-weight: 700; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.status-label { + font-size: 0.82rem; + color: rgb(248 246 255 / 78%); +} + +.spinner { + display: inline-block; + width: 14px; + height: 14px; + margin-right: 8px; + border: 2px solid rgb(255 255 255 / 35%); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.8s linear infinite; + vertical-align: -2px; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.layout { + display: grid; + grid-template-columns: 260px 1fr; + gap: 22px; + align-items: start; +} + +.content { + display: grid; + gap: 16px; + min-width: 0; +} + +/* Lets a wide table scroll inside its panel instead of widening the page */ +.content > * { + min-width: 0; +} + +.panel { + padding: 22px 24px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + box-shadow: 0 10px 26px rgb(33 22 66 / 8%); +} + +.panel h2 { + margin: 0 0 8px; + font-size: 1.15rem; +} + +.panel p { + margin: 0 0 10px; + line-height: 1.5; +} + +.panel a, +.banner a { + color: var(--link); +} + +.panel-hint { + color: var(--muted); + font-size: 0.92rem; +} + +.step-nav { + position: sticky; + top: 16px; + display: grid; + gap: 6px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); +} + +.step-nav-link { + display: grid; + gap: 4px; + padding: 8px 10px; + border-radius: 6px; + color: var(--ink); + text-decoration: none; +} + +.step-nav-link:hover, +.step-nav-link.active { + background: var(--canvas); +} + +.step-nav-link.active { + font-weight: 600; +} + +.step-nav-title { + font-size: 0.9rem; +} + +.how-it-works { + margin: 8px 0 0; + padding-left: 20px; + line-height: 1.55; +} + +.how-it-works li + li { + margin-top: 4px; +} + +.facts { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 14px; + margin: 12px 0; +} + +.facts dt { + font-weight: 600; + color: var(--muted); +} + +.facts dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; +} + +.ai-note { + padding: 10px 14px; + border-left: 4px solid var(--warn); + border-radius: 6px; + background: #fdf8e9; + color: #6b551a; +} + +.clues { + margin-top: 6px; + padding: 10px 14px; + border: 1px solid var(--line); + border-radius: 6px; + background: #fbfaff; +} + +.clues summary { + font-weight: 600; + cursor: pointer; +} + +.clues .panel-hint { + margin: 10px 0 0; +} + +.clues ul { + margin: 10px 0 0; + padding-left: 20px; + line-height: 1.55; +} + +.step-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 14px; +} + +.step-card { + display: grid; + gap: 6px; + padding: 16px 18px; + border: 1px solid var(--line); + border-left: 4px solid var(--accent); + border-radius: 8px; + background: var(--panel); + color: var(--ink); + text-decoration: none; + box-shadow: 0 10px 26px rgb(33 22 66 / 8%); +} + +.step-card:hover { + border-left-color: #00a4ef; +} + +.step-card-number { + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.step-card-scenario { + font-size: 1.05rem; + font-weight: 700; +} + +.step-card-title { + color: var(--muted); + overflow-wrap: anywhere; +} + +.badge { + justify-self: start; + padding: 2px 10px; + border-radius: 999px; + background: var(--canvas); + color: var(--muted); + font-size: 0.8rem; + font-weight: 600; + white-space: nowrap; +} + +.hero-panel .badge { + justify-self: end; +} + +.badge-passed { + background: #e3f4ec; + color: var(--good); +} + +.badge-predicted { + background: #fdf8e9; + color: #6b551a; +} + +.badge-ran { + background: #ece6ff; + color: var(--accent-dark); +} + +.banner { + display: grid; + gap: 4px; + padding: 12px 16px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + line-height: 1.5; +} + +.banner-passed { + border-color: rgb(15 138 82 / 30%); + background: #e3f4ec; + color: #0b6b40; +} + +.banner-failed { + border-color: rgb(207 32 47 / 30%); + background: #fbe5e7; + color: #a31825; +} + +.banner-locked { + color: var(--muted); +} + +.banner-note { + font-size: 0.85rem; + opacity: 0.85; +} + +/* @key recreates the banner for every new result, so this animation plays again after each click */ +.banner-fresh { + animation: banner-in 0.35s ease-out; +} + +@keyframes banner-in { + from { + opacity: 0; + transform: translateY(-6px); + } + + to { + opacity: 1; + transform: none; + } +} + +.table-scroll { + overflow-x: auto; +} + +.checklist { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.checklist th, +.checklist td { + padding: 8px 10px; + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: top; +} + +.checklist th { + color: var(--muted); + font-weight: 600; +} + +.check-failed td { + background: #fbe5e7; +} + +.check-failed td, +.check-hint td { + border-bottom-color: transparent; +} + +.check-hint td { + padding-top: 0; + background: #fbe5e7; + color: #7a1520; + font-size: 0.88rem; + line-height: 1.5; +} + +.check-hint + tr td, +.checklist tr:last-child td { + border-top: 1px solid var(--line); +} + +.results td:nth-child(3), +.results td:nth-child(4) { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.results .details { + display: grid; + gap: 2px; + margin-top: 4px; + font-family: "Cascadia Code", "SF Mono", Menlo, Consolas, monospace; + font-size: 0.82rem; + white-space: nowrap; +} + +.predictions td:first-child, +.results td:nth-child(2) { + min-width: 190px; +} + +.grid-results td:first-child { + min-width: 150px; +} + +.grid-results td:nth-child(2) { + font-variant-numeric: tabular-nums; +} + +.cell-result { + display: inline-flex; + gap: 6px; + align-items: center; + white-space: nowrap; +} + +.previous { + display: block; + margin-top: 4px; + color: var(--muted); + font-size: 0.8rem; +} + +.predictions td { + vertical-align: middle; +} + +.prediction { + width: 100%; + min-width: 190px; + padding: 7px 8px; + border: 1px solid var(--line); + border-radius: 6px; + background: #fff; + color: var(--ink); + font: inherit; +} + +.grid-predictions .prediction { + min-width: 120px; +} + +.prediction:disabled { + background: var(--canvas); + color: var(--muted); +} + +.run-row { + display: flex; + flex-wrap: wrap; + gap: 10px 16px; + align-items: center; + margin-bottom: 14px; +} + +.run-note { + color: var(--muted); + font-size: 0.92rem; +} + +.value-pill { + padding: 2px 8px; + border-radius: 12px; + font-weight: 600; + white-space: nowrap; +} + +.value-name { + background: #dcf2e3; + color: #11602f; +} + +.value-null { + background: #fbe1e1; + color: #9b1c1c; + font-family: "Cascadia Code", "SF Mono", Menlo, Consolas, monospace; +} + +.question { + margin: 0 0 14px; + padding: 14px 16px; + border: 1px solid var(--line); + border-radius: 8px; +} + +.question legend { + padding: 0 6px; + font-weight: 600; + line-height: 1.45; +} + +.answer { + display: flex; + gap: 10px; + align-items: flex-start; + margin-top: 6px; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: 6px; + line-height: 1.45; + cursor: pointer; +} + +.answer:hover { + background: #fbfaff; +} + +.answer input { + margin-top: 4px; +} + +.answer-correct { + border-color: rgb(15 138 82 / 30%); + background: #e3f4ec; +} + +.answer-wrong { + border-color: rgb(207 32 47 / 30%); + background: #fbe5e7; +} + +.feedback { + margin: 10px 0 0; + padding: 8px 12px; + border-radius: 6px; + font-size: 0.92rem; +} + +.feedback-correct { + background: #e3f4ec; + color: #0b6b40; +} + +.feedback-wrong { + background: #fdf8e9; + color: #6b551a; +} + +.sign-in-panel .form-row, +.form-row { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: end; + margin-bottom: 10px; +} + +.form-row form { + margin: 0; +} + +.field { + display: grid; + gap: 4px; + font-weight: 600; +} + +.field input { + min-width: 240px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 6px; + font: inherit; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +@media (max-width: 800px) { + .layout { + grid-template-columns: 1fr; + } + + .step-nav { + position: static; + } +} + +@media (max-width: 640px) { + .hero-panel { + grid-template-columns: 1fr; + align-items: start; + } + + .hero-actions, + .status-tile { + justify-items: start; + } + + .hero-panel .badge { + justify-self: start; + } + + .facts { + grid-template-columns: 1fr; + } +} \ No newline at end of file diff --git a/3. .NET Internals/README.md b/3. .NET Internals/README.md index 3fe054c..02099fb 100644 --- a/3. .NET Internals/README.md +++ b/3. .NET Internals/README.md @@ -2,44 +2,71 @@ In this section, you will inspect the internal state that .NET uses to flow asynchronous data across threads. -## 1. ThreadStatic Challenge +**InternalsLab** is a lab notebook with four experiments: `[ThreadStatic]` fields, `ExecutionContext`, the signed-in user, and `SynchronizationContext`. Nothing in it is broken, and there is no Start or Finish folder. Your job is to predict what the code will see, run it, compare, and explain what happened in your own words. -Recommended time: 8 minutes. +## 1. Open the App -> **Note:** Please avoid letting AI Agents solve the challenges for you. You're smart. You got this. Use them to understand the existing code, clarify runtime concepts, interpret debugger observations, and ask questions that help you form your own explanation. +1. Using File Explorer on Windows or Finder on macOS, navigate to **BecomeAnExpertWithAsyncAwait/3. .NET Internals**. +2. Open **InternalsLab.slnx** in your IDE. +3. Run the project, ideally with your IDE's debugger attached, and open the app in your browser. -1. Open **1. Thread Static/ThreadStaticExample.slnx**. -2. Open **ThreadStaticExample/Program.cs**. -3. Before running the app, predict which values will be shared and which values will stay isolated to each thread. -4. Build and run the project. -5. Write down why the main thread keeps its value and why each background thread has its own value. +The app runs at [http://localhost:5003](http://localhost:5003). -## 2. ExecutionContext Challenge +The app opens on **Workshop steps**. Step 1 is unlocked, and every later step unlocks when the step before it passes. -Recommended time: 12 minutes. +## 2. Inspect the Code -> **Note:** Please avoid letting AI Agents solve the challenges for you. You're smart. You got this. Use them to understand the existing code, clarify runtime concepts, interpret debugger observations, and ask questions that help you form your own explanation. +1. Open the **InternalsLab/Experiments** folder. `ThreadStaticExperiment.cs` is Step 1, `ExecutionContextExperiment.cs` is Step 2, and `SynchronizationContextExperiment.cs` with `SimulatedStoryFeed.cs` is Step 4. Every `// Step N:` comment marks something to read before you predict, and every `// Try it:` comment suggests a change to explore once the step passes. +2. Open **InternalsLab/Controllers/PrincipalController.cs**. It is Step 3. The Principal experiment is an MVC controller, because it has to observe a real ASP.NET Core request. **InternalsLab/Controllers/AccountController.cs** and **InternalsLab/Program.cs** show how you get signed in. +3. Skim **InternalsLab/Notebook**, **InternalsLab/Components**, and **InternalsLab/Views**. They are the workshop plumbing that runs the experiments and draws the pages. You do not need to change them. +4. Leave **InternalsLab/Steps** for later. Those files hold the hints, the questions, and their answers. -1. Open **2. ExecutionContext/ExecutionContextExample.slnx**. -2. Open **ExecutionContextExample/Program.cs**. -3. Before running the app, predict the culture, principal, and `AsyncLocal` value at each `PrintThreadValues()` call. -4. Debug the project and step through each call. -5. Explain what changes when `ExecutionContext.Run(...)` is used. -6. Explain what changes when `Task.Run(...)` flows `ExecutionContext` automatically. -7. Explain why the task created inside `using (ExecutionContext.SuppressFlow())` sees default values. -8. Explain why the task is awaited only after leaving the `using` block. +The app walks you through the investigation one step at a time: -## 3. Principal Challenge +1. Step 1 is ThreadStatic, Step 2 is ExecutionContext, Step 3 is Principal, and Step 4 is SynchronizationContext. +2. A step unlocks only after the step before it passes. Each step page tells the story behind the experiment, names the file to read, and asks you to predict what every checkpoint will see. +3. **Run the experiment** stays disabled until you choose a prediction for every checkpoint, and your predictions lock in when it runs. They do not have to be right. +4. The results show what really happened next to what you predicted, with ✅ or ❌, and a hint about what to look at under every result that does not match. +5. Each step ends with explain questions. A wrong answer shows a hint, and you can choose again. A step passes when its experiment has run and every question is answered correctly. +6. The lab notebook keeps your predictions, results, and answers for as long as the app runs, so reloading a page is safe. Stopping the app starts a fresh notebook, so explore the `// Try it:` comments with Hot Reload, or after the group review. -Recommended time: 10 minutes. +## 3. Challenge: Investigate the Internals + +Recommended time: 40 minutes in total. Step 1 takes 8 minutes, Step 2 takes 12, Step 3 takes 10, and Step 4 takes 10. > **Note:** Please avoid letting AI Agents solve the challenges for you. You're smart. You got this. Use them to understand the existing code, clarify runtime concepts, interpret debugger observations, and ask questions that help you form your own explanation. -The previous challenge showed `ExecutionContext` flowing through a console app. This challenge shows the same mechanism inside an ASP.NET Core request, where the ambient values are the signed-in user and the current `HttpContext`. +Work through the steps in order on the Workshop steps page. For each one, read the experiment code before you predict. If you want to see it happen, set a breakpoint on a checkpoint and inspect the threads in your debugger. + +### Step 1: ThreadStatic + +Recommended time: 8 minutes. + +1. Open **InternalsLab/Experiments/ThreadStaticExperiment.cs**. +2. Before running the experiment, predict which values will be shared and which values will stay isolated to each thread, including after `await Task.Yield()`. +3. Run the experiment and compare the results with your predictions. +4. Explain why the main thread keeps its value, why each background thread has its own value, and what happens to the value after the `await`. -1. Open **3. Principal/PrincipalExample.slnx**. -2. Open **PrincipalExample/Controllers/HomeController.cs** and read `RunExperiment()`. Each `Observe(string)` call records a checkpoint: the current thread ID, plus the signed-in user's name as seen through four different expressions. -3. Before running the app, copy this grid into your notes. In each cell, predict whether that expression returns your name or `null` at that checkpoint: +### Step 2: ExecutionContext + +Recommended time: 12 minutes. + +1. Open **InternalsLab/Experiments/ExecutionContextExperiment.cs**. +2. Before running the experiment, predict the culture, principal, and `AsyncLocal` value at each `RecordThreadValues(int)` call. +3. Run the experiment. If you want to step through it, set a breakpoint in `RunAsync()` before you click. +4. Explain what changes when `ExecutionContext.Run(...)` is used. +5. Explain what changes when `Task.Run(...)` flows `ExecutionContext` automatically. +6. Explain why the task created inside `using (ExecutionContext.SuppressFlow())` sees default values. +7. Explain why the task is awaited only after leaving the `using` block, then click **Try it** to see what happens when it is awaited inside the block. + +### Step 3: Principal + +Recommended time: 10 minutes. + +The previous step showed `ExecutionContext` flowing through a console-style program. This step shows the same mechanism inside an ASP.NET Core request, where the ambient values are the signed-in user and the current `HttpContext`. + +1. Open **InternalsLab/Controllers/PrincipalController.cs** and read `RunExperiment()`. Each `Observe(string)` call records a checkpoint: the current thread ID, plus the signed-in user's name as seen through four different expressions. +2. On the Step 3 page, predict every cell of this grid as your name or `null`: | Checkpoint | `Thread.CurrentPrincipal` | `httpContextAccessor.HttpContext?.User` | `HttpContext.User` | `signedInUser` | | --- | --- | --- | --- | --- | @@ -48,30 +75,31 @@ The previous challenge showed `ExecutionContext` flowing through a console app. | 3. Inside `Task.Run(...)` | | | | | | 4. Inside `Task.Run(...)` started while `ExecutionContext` flow is suppressed | | | | | -4. Run or debug the project. If your IDE does not open a browser, navigate to [http://localhost:5000](http://localhost:5000). Sign in with your first name. -5. Select **Run the experiment**. Compare the four name columns with your predictions, and note which thread ran each checkpoint. -6. Select **Run it again** a few times. What changes from run to run, and what never changes? -7. For each column, explain why the value is or is not still there at checkpoints 2, 3, and 4. Which values did `ExecutionContext` carry, and which were simply object references the code already held? -8. Use checkpoint 1 to explain whether ASP.NET Core sets `Thread.CurrentPrincipal` for you. Open **PrincipalExample/Program.cs** to see where `HttpContext.User` gets its value. -9. Explain why the checkpoint 4 task is created inside the `using` block but awaited after it. +3. Select **Sign in** and sign in with your first name. You come straight back to Step 3. +4. Select **Run the experiment**. The page leaves the Blazor app for a normal MVC request, runs the experiment, and brings you back to compare the four name columns with your predictions. Note which thread ran each checkpoint. +5. Select **Run it again** a few times. What changes from run to run, and what never changes? +6. For each column, explain why the value is or is not still there at checkpoints 2, 3, and 4. Which values did `ExecutionContext` carry, and which were simply object references the code already held? +7. Use checkpoint 1 to explain whether ASP.NET Core sets `Thread.CurrentPrincipal` for you. Open **InternalsLab/Program.cs** to see where `HttpContext.User` gets its value. -## 4. SynchronizationContext Challenge +### Step 4: SynchronizationContext Recommended time: 10 minutes. -> **Note:** Please avoid letting AI Agents solve the challenges for you. You're smart. You got this. Use them to understand the existing code, clarify runtime concepts, interpret debugger observations, and ask questions that help you form your own explanation. +1. Open **InternalsLab/Experiments/SynchronizationContextExperiment.cs**. `RefreshAsync(...)` is shaped like the `RefreshAsync` method you fixed in Correcting Common Async Await Mistakes, with a simulated story feed instead of Hacker News. +2. Before running the experiment, predict the `SynchronizationContext` at each `RecordSynchronizationContext(int)` call: before the first `await`, after each continuation, and inside `InvokeAsync(...)`. +3. Run the experiment. The Step 4 page calls `RefreshAsync(...)` straight from its click handler, so it starts on Blazor's renderer. Compare the results and the thread IDs with your predictions. +4. Explain why `ConfigureAwait(false)` avoids capturing Blazor's synchronization context when a continuation is scheduled, why synchronous completion may leave the current context unchanged, and why a plain `await` after that does not bring you back. + +Acceptance checks: -1. Open **4. SynchronizationContext/HackerNews.slnx**. -2. Open **HackerNews/Components/Pages/News.razor.cs**. -3. Before running, predict the thread ID and `SynchronizationContext` that each `Logger.LogInformation(...)` call in `RefreshAsync(CancellationToken)` will report, both before `ConfigureAwait(false)` and after each continuation. -4. Run the app and open [http://localhost:5004](http://localhost:5004). -5. Read the `NewsPageBase` log lines and compare them with your predictions. -6. Explain why `ConfigureAwait(false)` avoids capturing Blazor's synchronization context when a continuation is scheduled, and why synchronous completion may leave the current context unchanged. +1. **InternalsLab.slnx** builds. +2. The Workshop steps page shows 4 of 4 steps pass. +3. You can explain every checkpoint that did not match your prediction in your own words. -## 5. Review the Solution +## 4. Review the Solution After you have attempted the investigation challenges, pause here for group review. -We will compare observations, debug through the samples together, and answer questions before opening [SOLUTION.md](SOLUTION.md) together. +We will compare observations, debug through the experiments together, and answer questions before opening [SOLUTION.md](SOLUTION.md) together. -The solution walkthrough gives the step-by-step path through each sample and the observations you should be able to explain. +The solution walkthrough gives the step-by-step path through each experiment and the observations you should be able to explain. diff --git a/3. .NET Internals/SOLUTION.md b/3. .NET Internals/SOLUTION.md index ea58de1..a70fbba 100644 --- a/3. .NET Internals/SOLUTION.md +++ b/3. .NET Internals/SOLUTION.md @@ -2,35 +2,53 @@ Use this during the guided walkthrough after the investigation challenges and group review in [README.md](README.md). -## 1. ThreadStatic +All four experiments live in one app, **InternalsLab**. There is no Start or Finish folder, because nothing is broken: every section below walks through one step's experiment, the results it produces, and the observations behind that step's explain questions. -Open **1. Thread Static/ThreadStaticExample/Program.cs** and inspect the `[ThreadStatic]` field: +Run the app: + +```console +dotnet run --project "3. .NET Internals/InternalsLab/InternalsLab.csproj" +``` + +Open [http://localhost:5003](http://localhost:5003). Your thread IDs, random values, and name will differ from the runs shown below. The predictions each step grades never do. + +## 1. ThreadStatic (Step 1) + +Open **InternalsLab/Experiments/ThreadStaticExperiment.cs** and inspect the `[ThreadStatic]` field: ```cs [ThreadStatic] static int _threadSpecificValue; ``` -Run the project: +The Step 1 page runs `RunAsync()` on a new dedicated thread that plays the part of a console app's main thread. The main thread assigns `100`, starts two background threads that each record the field before and after assigning a random value, waits for both with `Join()`, and then awaits `Task.Yield()`: -```console -dotnet run --project "1. Thread Static/ThreadStaticExample/ThreadStaticExample.csproj" +```cs +await Task.Yield(); + +// Step 1: checkpoint 7. Whichever thread pool thread runs the continuation +log.Record(7, _threadSpecificValue); ``` -Expected output shape: +One run produced these results: -```console -Main thread - threadSpecificValue: 100 -Thread 4 _threadSpecificValue: 51 -Thread 5 _threadSpecificValue: 72 -Main thread after threads finished - threadSpecificValue: 100 -``` +| Checkpoint | Thread | Pool thread | `_threadSpecificValue` | +| --- | --- | --- | --- | +| 1. Main thread, after assigning 100 | 23 | no | 100 | +| 2. Background thread 1, before assigning | 24 | no | 0 | +| 3. Background thread 1, after assigning its random value | 24 | no | 7 | +| 4. Background thread 2, before assigning | 25 | no | 0 | +| 5. Background thread 2, after assigning its random value | 25 | no | 93 | +| 6. Main thread, after both threads finish | 23 | no | 100 | +| 7. After await Task.Yield() | 9 | yes | 0 | The exact background thread IDs and random values will differ. The important observation is that each thread has its own value, and the main thread keeps `100` after the background threads complete. -## 2. ExecutionContext +Checkpoints 2 and 4 show that nothing copies the main thread's value into a new thread: each thread's copy starts at `0`, the default for `int`. Checkpoint 7 is the reason async code cannot use `[ThreadStatic]` for ambient data. `Task.Yield()` always schedules a continuation, the dedicated thread has no `SynchronizationContext`, so the continuation ran on a thread pool thread, and that thread has its own copy of the field. The value stayed behind with the thread. The next step shows the storage .NET uses instead. + +## 2. ExecutionContext (Step 2) -Open **2. ExecutionContext/ExecutionContextExample/Program.cs**. +Open **InternalsLab/Experiments/ExecutionContextExperiment.cs**. The main thread sets values controlled by `ExecutionContext`: @@ -44,11 +62,13 @@ When the sample captures the main thread context and runs it on a background thr ```cs var mainThreadExecutionContext = ExecutionContext.Capture() ?? throw new InvalidOperationException("ExecutionContext only null when suppressed"); +``` +```cs ExecutionContext.Run(mainThreadExecutionContext, _ => { - Console.WriteLine("Same Background Thread, but using MainThread's ExecutionContext"); - PrintThreadValues(); + // Step 2: checkpoint 3. The same background thread, but running with the main thread's ExecutionContext + RecordThreadValues(3); }, null); ``` @@ -57,8 +77,8 @@ When `Task.Run(...)` is awaited normally, `ExecutionContext` flows automatically ```cs await Task.Run(() => { - Console.WriteLine("Print Values from Task.Run()"); - PrintThreadValues(); + // Step 2: checkpoint 5. Inside Task.Run() + RecordThreadValues(5); }); ``` @@ -70,49 +90,102 @@ using (ExecutionContext.SuppressFlow()) { suppressedExecutionContextTask = Task.Run(() => { - Console.WriteLine("Print Values from Task.Run() With Execution Context Suppressed"); - PrintThreadValues(); + // Step 2: checkpoint 6. Inside Task.Run() started while ExecutionContext flow is suppressed + RecordThreadValues(6); }); } +// Step 2: the task is created inside the using block, but awaited only after the block ends await suppressedExecutionContextTask; ``` `ExecutionContext.SuppressFlow()` returns a thread-affine `AsyncFlowControl`. Create the task while flow is suppressed, leave the `using` block so flow is restored on the current thread, and only then await the task. -Expected suppressed-flow output shape: +One run produced these results: + +| Checkpoint | Thread | Culture | Principal | AsyncLocal | +| --- | --- | --- | --- | --- | +| 1. Main thread, after assigning its values | 21 | es-ES | ClaimsPrincipal | Initial Value | +| 2. Background thread, after assigning its own values | 22 | en-GB | CustomPrincipal | AsyncLocalData in Thread | +| 3. Same background thread, inside ExecutionContext.Run(mainThreadExecutionContext, ...) | 22 | es-ES | ClaimsPrincipal | Initial Value | +| 4. Main thread, after the background thread finishes | 21 | es-ES | ClaimsPrincipal | Initial Value | +| 5. Inside Task.Run(...) | 9 | es-ES | ClaimsPrincipal | Initial Value | +| 6. Inside Task.Run(...) started while ExecutionContext flow is suppressed | 7 | en-US | null | null | + +The exact thread IDs may differ. At checkpoint 6, the important observation is that the culture returns to the machine default (`en-US` on this machine), `Principal` is empty, and `AsyncLocalData` is empty. + +Checkpoint 3 runs on the same thread as checkpoint 2, yet sees the main thread's values: `ExecutionContext.Run(...)` applies the captured context only for the duration of the callback. Checkpoint 4 shows that the background thread's assignments changed its own `ExecutionContext`, never the main thread's. Checkpoints 5 and 6 both run on thread pool threads, and the thread is not what decides the result: `Task.Run(...)` captures `ExecutionContext` when the task is created, and inside `SuppressFlow()` there is nothing to capture. + +The app starts the experiment with the same pattern, which is how the values the experiment assigns never leak into the Blazor circuit, and the circuit's values never leak into the experiment. From **InternalsLab/Steps/WorkshopStep.cs**: + +```cs +Task experimentTask; +using (ExecutionContext.SuppressFlow()) +{ + experimentTask = Task.Factory.StartNew(experiment, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); +} + +await experimentTask.WaitAsync(Timeout, token).ConfigureAwait(false); +``` + +### Try it: await inside the using block + +The **Try it** button on the Step 2 page runs `AwaitInsideSuppressFlowAsync()`, the version that awaits inside the block: + +```cs +public async Task AwaitInsideSuppressFlowAsync() +{ + using (ExecutionContext.SuppressFlow()) + { + // Step 2: the thread that starts the using block + RecordThreadValues(1); + + await Task.Run(() => RecordThreadValues(2)); + + // Step 2: the thread that runs the continuation, which is also the thread that ends the using block + RecordThreadValues(3); + } +} +``` + +The `await` lets the method return while flow is still suppressed on the thread that entered the block. The rest of the method, including the end of the `using` block, runs later as a continuation on a thread pool thread where flow is not suppressed, so disposing the `AsyncFlowControl` throws. The page shows the exception type and the two thread IDs, and the terminal running the app logs the full exception: ```console -Print Values from Task.Run() With Execution Context Suppressed -Thread ID: 7 -Culture: (eg "English (United States)") -Principal: -AsyncLocalData: +System.InvalidOperationException: AsyncFlowControl object must be used on the thread where it was created. + at System.Threading.AsyncFlowControl.Undo() ``` -The exact thread ID may differ. The important observation is that the culture returns to the machine default, `Principal` is empty, and `AsyncLocalData` is empty. +In the rare run where the task has already finished when the `await` looks at it, there is no continuation to schedule, the block ends on the thread that started it, and nothing throws. That does not make the code correct. It makes the bug intermittent. -## 3. Principal +## 3. Principal (Step 3) -The previous sample showed `ExecutionContext` flowing through a console app. This sample moves the same mechanism into an ASP.NET Core request. The goal is to separate two things that are easy to confuse: values that flow across an `await` because they ride on `ExecutionContext`, and values that are available after an `await` simply because they are object references the code already holds. +The previous sample showed `ExecutionContext` flowing through a console-style program. This sample moves the same mechanism into an ASP.NET Core request. The goal is to separate two things that are easy to confuse: values that flow across an `await` because they ride on `ExecutionContext`, and values that are available after an `await` simply because they are object references the code already holds. -Open **3. Principal/PrincipalExample/Controllers/HomeController.cs**. `RunExperiment()` holds the signed-in user in a local variable, then records four checkpoints: +Open **InternalsLab/Controllers/PrincipalController.cs**. `RunExperiment()` holds the signed-in user in a local variable, then records four checkpoints: ```cs var checkpoints = new List(); + +// Step 3: an ordinary local variable that holds the signed-in user var signedInUser = HttpContext.User; +// Step 3: checkpoint 1. Nothing in this action has awaited yet checkpoints.Add(Observe("1. Start of the action")); +// Try it: delete this line, apply the change with Hot Reload, and click Run it again. Which cells change at checkpoints 2 and 3? Thread.CurrentPrincipal = signedInUser; // Yields the current thread: the rest of this method runs later as a continuation on the thread pool +// Try it: replace this line with await Task.Delay(1).ConfigureAwait(false) and apply it with Hot Reload. Does httpContextAccessor still find the HttpContext at checkpoint 2? await Task.Yield(); +// Step 3: checkpoint 2. The continuation, usually on a different thread checkpoints.Add(Observe("2. After await Task.Yield()")); +// Step 3: checkpoint 3. A thread pool thread running the Task.Run(...) lambda checkpoints.Add(await Task.Run(() => Observe("3. Inside Task.Run(...)"))); +// Step 3: checkpoint 4. The task is created while ExecutionContext flow is suppressed, and awaited only after the using block ends Task suppressedFlowTask; using (ExecutionContext.SuppressFlow()) { @@ -136,34 +209,37 @@ Checkpoint Observe(string checkpoint) => new Checkpoint( `await Task.Yield()` never completes synchronously. ASP.NET Core has no `SynchronizationContext`, so the rest of the method is queued to the thread pool as a real continuation, and it usually resumes on a different thread. -Run the project: +The controller is an ordinary MVC action inside the same app, because the experiment has to observe a real request. **Run the experiment** on the Step 3 page leaves the Blazor circuit with a full page load, and the action saves its checkpoints in the lab notebook and redirects back: -```console -dotnet run --project "3. Principal/PrincipalExample/PrincipalExample.csproj" +```cs +// Workshop plumbing: saves the checkpoints in the lab notebook, then goes back to the Step 3 page to compare them with your predictions +notebook.RecordPrincipalRun(checkpoints); + +return Redirect("/steps/3"); ``` -Open [http://localhost:5000](http://localhost:5000), sign in, and select **Run the experiment**. Signed in as `Ada`, one run produced this **Results** table: +Open [http://localhost:5003/steps/3](http://localhost:5003/steps/3), sign in, and select **Run the experiment**. Signed in as `Ada`, one run produced this **Results** table: | Checkpoint | Thread | `Thread.CurrentPrincipal` | `httpContextAccessor.HttpContext?.User` | `HttpContext.User` | `signedInUser` | | --- | --- | --- | --- | --- | --- | -| 1. Start of the action | 15 | null | Ada | Ada | Ada | -| 2. After await Task.Yield() | 12 | Ada | Ada | Ada | Ada | -| 3. Inside Task.Run(...) | 7 | Ada | Ada | Ada | Ada | -| 4. Inside Task.Run(...) started while ExecutionContext flow is suppressed | 7 | null | null | Ada | Ada | +| 1. Start of the action | 13 | null | Ada | Ada | Ada | +| 2. After await Task.Yield() | 7 | Ada | Ada | Ada | Ada | +| 3. Inside Task.Run(...) | 15 | Ada | Ada | Ada | Ada | +| 4. Inside Task.Run(...) started while ExecutionContext flow is suppressed | 15 | null | null | Ada | Ada | -Your name and the thread IDs will differ, and a checkpoint may reuse a thread from an earlier row. Select **Run it again** a few times: the thread IDs move around, but the names and `null`s never change. Read the columns, not the rows. +Your name and the thread IDs will differ, and a checkpoint may reuse a thread from an earlier row. Select **Run it again** a few times: the thread IDs move around, and the page shows the previous run's thread ID under each one, but the names and `null`s never change. Read the columns, not the rows. ### Where the signed-in user comes from -Open **3. Principal/PrincipalExample/Program.cs**. The app uses cookie authentication: +Open **InternalsLab/Program.cs**. The app uses cookie authentication: ```cs builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) - .AddCookie(static options => options.LoginPath = "/"); - -var app = builder.Build(); + .AddCookie(static options => options.LoginPath = "/Account/SignIn"); +``` -// Reads the sign-in cookie on every request and assigns the signed-in user to HttpContext.User +```cs +// Step 3: reads the sign-in cookie on every request and assigns the signed-in user to HttpContext.User app.UseAuthentication(); app.UseAuthorization(); ``` @@ -181,11 +257,11 @@ Checkpoint 1 stays `null` on every run, even when the request lands on a thread ### What `ExecutionContext` carries -`Thread.CurrentPrincipal` and `IHttpContextAccessor.HttpContext` are both implemented with `AsyncLocal`. At checkpoint 2 the thread changed from 15 to 12, yet both columns still show `Ada`: `await` captured `ExecutionContext` and restored it on the continuation thread. `Task.Run(...)` does the same capture and restore at checkpoint 3, on the thread pool thread that runs the lambda. +`Thread.CurrentPrincipal` and `IHttpContextAccessor.HttpContext` are both implemented with `AsyncLocal`. At checkpoint 2 the thread changed from 13 to 7, yet both columns still show `Ada`: `await` captured `ExecutionContext` and restored it on the continuation thread. `Task.Run(...)` does the same capture and restore at checkpoint 3, on the thread pool thread that runs the lambda. -At checkpoint 4 both columns are `null`. The task was created while flow was suppressed, so no `ExecutionContext` was captured for it, and its lambda ran with the default, empty context. The thread does not decide the result: in the run above, checkpoint 4 ran on thread 7, the same thread as checkpoint 3, and still saw `null`, because `ExecutionContext` belongs to the work item, not to the thread. The `httpContextAccessor` object itself was still reachable at checkpoint 4; only the `AsyncLocal` lookup behind its `HttpContext` property came back empty. +At checkpoint 4 both columns are `null`. The task was created while flow was suppressed, so no `ExecutionContext` was captured for it, and its lambda ran with the default, empty context. The thread does not decide the result: in the run above, checkpoint 4 ran on thread 15, the same thread as checkpoint 3, and still saw `null`, because `ExecutionContext` belongs to the work item, not to the thread. The `httpContextAccessor` object itself was still reachable at checkpoint 4; only the `AsyncLocal` lookup behind its `HttpContext` property came back empty. -As in the previous sample, `ExecutionContext.SuppressFlow()` returns a thread-affine `AsyncFlowControl`. Create the task inside the `using` block, leave the block so flow is restored on the same thread, and only then await the task. Awaiting inside the block would let the method return before the block ends. The continuation runs with flow no longer suppressed, often on a different thread, so disposing the `AsyncFlowControl` at the end of the block throws `InvalidOperationException` even when the thread happens to be the same. +As in the previous sample, `ExecutionContext.SuppressFlow()` returns a thread-affine `AsyncFlowControl`. Create the task inside the `using` block, leave the block so flow is restored on the same thread, and only then await the task. Awaiting inside the block would let the method return before the block ends. The continuation runs with flow no longer suppressed, often on a different thread, so disposing the `AsyncFlowControl` at the end of the block throws `InvalidOperationException` even when the thread happens to be the same. Step 2's **Try it** button shows exactly that. ### What only looks like it flowed @@ -205,64 +281,94 @@ The `httpContextAccessor.HttpContext?.User` and `HttpContext.User` columns reach | `HttpContext.User` | your name | your name | your name | Object reference on the controller instance | | `signedInUser` | your name | your name | your name | Local variable captured in a compiler-generated closure | -A bit of history: in classic ASP.NET (System.Web), `HttpContext.Current` relied on ASP.NET's `SynchronizationContext` to be re-attached on the continuation thread, so it was commonly `null` after `ConfigureAwait(false)`. ASP.NET Core's `IHttpContextAccessor` is AsyncLocal-backed instead, so it flows the same way `Thread.CurrentPrincipal` does. +A bit of history: in classic ASP.NET (System.Web), `HttpContext.Current` relied on ASP.NET's `SynchronizationContext` to be re-attached on the continuation thread, so it was commonly `null` after `ConfigureAwait(false)`. ASP.NET Core's `IHttpContextAccessor` is AsyncLocal-backed instead, so it flows the same way `Thread.CurrentPrincipal` does. The second `// Try it:` comment in `RunExperiment()` proves it: replace `await Task.Yield()` with `await Task.Delay(1).ConfigureAwait(false)`, and checkpoint 2 still shows your name in the `httpContextAccessor.HttpContext?.User` column. -## 4. SynchronizationContext +## 4. SynchronizationContext (Step 4) -The previous two samples showed the values that ride on `ExecutionContext`. This sample prints the other piece of ambient state that `await` captures: `SynchronizationContext`. In Blazor Server that is the renderer's synchronization context, and it is exactly what `ConfigureAwait(false)` opts out of. +The previous two samples showed the values that ride on `ExecutionContext`. This sample shows the other piece of ambient state that `await` captures: `SynchronizationContext`. In Blazor Server that is the renderer's synchronization context, and it is exactly what `ConfigureAwait(false)` opts out of. -Open **4. SynchronizationContext/HackerNews/Components/Pages/News.razor.cs**. `RefreshAsync(CancellationToken token)` logs the current thread and `SynchronizationContext` before its first `ConfigureAwait(false)`: +Open **InternalsLab/Experiments/SynchronizationContextExperiment.cs**. `RefreshAsync(Func, CancellationToken)` is shaped like `RefreshAsync` from Correcting Common Async Await Mistakes, with a simulated story feed in place of Hacker News so the app runs offline. It records `SynchronizationContext.Current` and the thread at six checkpoints: ```cs -var thread = Thread.CurrentThread; -var synchronizationContext = SynchronizationContext.Current; -Logger.LogInformation("Before ConfigureAwait(false) | Thread {ThreadId} | SynchronizationContext: {SynchronizationContext}", thread.ManagedThreadId, synchronizationContext?.GetType().Name ?? ""); -``` +public async Task RefreshAsync(Func invokeAsync, CancellationToken token) +{ + // Step 4: checkpoint 1. Before the first await, still inside the click handler + RecordSynchronizationContext(1); -It logs them again each time the `await foreach` loop resumes after `ConfigureAwait(false)`: + // Step 4: a plain await. GetTopStoryIDs() has to wait for the simulated network. + var topStoryIds = await _storyFeed.GetTopStoryIDs(token); -```cs -await foreach (var story in GetTopStories(topStoryIds, StoriesConstants.NumberOfStories, token).ConfigureAwait(false)) -{ - var threadAfterConfigureAwaitFalse = Thread.CurrentThread; - var synchronizationContextAfterConfigureAwaitFalse = SynchronizationContext.Current; - Logger.LogInformation("After ConfigureAwait(false) | Thread {ThreadId} | SynchronizationContext: {SynchronizationContext}", threadAfterConfigureAwaitFalse.ManagedThreadId, synchronizationContextAfterConfigureAwaitFalse?.GetType().Name ?? ""); + // Step 4: checkpoint 2. After a plain await + RecordSynchronizationContext(2); - await InvokeAsync(() => + // Step 4: the top story is already in the feed's cache, so the ValueTask has completed before the await looks at it + var topStory = await _storyFeed.GetStory(topStoryIds[0], token).ConfigureAwait(false); + + // Step 4: checkpoint 3. After ConfigureAwait(false) on a story that was already cached + RecordSynchronizationContext(3); + + // Step 4: this story is not cached, so the await has to wait for the simulated network + // Try it: remove ConfigureAwait(false) from this await and apply the change with Hot Reload. Which checkpoints change? + var secondStory = await _storyFeed.GetStory(topStoryIds[1], token).ConfigureAwait(false); + + // Step 4: checkpoint 4. After ConfigureAwait(false) on a story that had to download + RecordSynchronizationContext(4); + + // Step 4: a plain await again, but it runs after checkpoint 4 + var thirdStory = await _storyFeed.GetStory(topStoryIds[2], token); + + // Step 4: checkpoint 5. After a plain await that started where checkpoint 4 left off + RecordSynchronizationContext(5); + + // Step 4: InvokeAsync() runs the lambda through Blazor's renderer, which is how RefreshAsync() safely changes component state + await invokeAsync(() => { - if (!TopStoryCollection.Any(x => x.Title.Equals(story.Title, StringComparison.Ordinal))) - { - InsertIntoSortedList(TopStoryCollection, (a, b) => b.Score.CompareTo(a.Score), story); - } + TopStories.AddRange([topStory, secondStory, thirdStory]); - StateHasChanged(); + // Step 4: checkpoint 6. Inside InvokeAsync() + RecordSynchronizationContext(6); }); } ``` -Run the project: +**Run the experiment** calls it straight from the Step 4 page's click handler, passing the page's `InvokeAsync`, with nothing awaited first. From **InternalsLab/Steps/Step4SynchronizationContext.cs**: -```console -dotnet run --project "4. SynchronizationContext/HackerNews/HackerNews.csproj" +```cs +// No Task.Run and no ConfigureAwait(false) before this call: RefreshAsync() starts synchronously, +// on Blazor's renderer, exactly like the click handler that called this method +var refreshTask = experiment.RefreshAsync(invokeAsync, token); ``` -Open [http://localhost:5004](http://localhost:5004) and read the `HackerNews.Components.Pages.NewsPageBase` lines in the console. They are interleaved with ASP.NET Core's request logging, so look for the `NewsPageBase` category. There is one `Before` line per refresh and one `After` line per story, and the output has this shape: +The top story is served from the feed's cache. Open **InternalsLab/Experiments/SimulatedStoryFeed.cs**: -```console -info: HackerNews.Components.Pages.NewsPageBase[0] - Before ConfigureAwait(false) | Thread 3 | SynchronizationContext: RendererSynchronizationContext -info: HackerNews.Components.Pages.NewsPageBase[0] - After ConfigureAwait(false) | Thread 19 | SynchronizationContext: -info: HackerNews.Components.Pages.NewsPageBase[0] - After ConfigureAwait(false) | Thread 24 | SynchronizationContext: -info: HackerNews.Components.Pages.NewsPageBase[0] - After ConfigureAwait(false) | Thread 3 | SynchronizationContext: +```cs +// A cached story comes back in a ValueTask that has already completed, so awaiting it never has to wait +public ValueTask GetStory(long storyId, CancellationToken token) +{ + if (_cache.TryGetValue(storyId, out var cachedStory)) + return ValueTask.FromResult(cachedStory); + + return new ValueTask(DownloadStory(storyId, token)); +} ``` -Thread IDs will differ. Before the await, `SynchronizationContext.Current` is Blazor's `RendererSynchronizationContext`. It is not a native UI thread, and the managed thread ID does not have to be `1`. Blazor Server has no dedicated UI thread: the renderer's synchronization context runs its work on thread pool threads, one work item at a time, and that context is what serializes access to component state. +One run produced these results: + +| Checkpoint | Thread | Pool thread | `SynchronizationContext.Current` | +| --- | --- | --- | --- | +| 1. Before the first await, in the click handler | 19 | yes | RendererSynchronizationContext | +| 2. After a plain await on GetTopStoryIDs() | 18 | yes | RendererSynchronizationContext | +| 3. After ConfigureAwait(false) on a story that was already cached | 18 | yes | RendererSynchronizationContext | +| 4. After ConfigureAwait(false) on a story that had to download | 18 | yes | null | +| 5. After a plain await that started where checkpoint 4 left off | 18 | yes | null | +| 6. Inside InvokeAsync(...) | 18 | yes | RendererSynchronizationContext | + +Thread IDs will differ. Before the first await, `SynchronizationContext.Current` is Blazor's `RendererSynchronizationContext`. It is not a native UI thread, and the managed thread ID does not have to be `1`: the Pool thread column says `yes` for every checkpoint. Blazor Server has no dedicated UI thread: the renderer's synchronization context runs its work on thread pool threads, one work item at a time, and that context is what serializes access to component state. Checkpoint 2 shows it: the plain `await` posted its continuation back to the renderer's context, which ran it on a different thread from checkpoint 1. + +After `ConfigureAwait(false)` on an operation that has to wait, the continuation runs on whichever thread pool thread completed the awaited operation, and `SynchronizationContext.Current` is `null`. In the run above, thread `18` ran checkpoints 2 and 3 on the renderer's context and then ran checkpoint 4 with no synchronization context at all. The thread is not what changed. `ConfigureAwait(false)` told the awaiter not to capture the context, so nothing restored it when the continuation was scheduled. -After `ConfigureAwait(false)`, each continuation runs on whichever thread pool thread completed the awaited operation, and `SynchronizationContext.Current` is ``. In the run above, thread `3` ran the code before the await and later ran a continuation with no synchronization context at all. The thread is not what changed. `ConfigureAwait(false)` told the awaiter not to capture the context, so nothing restored it when the continuation was scheduled. With fifty continuations you may see the same reuse in your own output. +Checkpoint 3 is the non-null result after `ConfigureAwait(false)`. The cached story's `ValueTask` had already completed when the `await` ran, so there was no continuation to schedule, and the code kept running on the same thread with the same context. `ConfigureAwait(false)` only affects continuations that are actually scheduled. -A non-null `After` line is also possible. If the awaited operation had already completed when the `await` ran, there was no continuation to schedule, so the code kept running on the same thread with the same context. `ConfigureAwait(false)` only affects continuations that are actually scheduled. +Checkpoint 5 is the one that surprises people. It is a plain `await`, but it does not bring you back to the renderer, because an `await` captures whatever `SynchronizationContext` is current when that `await` runs. After checkpoint 4 there was none. The only way back is to ask for it: checkpoint 6 runs inside `InvokeAsync(...)`, which runs the lambda through Blazor's renderer, and reports `RendererSynchronizationContext`, even though it ran on the same thread as checkpoint 5. -The key observation is that `ConfigureAwait(false)` and `ConfigureAwaitOptions.None` avoid capturing the synchronization context when a continuation is scheduled. The sample uses `InvokeAsync(...)` to marshal UI state updates back through Blazor's renderer. +The key observation is that `ConfigureAwait(false)` avoids capturing the synchronization context when a continuation is scheduled. After that, every change to component state goes back through `InvokeAsync(...)`, just as Correcting Common Async Await Mistakes does. diff --git a/4. Creating Custom Implementation of Task/1. Start/CoffeeShop/Components/Pages/Home.razor b/4. Creating Custom Implementation of Task/1. Start/CoffeeShop/Components/Pages/Home.razor index aefcf1d..90790c8 100644 --- a/4. Creating Custom Implementation of Task/1. Start/CoffeeShop/Components/Pages/Home.razor +++ b/4. Creating Custom Implementation of Task/1. Start/CoffeeShop/Components/Pages/Home.razor @@ -96,6 +96,13 @@ } +
+

Your challenge

+

Finish CustomTask.cs so every station in the shop works. Every other file, including CustomTaskAwaiter.cs and the Steps folder, is already complete, and should be unchanged when you finish.

+

Recommended time: 45 minutes. Work through the steps in order. When Steps 1 to 5 pass on startup and Step 6 passes from its own page, pause for the group review.

+

Please avoid letting AI Agents solve the challenges for you. You're smart. You got this. Use them to understand the existing code, clarify task-like type concepts, and interpret errors.

+
+

How it works

    diff --git a/4. Creating Custom Implementation of Task/1. Start/CoffeeShop/Components/Pages/Step.razor b/4. Creating Custom Implementation of Task/1. Start/CoffeeShop/Components/Pages/Step.razor index 56f327c..196f69f 100644 --- a/4. Creating Custom Implementation of Task/1. Start/CoffeeShop/Components/Pages/Step.razor +++ b/4. Creating Custom Implementation of Task/1. Start/CoffeeShop/Components/Pages/Step.razor @@ -71,7 +71,7 @@ break; case StepStatus.Failed: diff --git a/4. Creating Custom Implementation of Task/1. Start/CoffeeShop/Steps/Step1PrintReceipt.cs b/4. Creating Custom Implementation of Task/1. Start/CoffeeShop/Steps/Step1PrintReceipt.cs index 1ed2d7e..eeaba03 100644 --- a/4. Creating Custom Implementation of Task/1. Start/CoffeeShop/Steps/Step1PrintReceipt.cs +++ b/4. Creating Custom Implementation of Task/1. Start/CoffeeShop/Steps/Step1PrintReceipt.cs @@ -10,7 +10,7 @@ public sealed class Step1PrintReceipt : WorkshopStep public override string Story => "The receipt printer's SDK calls the till synchronously and needs the finished receipt before its callback returns. " + "Rendering the receipt is CPU-bound, so the till starts it on the thread pool with CustomTask.Run(), opens the cash drawer while it renders, then blocks with Wait() only because the callback must return synchronously. " - + "Calling Run() and then Wait() with nothing in between would only waste a thread, the same mistake as Task.Run(...).Wait() in Section 2. " + + "Calling Run() and then Wait() with nothing in between would only waste a thread, the same blocking wait you removed from the Top stories page in Correcting Common Async Await Mistakes. " + "Run() completes its CustomTask with SetResult() or SetException(), so implement those two first."; public override string TaskEquivalent => "Task.Run(), Task.Wait() and Task.IsCompleted"; diff --git a/4. Creating Custom Implementation of Task/1. Start/CoffeeShop/wwwroot/app.css b/4. Creating Custom Implementation of Task/1. Start/CoffeeShop/wwwroot/app.css index ff166e9..212fa43 100644 --- a/4. Creating Custom Implementation of Task/1. Start/CoffeeShop/wwwroot/app.css +++ b/4. Creating Custom Implementation of Task/1. Start/CoffeeShop/wwwroot/app.css @@ -240,6 +240,14 @@ h1:focus { font-size: 0.9rem; } +.ai-note { + padding: 10px 14px; + border-left: 4px solid var(--note); + border-radius: 6px; + background: var(--note-soft); + color: var(--note); +} + .how-it-works { margin: 0; padding-left: 20px; diff --git a/4. Creating Custom Implementation of Task/2. Finish/CoffeeShop/Components/Pages/Home.razor b/4. Creating Custom Implementation of Task/2. Finish/CoffeeShop/Components/Pages/Home.razor index aefcf1d..90790c8 100644 --- a/4. Creating Custom Implementation of Task/2. Finish/CoffeeShop/Components/Pages/Home.razor +++ b/4. Creating Custom Implementation of Task/2. Finish/CoffeeShop/Components/Pages/Home.razor @@ -96,6 +96,13 @@ } +
    +

    Your challenge

    +

    Finish CustomTask.cs so every station in the shop works. Every other file, including CustomTaskAwaiter.cs and the Steps folder, is already complete, and should be unchanged when you finish.

    +

    Recommended time: 45 minutes. Work through the steps in order. When Steps 1 to 5 pass on startup and Step 6 passes from its own page, pause for the group review.

    +

    Please avoid letting AI Agents solve the challenges for you. You're smart. You got this. Use them to understand the existing code, clarify task-like type concepts, and interpret errors.

    +
    +

    How it works

      diff --git a/4. Creating Custom Implementation of Task/2. Finish/CoffeeShop/Components/Pages/Step.razor b/4. Creating Custom Implementation of Task/2. Finish/CoffeeShop/Components/Pages/Step.razor index 56f327c..196f69f 100644 --- a/4. Creating Custom Implementation of Task/2. Finish/CoffeeShop/Components/Pages/Step.razor +++ b/4. Creating Custom Implementation of Task/2. Finish/CoffeeShop/Components/Pages/Step.razor @@ -71,7 +71,7 @@ break; case StepStatus.Failed: diff --git a/4. Creating Custom Implementation of Task/2. Finish/CoffeeShop/Steps/Step1PrintReceipt.cs b/4. Creating Custom Implementation of Task/2. Finish/CoffeeShop/Steps/Step1PrintReceipt.cs index 1ed2d7e..eeaba03 100644 --- a/4. Creating Custom Implementation of Task/2. Finish/CoffeeShop/Steps/Step1PrintReceipt.cs +++ b/4. Creating Custom Implementation of Task/2. Finish/CoffeeShop/Steps/Step1PrintReceipt.cs @@ -10,7 +10,7 @@ public sealed class Step1PrintReceipt : WorkshopStep public override string Story => "The receipt printer's SDK calls the till synchronously and needs the finished receipt before its callback returns. " + "Rendering the receipt is CPU-bound, so the till starts it on the thread pool with CustomTask.Run(), opens the cash drawer while it renders, then blocks with Wait() only because the callback must return synchronously. " - + "Calling Run() and then Wait() with nothing in between would only waste a thread, the same mistake as Task.Run(...).Wait() in Section 2. " + + "Calling Run() and then Wait() with nothing in between would only waste a thread, the same blocking wait you removed from the Top stories page in Correcting Common Async Await Mistakes. " + "Run() completes its CustomTask with SetResult() or SetException(), so implement those two first."; public override string TaskEquivalent => "Task.Run(), Task.Wait() and Task.IsCompleted"; diff --git a/4. Creating Custom Implementation of Task/2. Finish/CoffeeShop/wwwroot/app.css b/4. Creating Custom Implementation of Task/2. Finish/CoffeeShop/wwwroot/app.css index ff166e9..212fa43 100644 --- a/4. Creating Custom Implementation of Task/2. Finish/CoffeeShop/wwwroot/app.css +++ b/4. Creating Custom Implementation of Task/2. Finish/CoffeeShop/wwwroot/app.css @@ -240,6 +240,14 @@ h1:focus { font-size: 0.9rem; } +.ai-note { + padding: 10px 14px; + border-left: 4px solid var(--note); + border-radius: 6px; + background: var(--note-soft); + color: var(--note); +} + .how-it-works { margin: 0; padding-left: 20px; diff --git a/4. Creating Custom Implementation of Task/CreatingTaskFromScratch.pptx b/4. Creating Custom Implementation of Task/CreatingTaskFromScratch.pptx index 72fb6be..a04496e 100644 Binary files a/4. Creating Custom Implementation of Task/CreatingTaskFromScratch.pptx and b/4. Creating Custom Implementation of Task/CreatingTaskFromScratch.pptx differ diff --git a/4. Creating Custom Implementation of Task/README.md b/4. Creating Custom Implementation of Task/README.md index f919c07..edc0e44 100644 --- a/4. Creating Custom Implementation of Task/README.md +++ b/4. Creating Custom Implementation of Task/README.md @@ -27,7 +27,7 @@ The starter builds, but every `CustomTask` member is still a stub that throws a The app enforces the order of the steps: -1. Step 1 is `CustomTask.Run()`, `Wait()` and `IsCompleted`. Step 2 is `CustomTask.Delay()` and `ContinueWith()`. Step 3 is `await`, which uses `GetAwaiter()`. Step 4 is `SetResult()`. Step 5 is `SetException()`. Step 6, rush hour, covers many waiters, long `ContinueWith` chains and `ExecutionContext`. +1. Each step names the members its feature exercises, and a step can also need members from later steps. Step 1 is `CustomTask.Run()`, `Wait()` and `IsCompleted`, and it also needs `SetResult()`, `SetException()` and `ContinueWith()`. Step 2 is `CustomTask.Delay()` and `ContinueWith()`. Step 3 is `await`, which uses `GetAwaiter()`. Step 4 is `SetResult()`. Step 5 is `SetException()`. Step 6, rush hour, covers many waiters, long `ContinueWith` chains and `ExecutionContext`. 2. A step unlocks only after the step before it passes. Each step page tells the story behind the feature, names the real .NET API it mirrors, and shows a live log plus a checklist of every expected result next to what actually happened. 3. Every time the app starts, it checks your `CustomTask` against Steps 1 to 5 automatically, so the Home page always reflects the code you have now. Step 6 runs only when you click **Run step** on its page, because an implementation that runs continuations inline overflows the stack there, and a stack overflow ends the whole process. 4. Stop and run the app again after each change to **CustomTask.cs**. If your IDE applied the change with Hot Reload, click **Run Steps 1-5** on the Home page instead. diff --git a/4. Creating Custom Implementation of Task/SOLUTION.md b/4. Creating Custom Implementation of Task/SOLUTION.md index 0d31579..fc33251 100644 --- a/4. Creating Custom Implementation of Task/SOLUTION.md +++ b/4. Creating Custom Implementation of Task/SOLUTION.md @@ -246,7 +246,7 @@ readonly struct CustomTaskAwaiter : INotifyCompletion The coffee shop code in **Steps** needs no changes either. It compiled against the starter's stubs from the beginning, and now every member it calls does real work. Its `await` statements work because the compiler generates the same `IsCompleted`, `OnCompleted(...)` and `GetResult()` calls for `CustomTask` that it generates for `Task`. -There is one difference from `Task` that matters in a Blazor app. `CustomTask` never captures `SynchronizationContext`: `OnCompleted(...)` registers the continuation with `ContinueWith(...)`, and the continuation runs on a thread pool thread. After awaiting a `CustomTask`, a component is off Blazor's renderer, exactly as it is after `ConfigureAwait(false)`, so any component state change must go through `InvokeAsync(...)`. Step 3 checks this. +There is one difference from `Task` that matters in a Blazor app. `CustomTask` never captures `SynchronizationContext`: `OnCompleted(...)` registers the continuation with `ContinueWith(...)`, and the continuation runs on a thread pool thread. After awaiting a `CustomTask`, a component is off Blazor's renderer, exactly as it is after `ConfigureAwait(false)`, so any component state change must go through `InvokeAsync(...)`. Step 3's log shows this. ## 8. Compare Against Finish @@ -258,11 +258,11 @@ Compare your implementation with the completed files: [2. Finish/CoffeeShop/Steps](2.%20Finish/CoffeeShop/Steps) -Run the completed app at [http://localhost:5016](http://localhost:5016). Steps 1 to 5 pass as soon as it starts, and Step 6 passes when you run it from its page. Thread IDs will differ on your machine, but every expected result should match: +Run the completed app at [http://localhost:5016](http://localhost:5016). Steps 1 to 5 pass once the checks that run after the app starts have finished, and Step 6 passes when you run it from its page. Thread IDs will differ on your machine, but every expected result should match: 1. Step 1, print the receipt: `Run()` returns before the receipt is rendered, the render runs on a thread pool thread, and `Wait()` returns on the thread that called it with `IsCompleted` now `True`. 2. Step 2, brew a pour-over: `IsCompleted` is `False` right after `Delay()`, the first continuation runs after the delay, and the second continuation runs only after the first one completes. -3. Step 3, prepare a mobile order: the `async` method returns to its caller at its first `await`, resumes on a thread pool thread after the delay without the `SynchronizationContext` it started on, and awaiting an already completed `CustomTask` keeps running on the same thread. +3. Step 3, prepare a mobile order: the `async` method returns to its caller at its first `await`, resumes on a thread pool thread after the delay, and awaiting an already completed `CustomTask` keeps running on the same thread. Its log also shows that the continuation does not return to the `SynchronizationContext` it started on. 4. Step 4, wrap the espresso machine's events: `IsCompleted` stays `False` while the shot brews, the `await` resumes only after `ShotPulled`, and completing the same `CustomTask` twice throws `InvalidOperationException`. 5. Step 5, handle a jammed machine and an empty grinder: `await` rethrows the machine's exception, and `Wait()` rethrows the grinder's exception with a stack trace that still starts where it was thrown, not in `CustomTask.Wait()`. 6. Step 6, rush hour: two customers waiting on the same order both resume, a 10,000-link `ContinueWith` chain completes without a stack overflow, and each continuation sees the `ExecutionContext` of the code that registered it, never the context of the thread that completed the `CustomTask`. diff --git a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Components/Layout/MainLayout.razor b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Components/Layout/MainLayout.razor index 1c2a641..f745fc5 100644 --- a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Components/Layout/MainLayout.razor +++ b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Components/Layout/MainLayout.razor @@ -1,5 +1,9 @@ @inherits LayoutComponentBase -
      - @Body -
      +
      +
      + @Body +
      + + +
      diff --git a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Components/Layout/StepBadge.razor b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Components/Layout/StepBadge.razor new file mode 100644 index 0000000..352439e --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Components/Layout/StepBadge.razor @@ -0,0 +1,10 @@ +@inherits StepBadgeBase + +@if (IsCompact) +{ + @Label +} +else +{ + @Icon @Label +} diff --git a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Components/Layout/StepBadge.razor.cs b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Components/Layout/StepBadge.razor.cs new file mode 100644 index 0000000..02c8bb7 --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Components/Layout/StepBadge.razor.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Components; + +namespace OrderPortal.Components.Layout; + +public partial class StepBadgeBase : ComponentBase +{ + [Parameter, EditorRequired] + public StepStatus Status { get; set; } + + [Parameter] + public bool IsUnlocked { get; set; } = true; + + // Only the icon, for the collapsed guide + [Parameter] + public bool IsCompact { get; set; } + + public string Icon => Status is StepStatus.Running ? "⏳" : !IsUnlocked ? "🔒" : Status switch + { + StepStatus.Passed => "✅", + StepStatus.Failed or StepStatus.TimedOut or StepStatus.Crashed => "❌", + _ => "•", + }; + + public string Label => Status is StepStatus.Running ? "Running" : !IsUnlocked ? "Locked" : Status switch + { + StepStatus.Passed => "Passed", + StepStatus.Failed => "Failed", + StepStatus.TimedOut => "Timed out", + StepStatus.Crashed => "Crashed", + StepStatus.Stopped => "Stopped", + _ => "Not run", + }; + + public string CssClass => Status is StepStatus.Running ? "badge-running" : !IsUnlocked ? "badge-locked" : Status switch + { + StepStatus.Passed => "badge-passed", + StepStatus.Failed or StepStatus.TimedOut or StepStatus.Crashed => "badge-failed", + _ => "badge-idle", + }; +} \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Components/Layout/WorkshopGuide.razor b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Components/Layout/WorkshopGuide.razor new file mode 100644 index 0000000..dc60639 --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Components/Layout/WorkshopGuide.razor @@ -0,0 +1,321 @@ +@inherits WorkshopGuideBase + + diff --git a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Components/Layout/WorkshopGuide.razor.cs b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Components/Layout/WorkshopGuide.razor.cs new file mode 100644 index 0000000..75bbb85 --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Components/Layout/WorkshopGuide.razor.cs @@ -0,0 +1,179 @@ +using Microsoft.AspNetCore.Components; + +namespace OrderPortal.Components.Layout; + +// Workshop plumbing: the guide docked beside the app. It shows your challenge, every step's result, and the step you are working on. +public partial class WorkshopGuideBase : ComponentBase, IAsyncDisposable +{ + // Selecting this shows your challenge instead of a step + public const int ChallengeTab = 0; + + // A run that fails quickly can finish faster than anyone can see a spinner + static readonly TimeSpan _minimumActivityIndicatorTime = TimeSpan.FromMilliseconds(600); + + readonly CancellationTokenSource _disposeCancellationTokenSource = new(); + + Task _polling = Task.CompletedTask; + bool _isStartingCheck; + int? _startingRunStepNumber; + + [Inject] + public required StepVerifier Verifier { get; init; } + + [Inject] + public required ILogger Logger { get; init; } + + public int SelectedTab { get; private set; } = ChallengeTab; + + public bool IsCollapsed { get; private set; } + + public string? ErrorMessage { get; private set; } + + public int PassedStepCount => Verifier.Steps.Count(step => Verifier.GetState(step.Number).Status is StepStatus.Passed); + + public bool IsCheckingSteps => _isStartingCheck || Verifier.IsCheckingAllSteps; + + // The first step that does not pass yet, which is the one to work on next + public WorkshopStep? StoppedAtStep => Verifier.Steps.FirstOrDefault(step => Verifier.GetState(step.Number).Status is not StepStatus.Passed); + + // The last step the check reaches: the first one that did not pass, or the last step when they all passed + public WorkshopStep LastCheckedStep => StoppedAtStep ?? Verifier.Steps[^1]; + + public WorkshopStep? SelectedStep => Verifier.FindStep(SelectedTab); + + // The run lives in the verifier, not in this guide, so a step that is still running shows up again after a reload + public ActiveStepRun? SelectedRun => Verifier.ActiveRun is { } run && run.Step.Number == SelectedTab ? run : null; + + public bool IsSelectedStepRunning => _startingRunStepNumber == SelectedTab || SelectedRun is not null; + + public async ValueTask DisposeAsync() + { + // Only stops refreshing the guide. A step that is running keeps running. + await _disposeCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + await _polling.ConfigureAwait(false); + + _disposeCancellationTokenSource.Dispose(); + + GC.SuppressFinalize(this); + } + + protected override void OnInitialized() + { + _polling = PollVerifier(_disposeCancellationTokenSource.Token); + } + + protected void SelectTab(int tab) + { + SelectedTab = tab; + IsCollapsed = false; + ErrorMessage = null; + } + + protected void ToggleCollapsed() => IsCollapsed = !IsCollapsed; + + protected async Task CheckAllStepsAsync() + { + var token = _disposeCancellationTokenSource.Token; + string? errorMessage = null; + + ErrorMessage = null; + _isStartingCheck = true; + + // Show the activity indicator before the check starts + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + try + { + // Keep the indicator on screen long enough to notice, even when Step 1 fails quickly + await Task.WhenAll(Verifier.CheckAllSteps(), Task.Delay(_minimumActivityIndicatorTime, token)).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The guide was closed while the steps were being checked + return; + } + catch (Exception e) + { + Logger.LogError(e, "Checking every step failed"); + + errorMessage = "Checking the steps failed. The terminal running the app has the details."; + } + + // The continuation is off Blazor's renderer, so every component state change goes back through it + await InvokeAsync(() => + { + ErrorMessage = errorMessage; + _isStartingCheck = false; + + StateHasChanged(); + }).ConfigureAwait(false); + } + + protected async Task RunSelectedStepAsync() + { + if (SelectedStep is not { } step) + return; + + var token = _disposeCancellationTokenSource.Token; + string? errorMessage = null; + + ErrorMessage = null; + _startingRunStepNumber = step.Number; + + // Show the activity indicator before the run starts + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + try + { + // Keep the indicator on screen long enough to notice, even when the step fails quickly + await Task.WhenAll(Verifier.RunStep(step), Task.Delay(_minimumActivityIndicatorTime, token)).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The guide was closed while the step was running + return; + } + catch (Exception e) + { + Logger.LogError(e, "Running Step {StepNumber} failed", step.Number); + + errorMessage = "The step could not be run. The terminal running the app has the details."; + } + + // The continuation is off Blazor's renderer, so every component state change goes back through it + await InvokeAsync(() => + { + ErrorMessage = errorMessage; + _startingRunStepNumber = null; + + StateHasChanged(); + }).ConfigureAwait(false); + } + + protected void StopRun() => Verifier.StopRun(); + + // Re-renders while the verifier is busy, plus once more when it finishes, so the live log and results appear without a refresh + async Task PollVerifier(CancellationToken token) + { + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100)); + var wasBusy = true; + + try + { + while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false)) + { + var isBusy = Verifier.IsBusy; + + if (isBusy || wasBusy) + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + wasBusy = isBusy; + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // Expected when the guide is disposed + } + } +} \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Components/Pages/Checkout.razor b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Components/Pages/Checkout.razor index 5679d74..3d1d4f3 100644 --- a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Components/Pages/Checkout.razor +++ b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Components/Pages/Checkout.razor @@ -1,17 +1,20 @@ @page "/" @inherits CheckoutPageBase -OrderPortal +OrderPortal · Checkout -
      -
      -
      -

      OrderPortal

      -

      Checkout load simulator

      -

      Concurrent checkout workers run against the same singleton services, like a burst of real traffic.

      -
      +
      +
      + + Black Friday · Checkout war room +
      -
      +
      +

      Doors open at midnight

      +

      Checkout load simulator

      +

      @OrderCount.ToString("N0") customers check out at once against the same singleton services, like the first minute of the sale.

      + +
      -
      +
      @if (Result is null && !IsBusy) { -
      +

      What to look for

      -

      Run the burst. Every card that turns red is a bug in a service that looked fine when one request hit it at a time. Then press Reserve stock and watch how long it takes.

      -
      +

      Run the burst. Every card that turns red is a bug in a service that looked fine when one request hit it at a time. Then press Reserve stock and watch how long it takes.

      +
} @if (Result is not null) { -
+

Checkout results

-

Every number below has a known correct value. Anything that does not match is a race condition.

+

Every number below has a known correct value. Anything that does not match is a race condition.

@@ -61,19 +64,19 @@
Elapsed - @Result.Elapsed.TotalSeconds.ToString("F2")s + @Result.Elapsed.TotalSeconds.ToString("F2")s @Result.TaxRateLookups tax lookups
-
+
} @if (StockMessage is not null) { -
+

Stock reservation

-

Reserving stock writes an audit entry while holding the ledger lock.

+

Reserving stock writes an audit entry while holding the ledger lock.

@StockMessage
-
+ } - +
diff --git a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Program.cs b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Program.cs index 43e9d9b..3f34914 100644 --- a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Program.cs +++ b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Program.cs @@ -18,6 +18,11 @@ public static void Main(string[] args) builder.Services.AddSingleton(); builder.Services.AddSingleton(); + // Workshop plumbing: one StepVerifier holds every step's result. It is also a hosted service, + // so it checks your services against every step each time the app starts. + builder.Services.AddSingleton(); + builder.Services.AddHostedService(static serviceProvider => serviceProvider.GetRequiredService()); + var app = builder.Build(); if (!app.Environment.IsDevelopment()) diff --git a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Services/InventoryLedger.cs b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Services/InventoryLedger.cs index ce08cfa..4f03bef 100644 --- a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Services/InventoryLedger.cs +++ b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Services/InventoryLedger.cs @@ -16,6 +16,7 @@ public sealed class InventoryLedger : IDisposable readonly List _auditTrail = []; + // ToDo Refactor (Step 4): List is not thread safe, and this reads it while the audit writer appends to it public int AuditEntries => _auditTrail.Count; public void Dispose() => _ledgerSemaphore.Dispose(); @@ -31,7 +32,7 @@ public async Task ReserveStockAsync(string sku, int quantity, Cancellation return false; } - // ToDo Refactor: this call also waits on _ledgerSemaphore, which this + // ToDo Refactor (Step 4): this call also waits on _ledgerSemaphore, which this // method is already holding. SemaphoreSlim is not reentrant, so the // thread waits for a permit it will never release. That is a deadlock. await WriteAuditEntryAsync($"Reserved {quantity} of {sku}", token).ConfigureAwait(false); diff --git a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Services/OrderMetrics.cs b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Services/OrderMetrics.cs index f4e9b07..db5925e 100644 --- a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Services/OrderMetrics.cs +++ b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Services/OrderMetrics.cs @@ -6,18 +6,28 @@ public sealed class OrderMetrics int _ordersPlaced; decimal _revenue; + // ToDo Refactor (Step 1): an int read can be stale. The page should read the latest count, + // not one cached on another thread. public int OrdersPlaced => _ordersPlaced; + // ToDo Refactor (Step 2): reads can be wrong too. A decimal is 16 bytes, and this + // can read one while another thread is halfway through writing it. public decimal Revenue => _revenue; - // ToDo Refactor: `++` is a read, an add, and a write. Two threads can read the - // same value before either writes, and one order silently disappears. public void RecordOrder(decimal orderTotal) { + // ToDo Refactor (Step 1): `++` is a read, an add, and a write. Two threads can read the + // same value before either writes, and one order silently disappears. _ordersPlaced++; + + // ToDo Refactor (Step 2): `+=` on a decimal is a much wider read, modify, write than + // `++` on an int, and Interlocked has no overload for decimal. _revenue += orderTotal; } + // ToDo Refactor (Step 1): Reset() runs at the start of every burst, so it + // touches the same order count as RecordOrder(). + // ToDo Refactor (Step 2): it touches the same revenue total too. public void Reset() { _ordersPlaced = 0; diff --git a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Services/TaxRateProvider.cs b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Services/TaxRateProvider.cs index 001af04..29c4a7e 100644 --- a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Services/TaxRateProvider.cs +++ b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Services/TaxRateProvider.cs @@ -7,25 +7,29 @@ public sealed class TaxRateProvider int _lookups; int _builds; - // ToDo Refactor: two threads can both find this null and both build the table + // ToDo Refactor (Step 3): two threads can both find this null and both build the table IReadOnlyDictionary? _rates; + // ToDo Refactor (Step 3): an int read can be stale, so the page may not see the latest count public int Lookups => _lookups; // How many times the expensive table was actually built. Should be 1. + // ToDo Refactor (Step 3): the same stale read as Lookups public int Builds => _builds; public decimal GetRate(string region) { + // ToDo Refactor (Step 3): the same read, add, and write as `_ordersPlaced++` _lookups++; - // ToDo Refactor: `??=` is not atomic. Under load this runs BuildRates() + // ToDo Refactor (Step 3): `??=` is not atomic. Under load this runs BuildRates() // many times, and every caller pays the full build cost. _rates ??= BuildRates(); return _rates.TryGetValue(region, out var rate) ? rate : 0m; } + // ToDo Refactor (Step 3): after a reset, the next burst must build the table again, exactly once public void Reset() { _rates = null; diff --git a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Steps/Step1CountEveryOrder.cs b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Steps/Step1CountEveryOrder.cs new file mode 100644 index 0000000..7259f8f --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Steps/Step1CountEveryOrder.cs @@ -0,0 +1,80 @@ +using OrderPortal.Components.Pages; + +namespace OrderPortal; + +public sealed class Step1CountEveryOrder : WorkshopStep +{ + const int _threadCount = 32; + const int _ordersPerThread = 25_000; + + const string _lostOrdersHint = "Orders went missing and nothing threw. _ordersPlaced++ is a read, an add, and a write, and two threads can read the same value before either one writes. " + + "Update the count atomically, so nothing can slip between the read and the write."; + + public override int Number => 1; + + public override string Scenario => "Count every order"; + + public override string Title => "OrderMetrics.RecordOrder() and OrdersPlaced"; + + public override string Story => "A sale goes live and two thousand customers press Buy in the same second. " + + "Every checkout calls RecordOrder() on the one OrderMetrics singleton, and the Orders recorded number on the revenue report has to match the orders table exactly. " + + "When it does not, nothing throws and nothing logs a warning. The count is just quietly low."; + + public override string SeeItInTheApp => "On the Checkout page, press Run 2000 checkouts. The Orders recorded card expects 2000 and shows something slightly lower, such as 1996. " + + "Those orders were placed. The counter lost them. Press it again and the number changes, which is why a single passing run proves nothing."; + + public override string FileToChange => "Services/OrderMetrics.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Update the order count in RecordOrder() atomically, so no increment is ever lost.", + "Make the OrdersPlaced getter safe too, so it cannot hand the page a stale count.", + "Keep Reset() correct. It runs at the start of every burst, against the same shared count.", + "Leave CheckoutService, the pages, and the singleton registrations in Program.cs alone. The load is not the bug.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "_ordersPlaced++ is not one operation. It is a read, an add, and a write.", + "Reads can be wrong as well as writes. An int read can be stale.", + "The cheapest tool in this section's toolbox is lock free, so no thread ever waits.", + ]; + + public override string TimeoutHint => "The checkout burst never finished. Check that nothing in OrderMetrics can block or wait on itself."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + // Part 1: the same burst the Checkout page runs, through fresh copies of the services Program.cs registers as singletons + var metrics = new OrderMetrics(); + using var ledger = new InventoryLedger(); + using var checkout = new CheckoutService(metrics, new TaxRateProvider(), ledger); + + report.Log($"Running {CheckoutPageBase.OrderCount:N0} checkouts through Parallel.ForEachAsync, which runs one checkout per core ({Environment.ProcessorCount} on this machine)"); + var result = await checkout.RunCheckoutBurstAsync(CheckoutPageBase.OrderCount, token).ConfigureAwait(false); + report.Log($"The burst finished in {result.Elapsed.TotalSeconds:F2}s, and OrdersPlaced reads {result.OrdersPlaced:N0}"); + + report.Expect("The checkout burst records every order", CheckoutPageBase.OrderCount, result.OrdersPlaced, _lostOrdersHint); + + // Part 2: one burst can get lucky. 32 threads calling RecordOrder() as fast as they can almost never do. + var stressMetrics = new OrderMetrics(); + report.Log($"Recording {_threadCount * _ordersPerThread:N0} orders from {_threadCount} threads at the same time"); + + Parallel.For(0, _threadCount, new ParallelOptions { MaxDegreeOfParallelism = _threadCount, CancellationToken = token }, _ => + { + for (var order = 0; order < _ordersPerThread; order++) + { + stressMetrics.RecordOrder(1m); + } + }); + + report.Log($"OrdersPlaced reads {stressMetrics.OrdersPlaced:N0}"); + report.Expect($"{_threadCount} threads recording {_ordersPerThread:N0} orders each lose none of them", _threadCount * _ordersPerThread, stressMetrics.OrdersPlaced, _lostOrdersHint); + + // Part 3: Reset() runs at the start of every burst, so it touches the same shared count + stressMetrics.Reset(); + report.Expect("Reset() sets OrdersPlaced back to 0", 0, stressMetrics.OrdersPlaced, "Reset() runs at the start of every burst. It has to clear the same shared count that RecordOrder() updates."); + } +} \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Steps/Step2AddUpRevenue.cs b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Steps/Step2AddUpRevenue.cs new file mode 100644 index 0000000..fb0ffdd --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Steps/Step2AddUpRevenue.cs @@ -0,0 +1,92 @@ +using OrderPortal.Components.Pages; + +namespace OrderPortal; + +public sealed class Step2AddUpRevenue : WorkshopStep +{ + const int _threadCount = 32; + const int _ordersPerThread = 25_000; + const decimal _orderTotal = 1.25m; + + const string _lostRevenueHint = "Revenue went missing and nothing threw. decimal += is a much wider read, modify, write than int++, and Interlocked has no overload for decimal. " + + "Guard every write to the revenue, and every read of it, with the same lock."; + + public override int Number => 2; + + public override string Scenario => "Add up the revenue"; + + public override string Title => "OrderMetrics.RecordOrder(), Revenue and Reset()"; + + public override string Story => "At the end of the sale, finance reconciles the Revenue number against the payment processor. " + + "It is short, and by far more than the missing orders are worth. A 16 byte decimal update is a much wider window for two threads to collide in than an int, " + + "and the shortfall is different on every run, so a single run that happens to add up proves nothing."; + + public override string SeeItInTheApp => "On the Checkout page, the Revenue card is short too, and by considerably more than the missing orders are worth, often several times more. " + + "The two shortfalls never line up, no matter how carefully you divide."; + + public override string FileToChange => "Services/OrderMetrics.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Guard the revenue total. Interlocked cannot help you here, so pick the right lock for a synchronous update.", + "Read Revenue under the same lock, so the page can never read a decimal that is halfway through an update.", + "Keep Reset() correct. It clears the revenue at the start of every burst, so it needs the same lock.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "Interlocked has overloads for int and long. It has none for decimal.", + "A decimal is 16 bytes. A read can catch one halfway through an update.", + ".NET 9 added a dedicated Lock type, and the lock statement understands it.", + ]; + + public override string TimeoutHint => "The checkout burst never finished. Check that every lock in OrderMetrics is released, and that no method takes a lock it already holds."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + // Part 1: every order in the burst has a known total, so the revenue has exactly one correct value + var metrics = new OrderMetrics(); + using var ledger = new InventoryLedger(); + using var checkout = new CheckoutService(metrics, new TaxRateProvider(), ledger); + + report.Log($"Running {CheckoutPageBase.OrderCount:N0} checkouts through Parallel.ForEachAsync"); + var result = await checkout.RunCheckoutBurstAsync(CheckoutPageBase.OrderCount, token).ConfigureAwait(false); + report.Log($"The burst finished in {result.Elapsed.TotalSeconds:F2}s, and Revenue reads {result.Revenue:C2}"); + + report.Expect( + "The checkout burst adds up to the expected revenue", + CheckoutPageBase.ExpectedRevenue.ToString("C2"), + result.Revenue.ToString("C2"), + result.Revenue == CheckoutPageBase.ExpectedRevenue, + _lostRevenueHint); + + // Part 2: 32 threads adding the same order total as fast as they can + var stressMetrics = new OrderMetrics(); + var expectedRevenue = _threadCount * _ordersPerThread * _orderTotal; + report.Log($"Recording {_threadCount * _ordersPerThread:N0} orders of {_orderTotal:C2} from {_threadCount} threads at the same time"); + + Parallel.For(0, _threadCount, new ParallelOptions { MaxDegreeOfParallelism = _threadCount, CancellationToken = token }, _ => + { + for (var order = 0; order < _ordersPerThread; order++) + { + stressMetrics.RecordOrder(_orderTotal); + } + }); + + var stressRevenue = stressMetrics.Revenue; + report.Log($"Revenue reads {stressRevenue:C2}"); + report.Expect( + $"{_threadCount} threads adding {_ordersPerThread:N0} orders each lose none of the revenue", + expectedRevenue.ToString("C2"), + stressRevenue.ToString("C2"), + stressRevenue == expectedRevenue, + _lostRevenueHint); + + // Part 3: Reset() runs at the start of every burst, so it touches the same shared total + stressMetrics.Reset(); + report.Expect("Reset() sets Revenue back to 0", 0m.ToString("C2"), stressMetrics.Revenue.ToString("C2"), stressMetrics.Revenue is 0, "Reset() runs at the start of every burst. It has to clear the revenue under the same lock that RecordOrder() uses to update it."); + } +} \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Steps/Step3BuildTaxTableOnce.cs b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Steps/Step3BuildTaxTableOnce.cs new file mode 100644 index 0000000..121d224 --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Steps/Step3BuildTaxTableOnce.cs @@ -0,0 +1,133 @@ +using OrderPortal.Components.Pages; + +namespace OrderPortal; + +public sealed class Step3BuildTaxTableOnce : WorkshopStep +{ + const int _threadCount = 32; + const int _lookupsPerThread = 10_000; + const string _region = "US-CA"; + const decimal _expectedRate = 0.0925m; + + const string _buildsHint = "_rates ??= BuildRates() is a null check followed by an assignment, with a 120 millisecond gap in between. Every thread that asks for a rate during that gap finds the cache empty and builds its own table. " + + "Build the table exactly once, no matter how many threads call GetRate() at the same moment."; + + const string _lookupsHint = "Lookups went missing and nothing threw. _lookups++ is a read, an add, and a write, the same bug you fixed in OrderMetrics."; + + const string _resetHint = "Reset() runs at the start of every burst. After it, the provider has to build the table again on the next GetRate(), and still build it exactly once."; + + public override int Number => 3; + + public override string Scenario => "Build the tax table once"; + + public override string Title => "TaxRateProvider.GetRate(), Lookups, Builds and Reset()"; + + public override string Story => "Building the tax table takes 120 milliseconds, so TaxRateProvider builds it once and caches it for the life of the process. At least, that is the intent. " + + "When the first burst of a sale arrives, every checkout that asks for a rate during those 120 milliseconds finds the cache empty and pays for a table of its own. " + + "The number of tables you build turns out to be your core count."; + + public override string SeeItInTheApp => "On the Checkout page, the Tax table builds card expects 1 and shows one build per CPU core, so 16 on a 16 core machine and 8 on an 8 core laptop. " + + "The note under Elapsed counts tax rate lookups, and that counter is a plain ++ as well."; + + public override string FileToChange => "Services/TaxRateProvider.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Build the expensive tax table exactly once, no matter how many threads call GetRate() at the same moment.", + "Count tax rate lookups atomically, and read Lookups and Builds so the page never sees a stale value.", + "Keep Reset() working. After a reset, the next burst must build the table again, exactly once.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "_rates ??= BuildRates() is a null check followed by an assignment, with a gap in between.", + "One of the types in this section's toolbox exists to run a factory exactly once, however many threads ask for its value at the same time.", + "Reset() does not have to undo a table that was already built. It only has to leave the provider ready to build a fresh one.", + ]; + + public override string TimeoutHint => "GetRate() never returned. Check that the first caller builds the table and every other caller can read it once it is built."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + // Part 1: 32 threads wait at a Barrier, then all ask for a rate at the same instant, the way the first checkouts of a sale do + var taxRates = new TaxRateProvider(); + report.Log($"{_threadCount} threads are asking for the {_region} tax rate at the same instant"); + + var rates = AskForRatesAtTheSameTime(taxRates); + report.Log($"The table was built {taxRates.Builds} times"); + + report.Expect($"{_threadCount} threads asking for a rate at the same instant build the table once", 1, taxRates.Builds, _buildsHint); + + var correctRates = rates.Count(static rate => rate is _expectedRate); + report.Expect($"Every thread gets the {_region} rate of {_expectedRate}", $"{_threadCount} of {_threadCount}", $"{correctRates} of {_threadCount}", correctRates is _threadCount, "Every caller has to read the same table. Check what GetRate() returns while the table is still being built."); + + // Part 2: the table is built, and now every checkout looks up a rate as fast as it can + report.Log($"{_threadCount} threads are each looking up {_lookupsPerThread:N0} rates"); + + Parallel.For(0, _threadCount, new ParallelOptions { MaxDegreeOfParallelism = _threadCount, CancellationToken = token }, _ => + { + for (var lookup = 0; lookup < _lookupsPerThread; lookup++) + { + taxRates.GetRate(_region); + } + }); + + var expectedLookups = _threadCount + (_threadCount * _lookupsPerThread); + report.Log($"Lookups reads {taxRates.Lookups:N0}"); + report.Expect("Every lookup is counted", expectedLookups, taxRates.Lookups, _lookupsHint); + + // Part 3: after Reset() the next burst must build the table again, exactly once + taxRates.Reset(); + report.Expect("Reset() sets Builds and Lookups back to 0", "0 builds, 0 lookups", $"{taxRates.Builds} builds, {taxRates.Lookups} lookups", taxRates.Builds is 0 && taxRates.Lookups is 0, _resetHint); + + AskForRatesAtTheSameTime(taxRates); + report.Log($"After Reset(), the table was built {taxRates.Builds} times"); + report.Expect($"After Reset(), {_threadCount} threads asking at the same instant build the table once again", 1, taxRates.Builds, _resetHint); + + // Part 4: the burst the Checkout page runs resets the provider first, then shows the same number on the Tax table builds card + var metrics = new OrderMetrics(); + using var ledger = new InventoryLedger(); + using var checkout = new CheckoutService(metrics, new TaxRateProvider(), ledger); + + report.Log($"Running {CheckoutPageBase.OrderCount:N0} checkouts through Parallel.ForEachAsync"); + var result = await checkout.RunCheckoutBurstAsync(CheckoutPageBase.OrderCount, token).ConfigureAwait(false); + + report.Expect("The checkout burst builds the tax table once", 1, result.TaxRateBuilds, _buildsHint); + } + + static decimal[] AskForRatesAtTheSameTime(TaxRateProvider taxRates) + { + var rates = new decimal[_threadCount]; + var exceptions = new Exception?[_threadCount]; + + using var barrier = new Barrier(_threadCount); + + var threads = Enumerable.Range(0, _threadCount) + .Select(index => new Thread(() => + { + barrier.SignalAndWait(); + + // An exception on a dedicated thread would end the whole app, so keep it and rethrow it from this method instead + try + { + rates[index] = taxRates.GetRate(_region); + } + catch (Exception e) + { + exceptions[index] = e; + } + })) + .ToList(); + + threads.ForEach(static thread => thread.Start()); + threads.ForEach(static thread => thread.Join()); + + if (exceptions.FirstOrDefault(static exception => exception is not null) is { } firstException) + throw new InvalidOperationException("TaxRateProvider.GetRate() threw", firstException); + + return rates; + } +} \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Steps/Step4ReserveStock.cs b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Steps/Step4ReserveStock.cs new file mode 100644 index 0000000..3639d4c --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Steps/Step4ReserveStock.cs @@ -0,0 +1,131 @@ +namespace OrderPortal; + +public sealed class Step4ReserveStock : WorkshopStep +{ + const string _sku = "SKU-1000"; + const int _stockOnHand = 5_000; + const int _reservationCount = 200; + + const string _deadlockHint = "No thread is blocked and nothing threw, because WaitAsync is awaited. The ledger's only permit is simply never released. " + + "SemaphoreSlim is not reentrant: ask which method takes the permit, and which method it calls next. Keep the stock update and its audit entry under one lock, without taking that lock twice."; + + const string _skippedHint = "The ledger is still holding its only permit, so every later call on it would wait too. Fix the deadlock first."; + + // A real request is eventually abandoned by its client. These calls give up after 2 seconds, so a deadlock ends this step instead of hanging it. + static readonly TimeSpan _ledgerTimeout = TimeSpan.FromSeconds(2); + + public override int Number => 4; + + public override string Scenario => "Reserve stock without waiting on yourself"; + + public override string Title => "InventoryLedger.ReserveStockAsync() and WriteAuditEntryAsync()"; + + public override string Story => "Every reservation has to update the stock and write its audit entry under one lock, so the ledger can never show stock leaving the warehouse without a record of why. " + + "InventoryLedger uses SemaphoreSlim, which is the right lock for code that awaits. Reserve stock still never answers. " + + "Nothing is pegged, nothing throws, and a thread dump shows nothing waiting. The request just never completes, and every later reservation queues up behind it."; + + public override string SeeItInTheApp => "On the Checkout page, press Reserve stock. The panel says Reserving stock... and then sits there. " + + "Five seconds later it gives up with a timeout message. Nothing crashed. The request is simply stuck."; + + public override string FileToChange => "Services/InventoryLedger.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Remove the deadlock in ReserveStockAsync() without losing the guarantee that the stock update and its audit entry happen under the same lock.", + "Keep WriteAuditEntryAsync() callable on its own, by a caller that does not already hold the ledger lock.", + "Guard the audit trail list with a lock, so AuditEntries never reads it while an entry is being written.", + "Keep SemaphoreSlim in InventoryLedger. lock cannot be held across an await, and the ledger awaits.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "InventoryLedger uses SemaphoreSlim rather than lock, and that part is correct.", + "SemaphoreSlim is not reentrant. Ask which method takes the permit, and which method it calls next.", + "A common pattern: a public method that takes the lock, and a private method that assumes the lock is already held.", + "List is not thread safe, and a lock is all this one needs.", + ]; + + public override string TimeoutHint => "A ledger call ignored its 2 second timeout. Check that every wait on the ledger's semaphore passes the CancellationToken it was given."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + using var ledger = new InventoryLedger(); + + // Part 1: one reservation, the same call the Reserve stock button makes + report.Log($"Reserving 1 of {_sku}"); + + var (answered, reserved) = await WithinLedgerTimeout(timeoutToken => ledger.ReserveStockAsync(_sku, 1, timeoutToken), token).ConfigureAwait(false); + + if (!answered) + { + report.Log("Still waiting for the ledger lock after 2 seconds. Giving up, the way a client would"); + report.Expect("Reserving 1 unit answers within 2 seconds", "True, within 2 seconds", "no answer after 2 seconds", false, _deadlockHint); + report.Expect("WriteAuditEntryAsync() works on its own", "completed", "Skipped: the ledger is deadlocked", false, _skippedHint); + report.Expect($"{_reservationCount} reservations at the same time all succeed", $"{_reservationCount} of {_reservationCount}", "Skipped: the ledger is deadlocked", false, _skippedHint); + return; + } + + report.Log($"ReserveStockAsync() answered {reserved}"); + report.Expect("Reserving 1 unit answers within 2 seconds", "True, within 2 seconds", $"{reserved}, within 2 seconds", reserved, _deadlockHint); + report.Expect("The reservation wrote its audit entry", 1, ledger.AuditEntries, "Every reservation has to write exactly one audit entry, inside the same lock as the stock update."); + + // Part 2: a caller that does not hold the ledger lock can still write an audit entry on its own + report.Log("Writing a cycle count adjustment to the audit trail on its own"); + + var (wroteEntry, _) = await WithinLedgerTimeout(async timeoutToken => + { + await ledger.WriteAuditEntryAsync("Cycle count adjustment", timeoutToken).ConfigureAwait(false); + return true; + }, token).ConfigureAwait(false); + + report.Expect( + "WriteAuditEntryAsync() works on its own, for a caller that does not already hold the ledger lock", + "completed, 2 audit entries", + wroteEntry ? $"completed, {ledger.AuditEntries} audit entries" : "no answer after 2 seconds", + wroteEntry && ledger.AuditEntries is 2, + "WriteAuditEntryAsync() still has to take the ledger lock itself, because its caller does not hold it."); + + // Part 3: a burst of reservations at the same time. The semaphore lets them in one at a time. + report.Log($"Placing {_reservationCount} reservations for {_sku} at the same time"); + + var (reservationsAnswered, reservations) = await WithinLedgerTimeout( + timeoutToken => Task.WhenAll(Enumerable.Range(0, _reservationCount).Select(_ => ledger.ReserveStockAsync(_sku, 1, timeoutToken))), + token).ConfigureAwait(false); + + var succeeded = reservations?.Count(static reservation => reservation) ?? 0; + report.Log($"{succeeded} reservations succeeded, and the audit trail has {ledger.AuditEntries} entries"); + + report.Expect($"{_reservationCount} reservations at the same time all succeed", $"{_reservationCount} of {_reservationCount}", reservationsAnswered ? $"{succeeded} of {_reservationCount}" : "no answer after 2 seconds", succeeded is _reservationCount, _deadlockHint); + report.Expect("Each of them wrote one audit entry", 2 + _reservationCount, ledger.AuditEntries, "Every reservation has to write exactly one audit entry, inside the same lock as the stock update."); + + // Part 4: 201 units have been reserved, so there is no longer enough stock for the whole shelf + var reservedSoFar = 1 + _reservationCount; + var (tooManyAnswered, tooMany) = await WithinLedgerTimeout(timeoutToken => ledger.ReserveStockAsync(_sku, _stockOnHand, timeoutToken), token).ConfigureAwait(false); + + report.Expect( + $"Reserving all {_stockOnHand:N0} again is refused, because {reservedSoFar} are already reserved", + "False", + tooManyAnswered ? tooMany.ToString() : "no answer after 2 seconds", + tooManyAnswered && !tooMany, + "Every successful reservation has to reduce the stock on hand."); + } + + // Answered is false when the call is still waiting after 2 seconds + static async Task<(bool Answered, T? Value)> WithinLedgerTimeout(Func> call, CancellationToken token) + { + using var timeoutCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + timeoutCancellationTokenSource.CancelAfter(_ledgerTimeout); + + try + { + return (true, await call(timeoutCancellationTokenSource.Token).ConfigureAwait(false)); + } + catch (OperationCanceledException) when (timeoutCancellationTokenSource.IsCancellationRequested && !token.IsCancellationRequested) + { + return (false, default); + } + } +} \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Steps/StepContext.cs b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Steps/StepContext.cs new file mode 100644 index 0000000..300f9b0 --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Steps/StepContext.cs @@ -0,0 +1,4 @@ +namespace OrderPortal; + +// Token is cancelled when the run is stopped, times out, or the app shuts down +public sealed record StepContext(StepReport Report, CancellationToken Token); \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Steps/WorkshopStep.cs b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Steps/WorkshopStep.cs new file mode 100644 index 0000000..09ef469 --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Steps/WorkshopStep.cs @@ -0,0 +1,37 @@ +namespace OrderPortal; + +// Every step is one bug in OrderPortal's shared services. +// Its tab in the workshop guide tells you what to fix, and Run() drives your code the way a sale does, logs what happens, +// and records an expected result, with a hint, for everything that should be true once the bug is fixed. +public abstract class WorkshopStep +{ + public abstract int Number { get; } + + // What this step fixes, in a few words + public abstract string Scenario { get; } + + // The members this step exercises + public abstract string Title { get; } + + // What goes wrong in production, and why nobody noticed with one customer at a time + public abstract string Story { get; } + + // How to watch this bug happen on the Checkout page + public abstract string SeeItInTheApp { get; } + + // Where the bug lives + public abstract string FileToChange { get; } + + // What this step asks you to change. Some of it cannot be measured from outside, so not every task has an expected result. + public abstract IReadOnlyList Tasks { get; } + + // Shown only when you ask for them + public abstract IReadOnlyList Clues { get; } + + // What is most likely waiting forever when this step does not finish in time + public abstract string TimeoutHint { get; } + + public string SourceFile => $"Steps/{GetType().Name}.cs"; + + public abstract Task Run(StepContext context); +} \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Verification/ActiveStepRun.cs b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Verification/ActiveStepRun.cs new file mode 100644 index 0000000..4c5c92d --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Verification/ActiveStepRun.cs @@ -0,0 +1,3 @@ +namespace OrderPortal; + +public sealed record ActiveStepRun(WorkshopStep Step, StepReport Report); \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Verification/StepCheck.cs b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Verification/StepCheck.cs new file mode 100644 index 0000000..43ff1cb --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Verification/StepCheck.cs @@ -0,0 +1,3 @@ +namespace OrderPortal; + +public sealed record StepCheck(string Description, string Expected, string Actual, bool Passed, string Hint); \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Verification/StepLogEntry.cs b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Verification/StepLogEntry.cs new file mode 100644 index 0000000..44f9b01 --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Verification/StepLogEntry.cs @@ -0,0 +1,3 @@ +namespace OrderPortal; + +public sealed record StepLogEntry(TimeSpan Elapsed, int ThreadId, bool IsThreadPoolThread, string Message); \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Verification/StepReport.cs b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Verification/StepReport.cs new file mode 100644 index 0000000..2bd7540 --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Verification/StepReport.cs @@ -0,0 +1,82 @@ +using System.Diagnostics; + +namespace OrderPortal; + +// Collects what a step logged and every expected result it checked. +// Steps write to it from thread pool threads while the workshop guide reads it, so every member takes the lock. +public sealed class StepReport +{ + readonly Lock _lock = new(); + readonly Stopwatch _stopwatch = Stopwatch.StartNew(); + readonly List _entries = []; + readonly List _checks = []; + + bool _isComplete; + + public IReadOnlyList Entries + { + get + { + lock (_lock) + { + return [.. _entries]; + } + } + } + + public IReadOnlyList Checks + { + get + { + lock (_lock) + { + return [.. _checks]; + } + } + } + + public void Log(string message) + { + var threadId = Environment.CurrentManagedThreadId; + var isThreadPoolThread = Thread.CurrentThread.IsThreadPoolThread; + + lock (_lock) + { + if (!_isComplete) + _entries.Add(new StepLogEntry(_stopwatch.Elapsed, threadId, isThreadPoolThread, message)); + } + } + + // The hint is shown under the expected result only when it does not match + public void Expect(string description, T expected, T actual, string hint) + { + Expect(description, Format(expected), Format(actual), EqualityComparer.Default.Equals(expected, actual), hint); + } + + public void Expect(string description, string expected, string actual, bool passed, string hint) + { + lock (_lock) + { + if (!_isComplete) + _checks.Add(new StepCheck(description, expected, actual, passed, hint)); + } + } + + // Called when the run ends, including a run that timed out or was stopped while its step is still blocked somewhere. + // Anything that abandoned step writes later is ignored, so the result in the workshop guide can no longer change. + public void Complete() + { + lock (_lock) + { + _isComplete = true; + _stopwatch.Stop(); + } + } + + static string Format(T value) => value switch + { + null => "null", + int or long => $"{value:N0}", + _ => value.ToString() ?? "null", + }; +} \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Verification/StepState.cs b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Verification/StepState.cs new file mode 100644 index 0000000..91fcc90 --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Verification/StepState.cs @@ -0,0 +1,9 @@ +namespace OrderPortal; + +// ExceptionType is set for Crashed. Only the name is kept: exception messages and stack traces stay in the terminal. +// CompletedAt is when the run that produced this state ended, so the workshop guide can show that a click really ran again. +public sealed record StepState( + StepStatus Status, + StepReport? Report, + string? ExceptionType = null, + DateTimeOffset? CompletedAt = null); \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Verification/StepStatus.cs b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Verification/StepStatus.cs new file mode 100644 index 0000000..7b4b74b --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Verification/StepStatus.cs @@ -0,0 +1,12 @@ +namespace OrderPortal; + +public enum StepStatus +{ + NotRun, + Running, + Passed, + Failed, + TimedOut, + Crashed, + Stopped, +} \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Verification/StepVerifier.cs b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Verification/StepVerifier.cs new file mode 100644 index 0000000..081f25a --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/Verification/StepVerifier.cs @@ -0,0 +1,231 @@ +namespace OrderPortal; + +// Workshop plumbing: runs the steps against your services and keeps their results. +// Every step creates its own instances of the services, so a check never disturbs the singletons the Checkout page uses. +// Only one step runs at a time, so a step that measures contention never competes with another run for thread pool threads. +public sealed class StepVerifier(ILogger logger, IHostApplicationLifetime applicationLifetime) : BackgroundService +{ + public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); + + readonly Lock _lock = new(); + readonly StepState[] _states = [.. Enumerable.Repeat(new StepState(StepStatus.NotRun, null), 4)]; + + // Both start at 1: the app checks every step on startup, and nothing else may run before that finishes + int _isBusy = 1; + int _isCheckingAllSteps = 1; + + ActiveStepRun? _activeRun; + TaskCompletionSource? _stopRequested; + + public IReadOnlyList Steps { get; } = + [ + new Step1CountEveryOrder(), + new Step2AddUpRevenue(), + new Step3BuildTaxTableOnce(), + new Step4ReserveStock(), + ]; + + public bool IsBusy => Volatile.Read(ref _isBusy) is 1; + + public bool IsCheckingAllSteps => Volatile.Read(ref _isCheckingAllSteps) is 1; + + public ActiveStepRun? ActiveRun + { + get + { + lock (_lock) + { + return _activeRun; + } + } + } + + public WorkshopStep? FindStep(int number) => Steps.FirstOrDefault(step => step.Number == number); + + public StepState GetState(int number) + { + lock (_lock) + { + return _states[number - 1]; + } + } + + public bool IsUnlocked(int number) => number is 1 || GetState(number - 1).Status is StepStatus.Passed; + + // Re-runs every step, the same way startup does + public async Task CheckAllSteps() + { + if (Interlocked.CompareExchange(ref _isBusy, 1, 0) is not 0) + return; + + Volatile.Write(ref _isCheckingAllSteps, 1); + + try + { + await CheckSteps(applicationLifetime.ApplicationStopping).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isCheckingAllSteps, 0); + Volatile.Write(ref _isBusy, 0); + } + } + + public async Task RunStep(WorkshopStep step) + { + if (!IsUnlocked(step.Number) || Interlocked.CompareExchange(ref _isBusy, 1, 0) is not 0) + return GetState(step.Number); + + try + { + return await Run(step, applicationLifetime.ApplicationStopping).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isBusy, 0); + } + } + + public void StopRun() + { + lock (_lock) + { + _stopRequested?.TrySetResult(); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await CheckSteps(stoppingToken).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isCheckingAllSteps, 0); + Volatile.Write(ref _isBusy, 0); + } + } + + async Task CheckSteps(CancellationToken token) + { + try + { + // Results from before the latest change to the services are not trusted + lock (_lock) + { + Array.Fill(_states, new StepState(StepStatus.NotRun, null)); + } + + logger.LogInformation("Checking your services against every step. Open the app in your browser: the workshop guide beside the Checkout page shows what to fix next"); + + foreach (var step in Steps) + { + var state = await Run(step, token).ConfigureAwait(false); + + if (state.Status is not StepStatus.Passed) + { + logger.LogInformation("Stopped checking at Step {StepNumber}. Open it in the app to see what to change, with a hint for every result that did not match. Later steps unlock after it passes", step.Number); + return; + } + } + + logger.LogInformation("Every step passed. Pause here for the group review"); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The app is shutting down + } + catch (Exception e) + { + logger.LogError(e, "Checking the steps failed unexpectedly"); + } + } + + async Task Run(WorkshopStep step, CancellationToken token) + { + var report = new StepReport(); + var stopRequested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var previousState = GetState(step.Number); + + lock (_lock) + { + _activeRun = new ActiveStepRun(step, report); + _stopRequested = stopRequested; + _states[step.Number - 1] = new StepState(StepStatus.Running, report); + } + + // Cancelled when the run ends for any reason, so a step that is still working can stop + using var runCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + + var state = previousState; + + try + { + state = await Execute(step, report, stopRequested.Task, runCancellationTokenSource.Token, token).ConfigureAwait(false); + } + finally + { + await runCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + report.Complete(); + + lock (_lock) + { + _activeRun = null; + _stopRequested = null; + _states[step.Number - 1] = state with { CompletedAt = DateTimeOffset.Now }; + } + } + + return state; + } + + async Task Execute(WorkshopStep step, StepReport report, Task stopRequested, CancellationToken runToken, CancellationToken token) + { + try + { + // A dedicated thread runs the step until its first await. If a deadlock blocks it forever, + // a thread pool thread is not lost for the rest of the app's life. + var stepTask = Task.Factory.StartNew(() => step.Run(new StepContext(report, runToken)), token, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); + + var finished = await Task.WhenAny(stepTask, stopRequested).WaitAsync(Timeout, token).ConfigureAwait(false); + + if (finished == stopRequested) + { + logger.LogInformation("Step {StepNumber} was stopped", step.Number); + return new StepState(StepStatus.Stopped, report); + } + + await stepTask.ConfigureAwait(false); + + var checks = report.Checks; + var passedChecks = checks.Count(static check => check.Passed); + var status = checks.Count > 0 && passedChecks == checks.Count ? StepStatus.Passed : StepStatus.Failed; + + logger.LogInformation("Step {StepNumber} {Result} ({PassedChecks}/{TotalChecks} checks)", step.Number, status is StepStatus.Passed ? "passed" : "failed", passedChecks, checks.Count); + + // The hints belong in the terminal too, next to anything the app logged while the step ran + foreach (var check in checks.Where(static check => !check.Passed)) + { + logger.LogWarning("Step {StepNumber}: {Description}. Expected {Expected}, actual {Actual}. Hint: {Hint}", step.Number, check.Description, check.Expected, check.Actual, check.Hint); + } + + return new StepState(status, report); + } + catch (TimeoutException) + { + logger.LogWarning("Step {StepNumber} did not finish within {Timeout}. {TimeoutHint}", step.Number, Timeout, step.TimeoutHint); + return new StepState(StepStatus.TimedOut, report); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + return new StepState(StepStatus.NotRun, null); + } + catch (Exception e) + { + logger.LogError(e, "Step {StepNumber} threw an unexpected exception", step.Number); + return new StepState(StepStatus.Crashed, report, e.GetType().Name); + } + } +} \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/wwwroot/app.css b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/wwwroot/app.css index c5a79b2..3b0ec5d 100644 --- a/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/wwwroot/app.css +++ b/5. Asynchronous vs Parallel Programming/1. Start/OrderPortal/wwwroot/app.css @@ -1,20 +1,30 @@ +/* OrderPortal: a Black Friday checkout war room, with the workshop guide docked beside it as a launch checklist */ + :root { - --canvas: #eef3f1; - --panel: #ffffff; - --panel-strong: #112a2d; - --ink: #18211f; - --muted: #62706d; - --line: #d7e0dd; - --accent: #f46a1f; - --teal: #0f766e; + --night: #0b1320; + --night-raised: #131e31; + --night-card: #18253b; + --night-line: rgb(255 255 255 / 11%); + --night-ink: #f4f1ea; + --night-muted: #9aa6ba; + --coral: #ff5a36; + --coral-dark: #e2431f; + --gold: #ffc233; + --mint: #33d69f; + --red: #ff5c6c; + --paper: #fff9ef; + --paper-raised: #ffffff; + --paper-line: #eadfcb; + --ink: #1d1a16; + --muted: #6f665a; --good: #0f8a52; - --bad: #cf202f; - --warn: #c59d35; - --shadow: 0 18px 55px rgb(28 45 42 / 14%); + --bad: #c8283a; + --warn: #b7861b; + --guide-width: 460px; color-scheme: light; font-family: "Aptos", "Avenir Next", "Segoe UI", sans-serif; - background: var(--canvas); - color: var(--ink); + background: var(--night); + color: var(--night-ink); } * { @@ -24,248 +34,865 @@ body { margin: 0; overflow-x: clip; - background: - linear-gradient(135deg, rgb(15 118 110 / 14%), transparent 32%), - radial-gradient(circle at top right, rgb(244 106 31 / 16%), transparent 30%), - var(--canvas); + background: var(--night); } -.shell { - min-height: 100vh; +/* FocusOnNavigate moves focus to the page heading for screen readers; the heading is not interactive, so hide the ring */ +h1:focus { + outline: none; } -.page { - width: min(1120px, calc(100% - 32px)); - margin: 0 auto; - padding: 32px 0 56px; +code { + font-family: "Cascadia Code", "SF Mono", Menlo, Consolas, monospace; + font-size: 0.9em; } -.hero-panel { - display: grid; - grid-template-columns: 1fr auto; - gap: 28px; - align-items: end; - margin-bottom: 22px; - padding: 30px; - border: 1px solid rgb(36 35 31 / 10%); - border-radius: 8px; - background: linear-gradient(135deg, var(--panel-strong), #15463e 58%, #6f3b17); - box-shadow: var(--shadow); - color: #f8fffc; -} - -.hero-copy { - max-width: 680px; +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; } -.eyebrow { - margin: 0 0 6px; - font-size: 0.78rem; - font-weight: 600; - letter-spacing: 0.12em; - text-transform: uppercase; - color: rgb(248 255 252 / 72%); -} +/* The app and the guide share the screen */ -.hero-panel h1 { - margin: 0 0 10px; - font-size: clamp(1.8rem, 3vw, 2.5rem); - line-height: 1.1; +.workspace { + display: flex; + min-height: 100vh; } -.hero-summary { - margin: 0; - color: rgb(248 255 252 / 80%); +.app-pane { + flex: 1 1 auto; + min-width: 0; } -.hero-actions { - display: grid; - gap: 10px; - justify-items: end; -} +/* Buttons */ button.action { + display: inline-flex; + align-items: center; + justify-content: center; padding: 12px 22px; border: 0; - border-radius: 6px; - background: var(--accent); + border-radius: 999px; + background: var(--coral); color: #fff; font: inherit; - font-weight: 600; + font-weight: 700; cursor: pointer; } button.action:hover:not(:disabled) { - background: #d4560f; + background: var(--coral-dark); } button.action:disabled { - background: var(--muted); - cursor: progress; + background: #8b8579; + cursor: not-allowed; } button.action.secondary { - background: rgb(248 255 252 / 14%); - border: 1px solid rgb(248 255 252 / 30%); + border: 1px solid rgb(255 255 255 / 28%); + background: transparent; + color: inherit; } button.action.secondary:hover:not(:disabled) { - background: rgb(248 255 252 / 24%); + background: rgb(255 255 255 / 10%); +} + +button.action.small { + padding: 7px 14px; + font-size: 0.88rem; +} + +.spinner { + display: inline-block; + width: 14px; + height: 14px; + margin-right: 8px; + border: 2px solid rgb(255 255 255 / 35%); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.banner .spinner { + border-color: rgb(111 102 90 / 30%); + border-top-color: var(--warn); + vertical-align: -2px; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* The storefront */ + +.store { + width: min(1040px, calc(100% - 48px)); + margin: 0 auto; + padding: 20px 0 56px; +} + +.store-bar { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 8px 20px; + align-items: center; + padding: 6px 0 18px; + border-bottom: 1px solid var(--night-line); +} + +.store-logo { + font-size: 1.15rem; + font-weight: 800; + letter-spacing: -0.01em; +} + +.store-logo-mark { + color: var(--coral); +} + +.store-event { + padding: 4px 12px; + border: 1px solid rgb(255 194 51 / 45%); + border-radius: 999px; + color: var(--gold); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.store-hero { + position: relative; + margin: 28px 0 22px; + padding: 38px 36px; + overflow: hidden; + border-radius: 18px; + background: + radial-gradient(circle at 92% 12%, rgb(255 90 54 / 55%), transparent 38%), + radial-gradient(circle at 70% 120%, rgb(255 194 51 / 30%), transparent 45%), + linear-gradient(135deg, #1b2a44, #101a2b); + box-shadow: 0 30px 70px rgb(0 0 0 / 35%); +} + +.sale-tag { + display: inline-block; + margin: 0 0 14px; + padding: 4px 10px; + border-radius: 4px; + background: var(--gold); + color: #2a1d00; + font-size: 0.75rem; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.store-hero h1 { + max-width: 640px; + margin: 0 0 12px; + font-size: clamp(2rem, 4vw, 3.2rem); + font-weight: 800; + line-height: 1.02; + letter-spacing: -0.03em; } -.panel { - margin-bottom: 16px; +.store-summary { + max-width: 560px; + margin: 0 0 24px; + color: rgb(244 241 234 / 80%); + font-size: 1.05rem; + line-height: 1.5; +} + +.store-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.store-panel { + margin-bottom: 18px; padding: 22px 24px; - border: 1px solid var(--line); - border-radius: 8px; - background: var(--panel); - box-shadow: 0 10px 26px rgb(28 45 42 / 8%); + border: 1px solid var(--night-line); + border-radius: 14px; + background: var(--night-raised); } -.panel h2 { +.store-panel h2 { margin: 0 0 4px; font-size: 1.15rem; } -.panel-hint { +.store-hint { margin: 0 0 16px; - color: var(--muted); - font-size: 0.92rem; + color: var(--night-muted); + font-size: 0.93rem; + line-height: 1.5; } .result-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; } .result-card { display: grid; gap: 4px; - padding: 14px 16px; - border: 1px solid var(--line); - border-left: 4px solid var(--muted); - border-radius: 6px; - background: #fbfdfc; + padding: 16px 18px; + border: 1px solid var(--night-line); + border-top: 4px solid var(--night-muted); + border-radius: 10px; + background: var(--night-card); } .result-card.ok { - border-left-color: var(--good); + border-top-color: var(--mint); } .result-card.bad { - border-left-color: var(--bad); -} - -.result-card.pending { - border-left-color: var(--warn); + border-top-color: var(--red); } .result-label { - font-size: 0.78rem; - font-weight: 600; - letter-spacing: 0.06em; + color: var(--night-muted); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; text-transform: uppercase; - color: var(--muted); } .result-value { - font-size: 1.5rem; - font-weight: 700; + font-size: 1.7rem; + font-weight: 800; font-variant-numeric: tabular-nums; } .result-note { + color: var(--night-muted); font-size: 0.85rem; - color: var(--muted); } .result-card.ok .result-note { - color: var(--good); + color: var(--mint); } .result-card.bad .result-note { - color: var(--bad); -} - -.timing { - font-variant-numeric: tabular-nums; - font-weight: 600; + color: var(--red); } .log { max-height: 260px; - overflow-y: auto; margin: 0; padding: 14px 16px; - border: 1px solid var(--line); - border-radius: 6px; - background: #1e2227; + overflow-y: auto; + border: 1px solid var(--night-line); + border-radius: 8px; + background: #070c15; color: #d7dde6; - font-family: "Menlo", "Consolas", monospace; + font-family: "Cascadia Code", "SF Mono", Menlo, Consolas, monospace; font-size: 0.82rem; line-height: 1.6; white-space: pre-wrap; } -.status-tile { +/* The workshop guide: a launch checklist clipped to the side of the war room */ + +.guide { + position: sticky; + top: 0; + flex: 0 0 var(--guide-width); + height: 100vh; + overflow-y: auto; + border-top: 6px solid var(--coral); + background: var(--paper); + box-shadow: -24px 0 60px rgb(0 0 0 / 35%); + color: var(--ink); +} + +.guide.collapsed { + flex-basis: 76px; + display: grid; + align-content: start; + justify-items: center; + gap: 14px; + padding: 14px 0; +} + +.guide-header { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 20px 22px 12px; +} + +.guide-eyebrow { + margin: 0 0 2px; + color: var(--coral-dark); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.guide-title { + margin: 0; + font-size: 1.5rem; + font-weight: 800; + letter-spacing: -0.02em; +} + +.guide-subtitle { + margin: 4px 0 0; + color: var(--muted); + font-size: 0.9rem; +} + +.guide-toggle { + flex: none; + align-self: start; + display: grid; + justify-items: center; + gap: 8px; + min-width: 36px; + padding: 6px 8px; + border: 1px solid var(--paper-line); + border-radius: 8px; + background: var(--paper-raised); + color: var(--ink); + font: inherit; + font-size: 1.1rem; + font-weight: 700; + cursor: pointer; +} + +.guide-toggle:hover { + border-color: var(--coral); + color: var(--coral-dark); +} + +.guide-rail-label { + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; + writing-mode: vertical-rl; +} + +.guide-rail { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.guide-rail-step { + display: grid; + justify-items: center; + gap: 2px; + width: 52px; + padding: 6px 0; + border: 1px solid var(--paper-line); + border-radius: 10px; + background: var(--paper-raised); + color: var(--ink); + font: inherit; + cursor: pointer; +} + +.guide-rail-number { + font-weight: 800; +} + +.guide-progress { + display: grid; + gap: 10px; + padding: 0 22px 16px; + border-bottom: 1px dashed var(--paper-line); +} + +.progress-meter { + display: flex; + gap: 6px; +} + +.progress-segment { + flex: 1; + height: 8px; + border-radius: 999px; + background: var(--paper-line); +} + +.progress-segment.done { + background: var(--good); +} + +.guide-progress-row { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.guide-progress-count { + margin: 0; + color: var(--muted); +} + +.guide-progress-count strong { + color: var(--ink); + font-size: 1.1rem; +} + +.guide-tabs { display: grid; gap: 4px; - justify-items: end; - padding: 14px 18px; - border: 1px solid rgb(248 255 252 / 22%); + padding: 12px 14px; + border-bottom: 1px dashed var(--paper-line); +} + +.guide-tab { + display: grid; + grid-template-columns: 28px 1fr auto; + gap: 10px; + align-items: center; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: 10px; + background: transparent; + color: var(--ink); + font: inherit; + font-size: 0.93rem; + text-align: left; + cursor: pointer; +} + +.guide-tab:hover { + background: var(--paper-raised); +} + +.guide-tab.selected { + border-color: var(--paper-line); + background: var(--paper-raised); + box-shadow: 0 4px 14px rgb(64 44 16 / 8%); + font-weight: 700; +} + +.guide-tab-number { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border: 2px solid var(--ink); + border-radius: 50%; + font-size: 0.85rem; + font-weight: 800; +} + +.guide-tab.selected .guide-tab-number { + border-color: var(--coral); + background: var(--coral); + color: #fff; +} + +.guide-body { + display: grid; + gap: 14px; + padding: 16px 22px 40px; +} + +.guide-section { + padding: 16px 18px; + border: 1px solid var(--paper-line); + border-radius: 12px; + background: var(--paper-raised); +} + +.guide-section h3, +.guide-section h4 { + margin: 0 0 8px; + font-size: 1rem; +} + +.guide-section p { + margin: 0 0 10px; + line-height: 1.55; +} + +.guide-section p:last-child { + margin-bottom: 0; +} + +.guide-note { + color: var(--muted); + font-size: 0.88rem; +} + +.step-header { + display: grid; + gap: 10px; +} + +.step-eyebrow { + margin: 0; + color: var(--coral-dark); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.step-title { + margin: 2px 0 4px; + font-size: 1.35rem; + font-weight: 800; + letter-spacing: -0.01em; +} + +.step-members { + color: var(--muted); + overflow-wrap: anywhere; +} + +.step-actions { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.step-buttons { + display: flex; + gap: 8px; +} + +.step-buttons button.action.secondary { + border-color: var(--paper-line); + color: var(--ink); +} + +.how-it-works, +.task-list { + margin: 0 0 12px; + padding-left: 20px; + line-height: 1.55; +} + +.how-it-works li + li, +.task-list li + li { + margin-top: 6px; +} + +.how-it-works { + margin-bottom: 0; +} + +.facts { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 12px; + margin: 0 0 12px; + font-size: 0.92rem; +} + +.facts dt { + color: var(--muted); + font-weight: 700; +} + +.facts dd { + margin: 0; + overflow-wrap: anywhere; +} + +.see-it { + padding: 10px 14px; + border-left: 4px solid var(--coral); border-radius: 8px; - background: rgb(248 255 252 / 10%); + background: #fff0e8; } -.status-value { - font-size: 1.9rem; +.ai-note { + padding: 10px 14px; + border-left: 4px solid var(--gold); + border-radius: 8px; + background: #fff6dc; + color: #5e4508; +} + +.clues { + padding: 10px 14px; + border: 1px dashed var(--paper-line); + border-radius: 8px; + background: var(--paper); +} + +.clues summary, +.guide-log summary { font-weight: 700; - line-height: 1; - font-variant-numeric: tabular-nums; + cursor: pointer; } -.status-label { - font-size: 0.82rem; - color: rgb(248 255 252 / 78%); +.clues ul { + margin: 10px 0 0; + padding-left: 20px; + line-height: 1.55; } -.spinner { +.guide-log-count { + margin-left: 6px; + color: var(--muted); + font-weight: 400; + font-size: 0.85rem; +} + +/* Step results */ + +.badge { display: inline-block; - width: 14px; - height: 14px; - margin-right: 8px; - border: 2px solid rgb(98 112 109 / 35%); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; - vertical-align: -2px; + padding: 2px 10px; + border-radius: 999px; + background: #f1e8d8; + color: var(--muted); + font-size: 0.78rem; + font-weight: 700; + white-space: nowrap; } -@keyframes spin { +.badge-compact { + padding: 0; + background: transparent; + font-size: 0.9rem; +} + +.badge-passed { + background: #e0f3e9; + color: var(--good); +} + +.badge-failed { + background: #fbe3e5; + color: var(--bad); +} + +.badge-running { + background: #fff1cc; + color: #6b4f0b; +} + +.badge-compact.badge-passed, +.badge-compact.badge-failed, +.badge-compact.badge-running { + background: transparent; +} + +.banner { + display: grid; + justify-items: start; + gap: 6px; + padding: 12px 16px; + border: 1px solid var(--paper-line); + border-radius: 12px; + background: var(--paper-raised); + line-height: 1.5; +} + +.banner-passed { + border-color: rgb(15 138 82 / 30%); + background: #e0f3e9; + color: #0b6139; +} + +.banner-failed { + border-color: rgb(200 40 58 / 30%); + background: #fbe3e5; + color: #9d1b2b; +} + +.banner-running { + border-color: rgb(183 134 27 / 40%); + background: #fff1cc; + color: #6b4f0b; +} + +.banner-locked { + color: var(--muted); +} + +.banner-note { + font-size: 0.83rem; + opacity: 0.85; +} + +/* @key recreates the banner for every new result, so this animation plays again after each click */ +.banner-fresh { + animation: banner-in 0.35s ease-out; +} + +@keyframes banner-in { + from { + opacity: 0; + transform: translateY(-6px); + } + to { - transform: rotate(360deg); + opacity: 1; + transform: none; } } -.warning-banner { - margin-bottom: 16px; - padding: 14px 18px; - border: 1px solid rgb(197 157 53 / 45%); - border-left: 4px solid var(--warn); - border-radius: 6px; - background: #fdf8e9; - color: #6b551a; +.link-button { + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + font-weight: 700; + text-decoration: underline; + text-underline-offset: 3px; + cursor: pointer; +} + +.check-list { + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.check { + display: grid; + grid-template-columns: 24px 1fr; + gap: 8px; + padding: 10px 12px; + border: 1px solid var(--paper-line); + border-radius: 10px; +} + +.check-failed { + border-color: rgb(200 40 58 / 28%); + background: #fff4f5; +} + +.check-body { + min-width: 0; +} + +.guide-section .check-description { + margin: 0 0 6px; + font-weight: 600; + line-height: 1.4; +} + +.check-values { + display: grid; + grid-template-columns: max-content 1fr; + gap: 2px 10px; + margin: 0; + font-size: 0.86rem; +} + +.check-values dt { + color: var(--muted); +} + +.check-values dd { + margin: 0; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} + +.guide-section .check-hint { + margin: 8px 0 0; + padding: 8px 10px; + border-radius: 8px; + background: #fde2e5; + color: #7a1522; + font-size: 0.88rem; + line-height: 1.5; +} + +.table-scroll { + margin-top: 10px; + overflow-x: auto; +} + +.step-log { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; +} + +.step-log th, +.step-log td { + padding: 6px 8px; + border-bottom: 1px solid var(--paper-line); + text-align: left; + vertical-align: top; +} + +.step-log th { + color: var(--muted); +} + +.step-log td:nth-child(-n + 3) { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* Narrow screens stack the guide under the app */ + +@media (max-width: 1080px) { + :root { + --guide-width: 380px; + } } -@media (max-width: 640px) { - .hero-panel { - grid-template-columns: 1fr; - align-items: start; +@media (max-width: 860px) { + .workspace { + flex-direction: column; + } + + .guide, + .guide.collapsed { + position: static; + flex-basis: auto; + height: auto; + box-shadow: none; + } + + .guide.collapsed { + grid-auto-flow: column; + justify-content: start; + padding: 12px 16px; + } + + .guide.collapsed .guide-rail { + grid-auto-flow: column; + } + + .guide-rail-label { + writing-mode: horizontal-tb; + } + + .store { + width: calc(100% - 32px); } - .hero-actions, - .status-tile { - justify-items: start; + .store-hero { + padding: 28px 22px; } } diff --git a/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Components/Layout/MainLayout.razor b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Components/Layout/MainLayout.razor index 1c2a641..f745fc5 100644 --- a/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Components/Layout/MainLayout.razor +++ b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Components/Layout/MainLayout.razor @@ -1,5 +1,9 @@ @inherits LayoutComponentBase -
- @Body -
+
+
+ @Body +
+ + +
diff --git a/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Components/Layout/StepBadge.razor b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Components/Layout/StepBadge.razor new file mode 100644 index 0000000..352439e --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Components/Layout/StepBadge.razor @@ -0,0 +1,10 @@ +@inherits StepBadgeBase + +@if (IsCompact) +{ + @Label +} +else +{ + @Icon @Label +} diff --git a/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Components/Layout/StepBadge.razor.cs b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Components/Layout/StepBadge.razor.cs new file mode 100644 index 0000000..02c8bb7 --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Components/Layout/StepBadge.razor.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Components; + +namespace OrderPortal.Components.Layout; + +public partial class StepBadgeBase : ComponentBase +{ + [Parameter, EditorRequired] + public StepStatus Status { get; set; } + + [Parameter] + public bool IsUnlocked { get; set; } = true; + + // Only the icon, for the collapsed guide + [Parameter] + public bool IsCompact { get; set; } + + public string Icon => Status is StepStatus.Running ? "⏳" : !IsUnlocked ? "🔒" : Status switch + { + StepStatus.Passed => "✅", + StepStatus.Failed or StepStatus.TimedOut or StepStatus.Crashed => "❌", + _ => "•", + }; + + public string Label => Status is StepStatus.Running ? "Running" : !IsUnlocked ? "Locked" : Status switch + { + StepStatus.Passed => "Passed", + StepStatus.Failed => "Failed", + StepStatus.TimedOut => "Timed out", + StepStatus.Crashed => "Crashed", + StepStatus.Stopped => "Stopped", + _ => "Not run", + }; + + public string CssClass => Status is StepStatus.Running ? "badge-running" : !IsUnlocked ? "badge-locked" : Status switch + { + StepStatus.Passed => "badge-passed", + StepStatus.Failed or StepStatus.TimedOut or StepStatus.Crashed => "badge-failed", + _ => "badge-idle", + }; +} \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Components/Layout/WorkshopGuide.razor b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Components/Layout/WorkshopGuide.razor new file mode 100644 index 0000000..dc60639 --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Components/Layout/WorkshopGuide.razor @@ -0,0 +1,321 @@ +@inherits WorkshopGuideBase + + diff --git a/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Components/Layout/WorkshopGuide.razor.cs b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Components/Layout/WorkshopGuide.razor.cs new file mode 100644 index 0000000..75bbb85 --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Components/Layout/WorkshopGuide.razor.cs @@ -0,0 +1,179 @@ +using Microsoft.AspNetCore.Components; + +namespace OrderPortal.Components.Layout; + +// Workshop plumbing: the guide docked beside the app. It shows your challenge, every step's result, and the step you are working on. +public partial class WorkshopGuideBase : ComponentBase, IAsyncDisposable +{ + // Selecting this shows your challenge instead of a step + public const int ChallengeTab = 0; + + // A run that fails quickly can finish faster than anyone can see a spinner + static readonly TimeSpan _minimumActivityIndicatorTime = TimeSpan.FromMilliseconds(600); + + readonly CancellationTokenSource _disposeCancellationTokenSource = new(); + + Task _polling = Task.CompletedTask; + bool _isStartingCheck; + int? _startingRunStepNumber; + + [Inject] + public required StepVerifier Verifier { get; init; } + + [Inject] + public required ILogger Logger { get; init; } + + public int SelectedTab { get; private set; } = ChallengeTab; + + public bool IsCollapsed { get; private set; } + + public string? ErrorMessage { get; private set; } + + public int PassedStepCount => Verifier.Steps.Count(step => Verifier.GetState(step.Number).Status is StepStatus.Passed); + + public bool IsCheckingSteps => _isStartingCheck || Verifier.IsCheckingAllSteps; + + // The first step that does not pass yet, which is the one to work on next + public WorkshopStep? StoppedAtStep => Verifier.Steps.FirstOrDefault(step => Verifier.GetState(step.Number).Status is not StepStatus.Passed); + + // The last step the check reaches: the first one that did not pass, or the last step when they all passed + public WorkshopStep LastCheckedStep => StoppedAtStep ?? Verifier.Steps[^1]; + + public WorkshopStep? SelectedStep => Verifier.FindStep(SelectedTab); + + // The run lives in the verifier, not in this guide, so a step that is still running shows up again after a reload + public ActiveStepRun? SelectedRun => Verifier.ActiveRun is { } run && run.Step.Number == SelectedTab ? run : null; + + public bool IsSelectedStepRunning => _startingRunStepNumber == SelectedTab || SelectedRun is not null; + + public async ValueTask DisposeAsync() + { + // Only stops refreshing the guide. A step that is running keeps running. + await _disposeCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + await _polling.ConfigureAwait(false); + + _disposeCancellationTokenSource.Dispose(); + + GC.SuppressFinalize(this); + } + + protected override void OnInitialized() + { + _polling = PollVerifier(_disposeCancellationTokenSource.Token); + } + + protected void SelectTab(int tab) + { + SelectedTab = tab; + IsCollapsed = false; + ErrorMessage = null; + } + + protected void ToggleCollapsed() => IsCollapsed = !IsCollapsed; + + protected async Task CheckAllStepsAsync() + { + var token = _disposeCancellationTokenSource.Token; + string? errorMessage = null; + + ErrorMessage = null; + _isStartingCheck = true; + + // Show the activity indicator before the check starts + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + try + { + // Keep the indicator on screen long enough to notice, even when Step 1 fails quickly + await Task.WhenAll(Verifier.CheckAllSteps(), Task.Delay(_minimumActivityIndicatorTime, token)).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The guide was closed while the steps were being checked + return; + } + catch (Exception e) + { + Logger.LogError(e, "Checking every step failed"); + + errorMessage = "Checking the steps failed. The terminal running the app has the details."; + } + + // The continuation is off Blazor's renderer, so every component state change goes back through it + await InvokeAsync(() => + { + ErrorMessage = errorMessage; + _isStartingCheck = false; + + StateHasChanged(); + }).ConfigureAwait(false); + } + + protected async Task RunSelectedStepAsync() + { + if (SelectedStep is not { } step) + return; + + var token = _disposeCancellationTokenSource.Token; + string? errorMessage = null; + + ErrorMessage = null; + _startingRunStepNumber = step.Number; + + // Show the activity indicator before the run starts + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + try + { + // Keep the indicator on screen long enough to notice, even when the step fails quickly + await Task.WhenAll(Verifier.RunStep(step), Task.Delay(_minimumActivityIndicatorTime, token)).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The guide was closed while the step was running + return; + } + catch (Exception e) + { + Logger.LogError(e, "Running Step {StepNumber} failed", step.Number); + + errorMessage = "The step could not be run. The terminal running the app has the details."; + } + + // The continuation is off Blazor's renderer, so every component state change goes back through it + await InvokeAsync(() => + { + ErrorMessage = errorMessage; + _startingRunStepNumber = null; + + StateHasChanged(); + }).ConfigureAwait(false); + } + + protected void StopRun() => Verifier.StopRun(); + + // Re-renders while the verifier is busy, plus once more when it finishes, so the live log and results appear without a refresh + async Task PollVerifier(CancellationToken token) + { + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100)); + var wasBusy = true; + + try + { + while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false)) + { + var isBusy = Verifier.IsBusy; + + if (isBusy || wasBusy) + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + wasBusy = isBusy; + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // Expected when the guide is disposed + } + } +} \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Components/Pages/Checkout.razor b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Components/Pages/Checkout.razor index 5679d74..3d1d4f3 100644 --- a/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Components/Pages/Checkout.razor +++ b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Components/Pages/Checkout.razor @@ -1,17 +1,20 @@ @page "/" @inherits CheckoutPageBase -OrderPortal +OrderPortal · Checkout -
-
-
-

OrderPortal

-

Checkout load simulator

-

Concurrent checkout workers run against the same singleton services, like a burst of real traffic.

-
+
+
+ + Black Friday · Checkout war room +
-
+
+

Doors open at midnight

+

Checkout load simulator

+

@OrderCount.ToString("N0") customers check out at once against the same singleton services, like the first minute of the sale.

+ +
-
+
@if (Result is null && !IsBusy) { -
+

What to look for

-

Run the burst. Every card that turns red is a bug in a service that looked fine when one request hit it at a time. Then press Reserve stock and watch how long it takes.

-
+

Run the burst. Every card that turns red is a bug in a service that looked fine when one request hit it at a time. Then press Reserve stock and watch how long it takes.

+ } @if (Result is not null) { -
+

Checkout results

-

Every number below has a known correct value. Anything that does not match is a race condition.

+

Every number below has a known correct value. Anything that does not match is a race condition.

@@ -61,19 +64,19 @@
Elapsed - @Result.Elapsed.TotalSeconds.ToString("F2")s + @Result.Elapsed.TotalSeconds.ToString("F2")s @Result.TaxRateLookups tax lookups
-
+
} @if (StockMessage is not null) { -
+

Stock reservation

-

Reserving stock writes an audit entry while holding the ledger lock.

+

Reserving stock writes an audit entry while holding the ledger lock.

@StockMessage
-
+ } - +
diff --git a/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Components/Pages/Checkout.razor.cs b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Components/Pages/Checkout.razor.cs index 7f9e9e3..a0ef666 100644 --- a/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Components/Pages/Checkout.razor.cs +++ b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Components/Pages/Checkout.razor.cs @@ -65,7 +65,7 @@ protected async Task ReserveStockAsync() message = "Reserved 1 of SKU-1000 and wrote the audit entry."; } } - catch (OperationCanceledException) + catch (OperationCanceledException) when (timeoutCancellationTokenSource.IsCancellationRequested) { message = "Timed out waiting for the ledger lock."; } diff --git a/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Program.cs b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Program.cs index 154f0e4..3f34914 100644 --- a/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Program.cs +++ b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Program.cs @@ -12,12 +12,17 @@ public static void Main(string[] args) .AddInteractiveServerComponents(); // Registered as singletons, so one instance is shared by every - // concurrent request. That ensures their state is a shared resource. + // concurrent request. That is what makes their state a shared resource. builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + // Workshop plumbing: one StepVerifier holds every step's result. It is also a hosted service, + // so it checks your services against every step each time the app starts. + builder.Services.AddSingleton(); + builder.Services.AddHostedService(static serviceProvider => serviceProvider.GetRequiredService()); + var app = builder.Build(); if (!app.Environment.IsDevelopment()) diff --git a/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Steps/Step1CountEveryOrder.cs b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Steps/Step1CountEveryOrder.cs new file mode 100644 index 0000000..7259f8f --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Steps/Step1CountEveryOrder.cs @@ -0,0 +1,80 @@ +using OrderPortal.Components.Pages; + +namespace OrderPortal; + +public sealed class Step1CountEveryOrder : WorkshopStep +{ + const int _threadCount = 32; + const int _ordersPerThread = 25_000; + + const string _lostOrdersHint = "Orders went missing and nothing threw. _ordersPlaced++ is a read, an add, and a write, and two threads can read the same value before either one writes. " + + "Update the count atomically, so nothing can slip between the read and the write."; + + public override int Number => 1; + + public override string Scenario => "Count every order"; + + public override string Title => "OrderMetrics.RecordOrder() and OrdersPlaced"; + + public override string Story => "A sale goes live and two thousand customers press Buy in the same second. " + + "Every checkout calls RecordOrder() on the one OrderMetrics singleton, and the Orders recorded number on the revenue report has to match the orders table exactly. " + + "When it does not, nothing throws and nothing logs a warning. The count is just quietly low."; + + public override string SeeItInTheApp => "On the Checkout page, press Run 2000 checkouts. The Orders recorded card expects 2000 and shows something slightly lower, such as 1996. " + + "Those orders were placed. The counter lost them. Press it again and the number changes, which is why a single passing run proves nothing."; + + public override string FileToChange => "Services/OrderMetrics.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Update the order count in RecordOrder() atomically, so no increment is ever lost.", + "Make the OrdersPlaced getter safe too, so it cannot hand the page a stale count.", + "Keep Reset() correct. It runs at the start of every burst, against the same shared count.", + "Leave CheckoutService, the pages, and the singleton registrations in Program.cs alone. The load is not the bug.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "_ordersPlaced++ is not one operation. It is a read, an add, and a write.", + "Reads can be wrong as well as writes. An int read can be stale.", + "The cheapest tool in this section's toolbox is lock free, so no thread ever waits.", + ]; + + public override string TimeoutHint => "The checkout burst never finished. Check that nothing in OrderMetrics can block or wait on itself."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + // Part 1: the same burst the Checkout page runs, through fresh copies of the services Program.cs registers as singletons + var metrics = new OrderMetrics(); + using var ledger = new InventoryLedger(); + using var checkout = new CheckoutService(metrics, new TaxRateProvider(), ledger); + + report.Log($"Running {CheckoutPageBase.OrderCount:N0} checkouts through Parallel.ForEachAsync, which runs one checkout per core ({Environment.ProcessorCount} on this machine)"); + var result = await checkout.RunCheckoutBurstAsync(CheckoutPageBase.OrderCount, token).ConfigureAwait(false); + report.Log($"The burst finished in {result.Elapsed.TotalSeconds:F2}s, and OrdersPlaced reads {result.OrdersPlaced:N0}"); + + report.Expect("The checkout burst records every order", CheckoutPageBase.OrderCount, result.OrdersPlaced, _lostOrdersHint); + + // Part 2: one burst can get lucky. 32 threads calling RecordOrder() as fast as they can almost never do. + var stressMetrics = new OrderMetrics(); + report.Log($"Recording {_threadCount * _ordersPerThread:N0} orders from {_threadCount} threads at the same time"); + + Parallel.For(0, _threadCount, new ParallelOptions { MaxDegreeOfParallelism = _threadCount, CancellationToken = token }, _ => + { + for (var order = 0; order < _ordersPerThread; order++) + { + stressMetrics.RecordOrder(1m); + } + }); + + report.Log($"OrdersPlaced reads {stressMetrics.OrdersPlaced:N0}"); + report.Expect($"{_threadCount} threads recording {_ordersPerThread:N0} orders each lose none of them", _threadCount * _ordersPerThread, stressMetrics.OrdersPlaced, _lostOrdersHint); + + // Part 3: Reset() runs at the start of every burst, so it touches the same shared count + stressMetrics.Reset(); + report.Expect("Reset() sets OrdersPlaced back to 0", 0, stressMetrics.OrdersPlaced, "Reset() runs at the start of every burst. It has to clear the same shared count that RecordOrder() updates."); + } +} \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Steps/Step2AddUpRevenue.cs b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Steps/Step2AddUpRevenue.cs new file mode 100644 index 0000000..fb0ffdd --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Steps/Step2AddUpRevenue.cs @@ -0,0 +1,92 @@ +using OrderPortal.Components.Pages; + +namespace OrderPortal; + +public sealed class Step2AddUpRevenue : WorkshopStep +{ + const int _threadCount = 32; + const int _ordersPerThread = 25_000; + const decimal _orderTotal = 1.25m; + + const string _lostRevenueHint = "Revenue went missing and nothing threw. decimal += is a much wider read, modify, write than int++, and Interlocked has no overload for decimal. " + + "Guard every write to the revenue, and every read of it, with the same lock."; + + public override int Number => 2; + + public override string Scenario => "Add up the revenue"; + + public override string Title => "OrderMetrics.RecordOrder(), Revenue and Reset()"; + + public override string Story => "At the end of the sale, finance reconciles the Revenue number against the payment processor. " + + "It is short, and by far more than the missing orders are worth. A 16 byte decimal update is a much wider window for two threads to collide in than an int, " + + "and the shortfall is different on every run, so a single run that happens to add up proves nothing."; + + public override string SeeItInTheApp => "On the Checkout page, the Revenue card is short too, and by considerably more than the missing orders are worth, often several times more. " + + "The two shortfalls never line up, no matter how carefully you divide."; + + public override string FileToChange => "Services/OrderMetrics.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Guard the revenue total. Interlocked cannot help you here, so pick the right lock for a synchronous update.", + "Read Revenue under the same lock, so the page can never read a decimal that is halfway through an update.", + "Keep Reset() correct. It clears the revenue at the start of every burst, so it needs the same lock.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "Interlocked has overloads for int and long. It has none for decimal.", + "A decimal is 16 bytes. A read can catch one halfway through an update.", + ".NET 9 added a dedicated Lock type, and the lock statement understands it.", + ]; + + public override string TimeoutHint => "The checkout burst never finished. Check that every lock in OrderMetrics is released, and that no method takes a lock it already holds."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + // Part 1: every order in the burst has a known total, so the revenue has exactly one correct value + var metrics = new OrderMetrics(); + using var ledger = new InventoryLedger(); + using var checkout = new CheckoutService(metrics, new TaxRateProvider(), ledger); + + report.Log($"Running {CheckoutPageBase.OrderCount:N0} checkouts through Parallel.ForEachAsync"); + var result = await checkout.RunCheckoutBurstAsync(CheckoutPageBase.OrderCount, token).ConfigureAwait(false); + report.Log($"The burst finished in {result.Elapsed.TotalSeconds:F2}s, and Revenue reads {result.Revenue:C2}"); + + report.Expect( + "The checkout burst adds up to the expected revenue", + CheckoutPageBase.ExpectedRevenue.ToString("C2"), + result.Revenue.ToString("C2"), + result.Revenue == CheckoutPageBase.ExpectedRevenue, + _lostRevenueHint); + + // Part 2: 32 threads adding the same order total as fast as they can + var stressMetrics = new OrderMetrics(); + var expectedRevenue = _threadCount * _ordersPerThread * _orderTotal; + report.Log($"Recording {_threadCount * _ordersPerThread:N0} orders of {_orderTotal:C2} from {_threadCount} threads at the same time"); + + Parallel.For(0, _threadCount, new ParallelOptions { MaxDegreeOfParallelism = _threadCount, CancellationToken = token }, _ => + { + for (var order = 0; order < _ordersPerThread; order++) + { + stressMetrics.RecordOrder(_orderTotal); + } + }); + + var stressRevenue = stressMetrics.Revenue; + report.Log($"Revenue reads {stressRevenue:C2}"); + report.Expect( + $"{_threadCount} threads adding {_ordersPerThread:N0} orders each lose none of the revenue", + expectedRevenue.ToString("C2"), + stressRevenue.ToString("C2"), + stressRevenue == expectedRevenue, + _lostRevenueHint); + + // Part 3: Reset() runs at the start of every burst, so it touches the same shared total + stressMetrics.Reset(); + report.Expect("Reset() sets Revenue back to 0", 0m.ToString("C2"), stressMetrics.Revenue.ToString("C2"), stressMetrics.Revenue is 0, "Reset() runs at the start of every burst. It has to clear the revenue under the same lock that RecordOrder() uses to update it."); + } +} \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Steps/Step3BuildTaxTableOnce.cs b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Steps/Step3BuildTaxTableOnce.cs new file mode 100644 index 0000000..121d224 --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Steps/Step3BuildTaxTableOnce.cs @@ -0,0 +1,133 @@ +using OrderPortal.Components.Pages; + +namespace OrderPortal; + +public sealed class Step3BuildTaxTableOnce : WorkshopStep +{ + const int _threadCount = 32; + const int _lookupsPerThread = 10_000; + const string _region = "US-CA"; + const decimal _expectedRate = 0.0925m; + + const string _buildsHint = "_rates ??= BuildRates() is a null check followed by an assignment, with a 120 millisecond gap in between. Every thread that asks for a rate during that gap finds the cache empty and builds its own table. " + + "Build the table exactly once, no matter how many threads call GetRate() at the same moment."; + + const string _lookupsHint = "Lookups went missing and nothing threw. _lookups++ is a read, an add, and a write, the same bug you fixed in OrderMetrics."; + + const string _resetHint = "Reset() runs at the start of every burst. After it, the provider has to build the table again on the next GetRate(), and still build it exactly once."; + + public override int Number => 3; + + public override string Scenario => "Build the tax table once"; + + public override string Title => "TaxRateProvider.GetRate(), Lookups, Builds and Reset()"; + + public override string Story => "Building the tax table takes 120 milliseconds, so TaxRateProvider builds it once and caches it for the life of the process. At least, that is the intent. " + + "When the first burst of a sale arrives, every checkout that asks for a rate during those 120 milliseconds finds the cache empty and pays for a table of its own. " + + "The number of tables you build turns out to be your core count."; + + public override string SeeItInTheApp => "On the Checkout page, the Tax table builds card expects 1 and shows one build per CPU core, so 16 on a 16 core machine and 8 on an 8 core laptop. " + + "The note under Elapsed counts tax rate lookups, and that counter is a plain ++ as well."; + + public override string FileToChange => "Services/TaxRateProvider.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Build the expensive tax table exactly once, no matter how many threads call GetRate() at the same moment.", + "Count tax rate lookups atomically, and read Lookups and Builds so the page never sees a stale value.", + "Keep Reset() working. After a reset, the next burst must build the table again, exactly once.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "_rates ??= BuildRates() is a null check followed by an assignment, with a gap in between.", + "One of the types in this section's toolbox exists to run a factory exactly once, however many threads ask for its value at the same time.", + "Reset() does not have to undo a table that was already built. It only has to leave the provider ready to build a fresh one.", + ]; + + public override string TimeoutHint => "GetRate() never returned. Check that the first caller builds the table and every other caller can read it once it is built."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + // Part 1: 32 threads wait at a Barrier, then all ask for a rate at the same instant, the way the first checkouts of a sale do + var taxRates = new TaxRateProvider(); + report.Log($"{_threadCount} threads are asking for the {_region} tax rate at the same instant"); + + var rates = AskForRatesAtTheSameTime(taxRates); + report.Log($"The table was built {taxRates.Builds} times"); + + report.Expect($"{_threadCount} threads asking for a rate at the same instant build the table once", 1, taxRates.Builds, _buildsHint); + + var correctRates = rates.Count(static rate => rate is _expectedRate); + report.Expect($"Every thread gets the {_region} rate of {_expectedRate}", $"{_threadCount} of {_threadCount}", $"{correctRates} of {_threadCount}", correctRates is _threadCount, "Every caller has to read the same table. Check what GetRate() returns while the table is still being built."); + + // Part 2: the table is built, and now every checkout looks up a rate as fast as it can + report.Log($"{_threadCount} threads are each looking up {_lookupsPerThread:N0} rates"); + + Parallel.For(0, _threadCount, new ParallelOptions { MaxDegreeOfParallelism = _threadCount, CancellationToken = token }, _ => + { + for (var lookup = 0; lookup < _lookupsPerThread; lookup++) + { + taxRates.GetRate(_region); + } + }); + + var expectedLookups = _threadCount + (_threadCount * _lookupsPerThread); + report.Log($"Lookups reads {taxRates.Lookups:N0}"); + report.Expect("Every lookup is counted", expectedLookups, taxRates.Lookups, _lookupsHint); + + // Part 3: after Reset() the next burst must build the table again, exactly once + taxRates.Reset(); + report.Expect("Reset() sets Builds and Lookups back to 0", "0 builds, 0 lookups", $"{taxRates.Builds} builds, {taxRates.Lookups} lookups", taxRates.Builds is 0 && taxRates.Lookups is 0, _resetHint); + + AskForRatesAtTheSameTime(taxRates); + report.Log($"After Reset(), the table was built {taxRates.Builds} times"); + report.Expect($"After Reset(), {_threadCount} threads asking at the same instant build the table once again", 1, taxRates.Builds, _resetHint); + + // Part 4: the burst the Checkout page runs resets the provider first, then shows the same number on the Tax table builds card + var metrics = new OrderMetrics(); + using var ledger = new InventoryLedger(); + using var checkout = new CheckoutService(metrics, new TaxRateProvider(), ledger); + + report.Log($"Running {CheckoutPageBase.OrderCount:N0} checkouts through Parallel.ForEachAsync"); + var result = await checkout.RunCheckoutBurstAsync(CheckoutPageBase.OrderCount, token).ConfigureAwait(false); + + report.Expect("The checkout burst builds the tax table once", 1, result.TaxRateBuilds, _buildsHint); + } + + static decimal[] AskForRatesAtTheSameTime(TaxRateProvider taxRates) + { + var rates = new decimal[_threadCount]; + var exceptions = new Exception?[_threadCount]; + + using var barrier = new Barrier(_threadCount); + + var threads = Enumerable.Range(0, _threadCount) + .Select(index => new Thread(() => + { + barrier.SignalAndWait(); + + // An exception on a dedicated thread would end the whole app, so keep it and rethrow it from this method instead + try + { + rates[index] = taxRates.GetRate(_region); + } + catch (Exception e) + { + exceptions[index] = e; + } + })) + .ToList(); + + threads.ForEach(static thread => thread.Start()); + threads.ForEach(static thread => thread.Join()); + + if (exceptions.FirstOrDefault(static exception => exception is not null) is { } firstException) + throw new InvalidOperationException("TaxRateProvider.GetRate() threw", firstException); + + return rates; + } +} \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Steps/Step4ReserveStock.cs b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Steps/Step4ReserveStock.cs new file mode 100644 index 0000000..3639d4c --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Steps/Step4ReserveStock.cs @@ -0,0 +1,131 @@ +namespace OrderPortal; + +public sealed class Step4ReserveStock : WorkshopStep +{ + const string _sku = "SKU-1000"; + const int _stockOnHand = 5_000; + const int _reservationCount = 200; + + const string _deadlockHint = "No thread is blocked and nothing threw, because WaitAsync is awaited. The ledger's only permit is simply never released. " + + "SemaphoreSlim is not reentrant: ask which method takes the permit, and which method it calls next. Keep the stock update and its audit entry under one lock, without taking that lock twice."; + + const string _skippedHint = "The ledger is still holding its only permit, so every later call on it would wait too. Fix the deadlock first."; + + // A real request is eventually abandoned by its client. These calls give up after 2 seconds, so a deadlock ends this step instead of hanging it. + static readonly TimeSpan _ledgerTimeout = TimeSpan.FromSeconds(2); + + public override int Number => 4; + + public override string Scenario => "Reserve stock without waiting on yourself"; + + public override string Title => "InventoryLedger.ReserveStockAsync() and WriteAuditEntryAsync()"; + + public override string Story => "Every reservation has to update the stock and write its audit entry under one lock, so the ledger can never show stock leaving the warehouse without a record of why. " + + "InventoryLedger uses SemaphoreSlim, which is the right lock for code that awaits. Reserve stock still never answers. " + + "Nothing is pegged, nothing throws, and a thread dump shows nothing waiting. The request just never completes, and every later reservation queues up behind it."; + + public override string SeeItInTheApp => "On the Checkout page, press Reserve stock. The panel says Reserving stock... and then sits there. " + + "Five seconds later it gives up with a timeout message. Nothing crashed. The request is simply stuck."; + + public override string FileToChange => "Services/InventoryLedger.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Remove the deadlock in ReserveStockAsync() without losing the guarantee that the stock update and its audit entry happen under the same lock.", + "Keep WriteAuditEntryAsync() callable on its own, by a caller that does not already hold the ledger lock.", + "Guard the audit trail list with a lock, so AuditEntries never reads it while an entry is being written.", + "Keep SemaphoreSlim in InventoryLedger. lock cannot be held across an await, and the ledger awaits.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "InventoryLedger uses SemaphoreSlim rather than lock, and that part is correct.", + "SemaphoreSlim is not reentrant. Ask which method takes the permit, and which method it calls next.", + "A common pattern: a public method that takes the lock, and a private method that assumes the lock is already held.", + "List is not thread safe, and a lock is all this one needs.", + ]; + + public override string TimeoutHint => "A ledger call ignored its 2 second timeout. Check that every wait on the ledger's semaphore passes the CancellationToken it was given."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + using var ledger = new InventoryLedger(); + + // Part 1: one reservation, the same call the Reserve stock button makes + report.Log($"Reserving 1 of {_sku}"); + + var (answered, reserved) = await WithinLedgerTimeout(timeoutToken => ledger.ReserveStockAsync(_sku, 1, timeoutToken), token).ConfigureAwait(false); + + if (!answered) + { + report.Log("Still waiting for the ledger lock after 2 seconds. Giving up, the way a client would"); + report.Expect("Reserving 1 unit answers within 2 seconds", "True, within 2 seconds", "no answer after 2 seconds", false, _deadlockHint); + report.Expect("WriteAuditEntryAsync() works on its own", "completed", "Skipped: the ledger is deadlocked", false, _skippedHint); + report.Expect($"{_reservationCount} reservations at the same time all succeed", $"{_reservationCount} of {_reservationCount}", "Skipped: the ledger is deadlocked", false, _skippedHint); + return; + } + + report.Log($"ReserveStockAsync() answered {reserved}"); + report.Expect("Reserving 1 unit answers within 2 seconds", "True, within 2 seconds", $"{reserved}, within 2 seconds", reserved, _deadlockHint); + report.Expect("The reservation wrote its audit entry", 1, ledger.AuditEntries, "Every reservation has to write exactly one audit entry, inside the same lock as the stock update."); + + // Part 2: a caller that does not hold the ledger lock can still write an audit entry on its own + report.Log("Writing a cycle count adjustment to the audit trail on its own"); + + var (wroteEntry, _) = await WithinLedgerTimeout(async timeoutToken => + { + await ledger.WriteAuditEntryAsync("Cycle count adjustment", timeoutToken).ConfigureAwait(false); + return true; + }, token).ConfigureAwait(false); + + report.Expect( + "WriteAuditEntryAsync() works on its own, for a caller that does not already hold the ledger lock", + "completed, 2 audit entries", + wroteEntry ? $"completed, {ledger.AuditEntries} audit entries" : "no answer after 2 seconds", + wroteEntry && ledger.AuditEntries is 2, + "WriteAuditEntryAsync() still has to take the ledger lock itself, because its caller does not hold it."); + + // Part 3: a burst of reservations at the same time. The semaphore lets them in one at a time. + report.Log($"Placing {_reservationCount} reservations for {_sku} at the same time"); + + var (reservationsAnswered, reservations) = await WithinLedgerTimeout( + timeoutToken => Task.WhenAll(Enumerable.Range(0, _reservationCount).Select(_ => ledger.ReserveStockAsync(_sku, 1, timeoutToken))), + token).ConfigureAwait(false); + + var succeeded = reservations?.Count(static reservation => reservation) ?? 0; + report.Log($"{succeeded} reservations succeeded, and the audit trail has {ledger.AuditEntries} entries"); + + report.Expect($"{_reservationCount} reservations at the same time all succeed", $"{_reservationCount} of {_reservationCount}", reservationsAnswered ? $"{succeeded} of {_reservationCount}" : "no answer after 2 seconds", succeeded is _reservationCount, _deadlockHint); + report.Expect("Each of them wrote one audit entry", 2 + _reservationCount, ledger.AuditEntries, "Every reservation has to write exactly one audit entry, inside the same lock as the stock update."); + + // Part 4: 201 units have been reserved, so there is no longer enough stock for the whole shelf + var reservedSoFar = 1 + _reservationCount; + var (tooManyAnswered, tooMany) = await WithinLedgerTimeout(timeoutToken => ledger.ReserveStockAsync(_sku, _stockOnHand, timeoutToken), token).ConfigureAwait(false); + + report.Expect( + $"Reserving all {_stockOnHand:N0} again is refused, because {reservedSoFar} are already reserved", + "False", + tooManyAnswered ? tooMany.ToString() : "no answer after 2 seconds", + tooManyAnswered && !tooMany, + "Every successful reservation has to reduce the stock on hand."); + } + + // Answered is false when the call is still waiting after 2 seconds + static async Task<(bool Answered, T? Value)> WithinLedgerTimeout(Func> call, CancellationToken token) + { + using var timeoutCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + timeoutCancellationTokenSource.CancelAfter(_ledgerTimeout); + + try + { + return (true, await call(timeoutCancellationTokenSource.Token).ConfigureAwait(false)); + } + catch (OperationCanceledException) when (timeoutCancellationTokenSource.IsCancellationRequested && !token.IsCancellationRequested) + { + return (false, default); + } + } +} \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Steps/StepContext.cs b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Steps/StepContext.cs new file mode 100644 index 0000000..300f9b0 --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Steps/StepContext.cs @@ -0,0 +1,4 @@ +namespace OrderPortal; + +// Token is cancelled when the run is stopped, times out, or the app shuts down +public sealed record StepContext(StepReport Report, CancellationToken Token); \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Steps/WorkshopStep.cs b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Steps/WorkshopStep.cs new file mode 100644 index 0000000..09ef469 --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Steps/WorkshopStep.cs @@ -0,0 +1,37 @@ +namespace OrderPortal; + +// Every step is one bug in OrderPortal's shared services. +// Its tab in the workshop guide tells you what to fix, and Run() drives your code the way a sale does, logs what happens, +// and records an expected result, with a hint, for everything that should be true once the bug is fixed. +public abstract class WorkshopStep +{ + public abstract int Number { get; } + + // What this step fixes, in a few words + public abstract string Scenario { get; } + + // The members this step exercises + public abstract string Title { get; } + + // What goes wrong in production, and why nobody noticed with one customer at a time + public abstract string Story { get; } + + // How to watch this bug happen on the Checkout page + public abstract string SeeItInTheApp { get; } + + // Where the bug lives + public abstract string FileToChange { get; } + + // What this step asks you to change. Some of it cannot be measured from outside, so not every task has an expected result. + public abstract IReadOnlyList Tasks { get; } + + // Shown only when you ask for them + public abstract IReadOnlyList Clues { get; } + + // What is most likely waiting forever when this step does not finish in time + public abstract string TimeoutHint { get; } + + public string SourceFile => $"Steps/{GetType().Name}.cs"; + + public abstract Task Run(StepContext context); +} \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Verification/ActiveStepRun.cs b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Verification/ActiveStepRun.cs new file mode 100644 index 0000000..4c5c92d --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Verification/ActiveStepRun.cs @@ -0,0 +1,3 @@ +namespace OrderPortal; + +public sealed record ActiveStepRun(WorkshopStep Step, StepReport Report); \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Verification/StepCheck.cs b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Verification/StepCheck.cs new file mode 100644 index 0000000..43ff1cb --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Verification/StepCheck.cs @@ -0,0 +1,3 @@ +namespace OrderPortal; + +public sealed record StepCheck(string Description, string Expected, string Actual, bool Passed, string Hint); \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Verification/StepLogEntry.cs b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Verification/StepLogEntry.cs new file mode 100644 index 0000000..44f9b01 --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Verification/StepLogEntry.cs @@ -0,0 +1,3 @@ +namespace OrderPortal; + +public sealed record StepLogEntry(TimeSpan Elapsed, int ThreadId, bool IsThreadPoolThread, string Message); \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Verification/StepReport.cs b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Verification/StepReport.cs new file mode 100644 index 0000000..2bd7540 --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Verification/StepReport.cs @@ -0,0 +1,82 @@ +using System.Diagnostics; + +namespace OrderPortal; + +// Collects what a step logged and every expected result it checked. +// Steps write to it from thread pool threads while the workshop guide reads it, so every member takes the lock. +public sealed class StepReport +{ + readonly Lock _lock = new(); + readonly Stopwatch _stopwatch = Stopwatch.StartNew(); + readonly List _entries = []; + readonly List _checks = []; + + bool _isComplete; + + public IReadOnlyList Entries + { + get + { + lock (_lock) + { + return [.. _entries]; + } + } + } + + public IReadOnlyList Checks + { + get + { + lock (_lock) + { + return [.. _checks]; + } + } + } + + public void Log(string message) + { + var threadId = Environment.CurrentManagedThreadId; + var isThreadPoolThread = Thread.CurrentThread.IsThreadPoolThread; + + lock (_lock) + { + if (!_isComplete) + _entries.Add(new StepLogEntry(_stopwatch.Elapsed, threadId, isThreadPoolThread, message)); + } + } + + // The hint is shown under the expected result only when it does not match + public void Expect(string description, T expected, T actual, string hint) + { + Expect(description, Format(expected), Format(actual), EqualityComparer.Default.Equals(expected, actual), hint); + } + + public void Expect(string description, string expected, string actual, bool passed, string hint) + { + lock (_lock) + { + if (!_isComplete) + _checks.Add(new StepCheck(description, expected, actual, passed, hint)); + } + } + + // Called when the run ends, including a run that timed out or was stopped while its step is still blocked somewhere. + // Anything that abandoned step writes later is ignored, so the result in the workshop guide can no longer change. + public void Complete() + { + lock (_lock) + { + _isComplete = true; + _stopwatch.Stop(); + } + } + + static string Format(T value) => value switch + { + null => "null", + int or long => $"{value:N0}", + _ => value.ToString() ?? "null", + }; +} \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Verification/StepState.cs b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Verification/StepState.cs new file mode 100644 index 0000000..91fcc90 --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Verification/StepState.cs @@ -0,0 +1,9 @@ +namespace OrderPortal; + +// ExceptionType is set for Crashed. Only the name is kept: exception messages and stack traces stay in the terminal. +// CompletedAt is when the run that produced this state ended, so the workshop guide can show that a click really ran again. +public sealed record StepState( + StepStatus Status, + StepReport? Report, + string? ExceptionType = null, + DateTimeOffset? CompletedAt = null); \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Verification/StepStatus.cs b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Verification/StepStatus.cs new file mode 100644 index 0000000..7b4b74b --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Verification/StepStatus.cs @@ -0,0 +1,12 @@ +namespace OrderPortal; + +public enum StepStatus +{ + NotRun, + Running, + Passed, + Failed, + TimedOut, + Crashed, + Stopped, +} \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Verification/StepVerifier.cs b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Verification/StepVerifier.cs new file mode 100644 index 0000000..081f25a --- /dev/null +++ b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/Verification/StepVerifier.cs @@ -0,0 +1,231 @@ +namespace OrderPortal; + +// Workshop plumbing: runs the steps against your services and keeps their results. +// Every step creates its own instances of the services, so a check never disturbs the singletons the Checkout page uses. +// Only one step runs at a time, so a step that measures contention never competes with another run for thread pool threads. +public sealed class StepVerifier(ILogger logger, IHostApplicationLifetime applicationLifetime) : BackgroundService +{ + public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); + + readonly Lock _lock = new(); + readonly StepState[] _states = [.. Enumerable.Repeat(new StepState(StepStatus.NotRun, null), 4)]; + + // Both start at 1: the app checks every step on startup, and nothing else may run before that finishes + int _isBusy = 1; + int _isCheckingAllSteps = 1; + + ActiveStepRun? _activeRun; + TaskCompletionSource? _stopRequested; + + public IReadOnlyList Steps { get; } = + [ + new Step1CountEveryOrder(), + new Step2AddUpRevenue(), + new Step3BuildTaxTableOnce(), + new Step4ReserveStock(), + ]; + + public bool IsBusy => Volatile.Read(ref _isBusy) is 1; + + public bool IsCheckingAllSteps => Volatile.Read(ref _isCheckingAllSteps) is 1; + + public ActiveStepRun? ActiveRun + { + get + { + lock (_lock) + { + return _activeRun; + } + } + } + + public WorkshopStep? FindStep(int number) => Steps.FirstOrDefault(step => step.Number == number); + + public StepState GetState(int number) + { + lock (_lock) + { + return _states[number - 1]; + } + } + + public bool IsUnlocked(int number) => number is 1 || GetState(number - 1).Status is StepStatus.Passed; + + // Re-runs every step, the same way startup does + public async Task CheckAllSteps() + { + if (Interlocked.CompareExchange(ref _isBusy, 1, 0) is not 0) + return; + + Volatile.Write(ref _isCheckingAllSteps, 1); + + try + { + await CheckSteps(applicationLifetime.ApplicationStopping).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isCheckingAllSteps, 0); + Volatile.Write(ref _isBusy, 0); + } + } + + public async Task RunStep(WorkshopStep step) + { + if (!IsUnlocked(step.Number) || Interlocked.CompareExchange(ref _isBusy, 1, 0) is not 0) + return GetState(step.Number); + + try + { + return await Run(step, applicationLifetime.ApplicationStopping).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isBusy, 0); + } + } + + public void StopRun() + { + lock (_lock) + { + _stopRequested?.TrySetResult(); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await CheckSteps(stoppingToken).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isCheckingAllSteps, 0); + Volatile.Write(ref _isBusy, 0); + } + } + + async Task CheckSteps(CancellationToken token) + { + try + { + // Results from before the latest change to the services are not trusted + lock (_lock) + { + Array.Fill(_states, new StepState(StepStatus.NotRun, null)); + } + + logger.LogInformation("Checking your services against every step. Open the app in your browser: the workshop guide beside the Checkout page shows what to fix next"); + + foreach (var step in Steps) + { + var state = await Run(step, token).ConfigureAwait(false); + + if (state.Status is not StepStatus.Passed) + { + logger.LogInformation("Stopped checking at Step {StepNumber}. Open it in the app to see what to change, with a hint for every result that did not match. Later steps unlock after it passes", step.Number); + return; + } + } + + logger.LogInformation("Every step passed. Pause here for the group review"); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The app is shutting down + } + catch (Exception e) + { + logger.LogError(e, "Checking the steps failed unexpectedly"); + } + } + + async Task Run(WorkshopStep step, CancellationToken token) + { + var report = new StepReport(); + var stopRequested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var previousState = GetState(step.Number); + + lock (_lock) + { + _activeRun = new ActiveStepRun(step, report); + _stopRequested = stopRequested; + _states[step.Number - 1] = new StepState(StepStatus.Running, report); + } + + // Cancelled when the run ends for any reason, so a step that is still working can stop + using var runCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + + var state = previousState; + + try + { + state = await Execute(step, report, stopRequested.Task, runCancellationTokenSource.Token, token).ConfigureAwait(false); + } + finally + { + await runCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + report.Complete(); + + lock (_lock) + { + _activeRun = null; + _stopRequested = null; + _states[step.Number - 1] = state with { CompletedAt = DateTimeOffset.Now }; + } + } + + return state; + } + + async Task Execute(WorkshopStep step, StepReport report, Task stopRequested, CancellationToken runToken, CancellationToken token) + { + try + { + // A dedicated thread runs the step until its first await. If a deadlock blocks it forever, + // a thread pool thread is not lost for the rest of the app's life. + var stepTask = Task.Factory.StartNew(() => step.Run(new StepContext(report, runToken)), token, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); + + var finished = await Task.WhenAny(stepTask, stopRequested).WaitAsync(Timeout, token).ConfigureAwait(false); + + if (finished == stopRequested) + { + logger.LogInformation("Step {StepNumber} was stopped", step.Number); + return new StepState(StepStatus.Stopped, report); + } + + await stepTask.ConfigureAwait(false); + + var checks = report.Checks; + var passedChecks = checks.Count(static check => check.Passed); + var status = checks.Count > 0 && passedChecks == checks.Count ? StepStatus.Passed : StepStatus.Failed; + + logger.LogInformation("Step {StepNumber} {Result} ({PassedChecks}/{TotalChecks} checks)", step.Number, status is StepStatus.Passed ? "passed" : "failed", passedChecks, checks.Count); + + // The hints belong in the terminal too, next to anything the app logged while the step ran + foreach (var check in checks.Where(static check => !check.Passed)) + { + logger.LogWarning("Step {StepNumber}: {Description}. Expected {Expected}, actual {Actual}. Hint: {Hint}", step.Number, check.Description, check.Expected, check.Actual, check.Hint); + } + + return new StepState(status, report); + } + catch (TimeoutException) + { + logger.LogWarning("Step {StepNumber} did not finish within {Timeout}. {TimeoutHint}", step.Number, Timeout, step.TimeoutHint); + return new StepState(StepStatus.TimedOut, report); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + return new StepState(StepStatus.NotRun, null); + } + catch (Exception e) + { + logger.LogError(e, "Step {StepNumber} threw an unexpected exception", step.Number); + return new StepState(StepStatus.Crashed, report, e.GetType().Name); + } + } +} \ No newline at end of file diff --git a/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/wwwroot/app.css b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/wwwroot/app.css index c5a79b2..3b0ec5d 100644 --- a/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/wwwroot/app.css +++ b/5. Asynchronous vs Parallel Programming/2. Finish/OrderPortal/wwwroot/app.css @@ -1,20 +1,30 @@ +/* OrderPortal: a Black Friday checkout war room, with the workshop guide docked beside it as a launch checklist */ + :root { - --canvas: #eef3f1; - --panel: #ffffff; - --panel-strong: #112a2d; - --ink: #18211f; - --muted: #62706d; - --line: #d7e0dd; - --accent: #f46a1f; - --teal: #0f766e; + --night: #0b1320; + --night-raised: #131e31; + --night-card: #18253b; + --night-line: rgb(255 255 255 / 11%); + --night-ink: #f4f1ea; + --night-muted: #9aa6ba; + --coral: #ff5a36; + --coral-dark: #e2431f; + --gold: #ffc233; + --mint: #33d69f; + --red: #ff5c6c; + --paper: #fff9ef; + --paper-raised: #ffffff; + --paper-line: #eadfcb; + --ink: #1d1a16; + --muted: #6f665a; --good: #0f8a52; - --bad: #cf202f; - --warn: #c59d35; - --shadow: 0 18px 55px rgb(28 45 42 / 14%); + --bad: #c8283a; + --warn: #b7861b; + --guide-width: 460px; color-scheme: light; font-family: "Aptos", "Avenir Next", "Segoe UI", sans-serif; - background: var(--canvas); - color: var(--ink); + background: var(--night); + color: var(--night-ink); } * { @@ -24,248 +34,865 @@ body { margin: 0; overflow-x: clip; - background: - linear-gradient(135deg, rgb(15 118 110 / 14%), transparent 32%), - radial-gradient(circle at top right, rgb(244 106 31 / 16%), transparent 30%), - var(--canvas); + background: var(--night); } -.shell { - min-height: 100vh; +/* FocusOnNavigate moves focus to the page heading for screen readers; the heading is not interactive, so hide the ring */ +h1:focus { + outline: none; } -.page { - width: min(1120px, calc(100% - 32px)); - margin: 0 auto; - padding: 32px 0 56px; +code { + font-family: "Cascadia Code", "SF Mono", Menlo, Consolas, monospace; + font-size: 0.9em; } -.hero-panel { - display: grid; - grid-template-columns: 1fr auto; - gap: 28px; - align-items: end; - margin-bottom: 22px; - padding: 30px; - border: 1px solid rgb(36 35 31 / 10%); - border-radius: 8px; - background: linear-gradient(135deg, var(--panel-strong), #15463e 58%, #6f3b17); - box-shadow: var(--shadow); - color: #f8fffc; -} - -.hero-copy { - max-width: 680px; +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; } -.eyebrow { - margin: 0 0 6px; - font-size: 0.78rem; - font-weight: 600; - letter-spacing: 0.12em; - text-transform: uppercase; - color: rgb(248 255 252 / 72%); -} +/* The app and the guide share the screen */ -.hero-panel h1 { - margin: 0 0 10px; - font-size: clamp(1.8rem, 3vw, 2.5rem); - line-height: 1.1; +.workspace { + display: flex; + min-height: 100vh; } -.hero-summary { - margin: 0; - color: rgb(248 255 252 / 80%); +.app-pane { + flex: 1 1 auto; + min-width: 0; } -.hero-actions { - display: grid; - gap: 10px; - justify-items: end; -} +/* Buttons */ button.action { + display: inline-flex; + align-items: center; + justify-content: center; padding: 12px 22px; border: 0; - border-radius: 6px; - background: var(--accent); + border-radius: 999px; + background: var(--coral); color: #fff; font: inherit; - font-weight: 600; + font-weight: 700; cursor: pointer; } button.action:hover:not(:disabled) { - background: #d4560f; + background: var(--coral-dark); } button.action:disabled { - background: var(--muted); - cursor: progress; + background: #8b8579; + cursor: not-allowed; } button.action.secondary { - background: rgb(248 255 252 / 14%); - border: 1px solid rgb(248 255 252 / 30%); + border: 1px solid rgb(255 255 255 / 28%); + background: transparent; + color: inherit; } button.action.secondary:hover:not(:disabled) { - background: rgb(248 255 252 / 24%); + background: rgb(255 255 255 / 10%); +} + +button.action.small { + padding: 7px 14px; + font-size: 0.88rem; +} + +.spinner { + display: inline-block; + width: 14px; + height: 14px; + margin-right: 8px; + border: 2px solid rgb(255 255 255 / 35%); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.banner .spinner { + border-color: rgb(111 102 90 / 30%); + border-top-color: var(--warn); + vertical-align: -2px; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* The storefront */ + +.store { + width: min(1040px, calc(100% - 48px)); + margin: 0 auto; + padding: 20px 0 56px; +} + +.store-bar { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 8px 20px; + align-items: center; + padding: 6px 0 18px; + border-bottom: 1px solid var(--night-line); +} + +.store-logo { + font-size: 1.15rem; + font-weight: 800; + letter-spacing: -0.01em; +} + +.store-logo-mark { + color: var(--coral); +} + +.store-event { + padding: 4px 12px; + border: 1px solid rgb(255 194 51 / 45%); + border-radius: 999px; + color: var(--gold); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.store-hero { + position: relative; + margin: 28px 0 22px; + padding: 38px 36px; + overflow: hidden; + border-radius: 18px; + background: + radial-gradient(circle at 92% 12%, rgb(255 90 54 / 55%), transparent 38%), + radial-gradient(circle at 70% 120%, rgb(255 194 51 / 30%), transparent 45%), + linear-gradient(135deg, #1b2a44, #101a2b); + box-shadow: 0 30px 70px rgb(0 0 0 / 35%); +} + +.sale-tag { + display: inline-block; + margin: 0 0 14px; + padding: 4px 10px; + border-radius: 4px; + background: var(--gold); + color: #2a1d00; + font-size: 0.75rem; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.store-hero h1 { + max-width: 640px; + margin: 0 0 12px; + font-size: clamp(2rem, 4vw, 3.2rem); + font-weight: 800; + line-height: 1.02; + letter-spacing: -0.03em; } -.panel { - margin-bottom: 16px; +.store-summary { + max-width: 560px; + margin: 0 0 24px; + color: rgb(244 241 234 / 80%); + font-size: 1.05rem; + line-height: 1.5; +} + +.store-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.store-panel { + margin-bottom: 18px; padding: 22px 24px; - border: 1px solid var(--line); - border-radius: 8px; - background: var(--panel); - box-shadow: 0 10px 26px rgb(28 45 42 / 8%); + border: 1px solid var(--night-line); + border-radius: 14px; + background: var(--night-raised); } -.panel h2 { +.store-panel h2 { margin: 0 0 4px; font-size: 1.15rem; } -.panel-hint { +.store-hint { margin: 0 0 16px; - color: var(--muted); - font-size: 0.92rem; + color: var(--night-muted); + font-size: 0.93rem; + line-height: 1.5; } .result-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; } .result-card { display: grid; gap: 4px; - padding: 14px 16px; - border: 1px solid var(--line); - border-left: 4px solid var(--muted); - border-radius: 6px; - background: #fbfdfc; + padding: 16px 18px; + border: 1px solid var(--night-line); + border-top: 4px solid var(--night-muted); + border-radius: 10px; + background: var(--night-card); } .result-card.ok { - border-left-color: var(--good); + border-top-color: var(--mint); } .result-card.bad { - border-left-color: var(--bad); -} - -.result-card.pending { - border-left-color: var(--warn); + border-top-color: var(--red); } .result-label { - font-size: 0.78rem; - font-weight: 600; - letter-spacing: 0.06em; + color: var(--night-muted); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; text-transform: uppercase; - color: var(--muted); } .result-value { - font-size: 1.5rem; - font-weight: 700; + font-size: 1.7rem; + font-weight: 800; font-variant-numeric: tabular-nums; } .result-note { + color: var(--night-muted); font-size: 0.85rem; - color: var(--muted); } .result-card.ok .result-note { - color: var(--good); + color: var(--mint); } .result-card.bad .result-note { - color: var(--bad); -} - -.timing { - font-variant-numeric: tabular-nums; - font-weight: 600; + color: var(--red); } .log { max-height: 260px; - overflow-y: auto; margin: 0; padding: 14px 16px; - border: 1px solid var(--line); - border-radius: 6px; - background: #1e2227; + overflow-y: auto; + border: 1px solid var(--night-line); + border-radius: 8px; + background: #070c15; color: #d7dde6; - font-family: "Menlo", "Consolas", monospace; + font-family: "Cascadia Code", "SF Mono", Menlo, Consolas, monospace; font-size: 0.82rem; line-height: 1.6; white-space: pre-wrap; } -.status-tile { +/* The workshop guide: a launch checklist clipped to the side of the war room */ + +.guide { + position: sticky; + top: 0; + flex: 0 0 var(--guide-width); + height: 100vh; + overflow-y: auto; + border-top: 6px solid var(--coral); + background: var(--paper); + box-shadow: -24px 0 60px rgb(0 0 0 / 35%); + color: var(--ink); +} + +.guide.collapsed { + flex-basis: 76px; + display: grid; + align-content: start; + justify-items: center; + gap: 14px; + padding: 14px 0; +} + +.guide-header { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 20px 22px 12px; +} + +.guide-eyebrow { + margin: 0 0 2px; + color: var(--coral-dark); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.guide-title { + margin: 0; + font-size: 1.5rem; + font-weight: 800; + letter-spacing: -0.02em; +} + +.guide-subtitle { + margin: 4px 0 0; + color: var(--muted); + font-size: 0.9rem; +} + +.guide-toggle { + flex: none; + align-self: start; + display: grid; + justify-items: center; + gap: 8px; + min-width: 36px; + padding: 6px 8px; + border: 1px solid var(--paper-line); + border-radius: 8px; + background: var(--paper-raised); + color: var(--ink); + font: inherit; + font-size: 1.1rem; + font-weight: 700; + cursor: pointer; +} + +.guide-toggle:hover { + border-color: var(--coral); + color: var(--coral-dark); +} + +.guide-rail-label { + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; + writing-mode: vertical-rl; +} + +.guide-rail { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.guide-rail-step { + display: grid; + justify-items: center; + gap: 2px; + width: 52px; + padding: 6px 0; + border: 1px solid var(--paper-line); + border-radius: 10px; + background: var(--paper-raised); + color: var(--ink); + font: inherit; + cursor: pointer; +} + +.guide-rail-number { + font-weight: 800; +} + +.guide-progress { + display: grid; + gap: 10px; + padding: 0 22px 16px; + border-bottom: 1px dashed var(--paper-line); +} + +.progress-meter { + display: flex; + gap: 6px; +} + +.progress-segment { + flex: 1; + height: 8px; + border-radius: 999px; + background: var(--paper-line); +} + +.progress-segment.done { + background: var(--good); +} + +.guide-progress-row { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.guide-progress-count { + margin: 0; + color: var(--muted); +} + +.guide-progress-count strong { + color: var(--ink); + font-size: 1.1rem; +} + +.guide-tabs { display: grid; gap: 4px; - justify-items: end; - padding: 14px 18px; - border: 1px solid rgb(248 255 252 / 22%); + padding: 12px 14px; + border-bottom: 1px dashed var(--paper-line); +} + +.guide-tab { + display: grid; + grid-template-columns: 28px 1fr auto; + gap: 10px; + align-items: center; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: 10px; + background: transparent; + color: var(--ink); + font: inherit; + font-size: 0.93rem; + text-align: left; + cursor: pointer; +} + +.guide-tab:hover { + background: var(--paper-raised); +} + +.guide-tab.selected { + border-color: var(--paper-line); + background: var(--paper-raised); + box-shadow: 0 4px 14px rgb(64 44 16 / 8%); + font-weight: 700; +} + +.guide-tab-number { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border: 2px solid var(--ink); + border-radius: 50%; + font-size: 0.85rem; + font-weight: 800; +} + +.guide-tab.selected .guide-tab-number { + border-color: var(--coral); + background: var(--coral); + color: #fff; +} + +.guide-body { + display: grid; + gap: 14px; + padding: 16px 22px 40px; +} + +.guide-section { + padding: 16px 18px; + border: 1px solid var(--paper-line); + border-radius: 12px; + background: var(--paper-raised); +} + +.guide-section h3, +.guide-section h4 { + margin: 0 0 8px; + font-size: 1rem; +} + +.guide-section p { + margin: 0 0 10px; + line-height: 1.55; +} + +.guide-section p:last-child { + margin-bottom: 0; +} + +.guide-note { + color: var(--muted); + font-size: 0.88rem; +} + +.step-header { + display: grid; + gap: 10px; +} + +.step-eyebrow { + margin: 0; + color: var(--coral-dark); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.step-title { + margin: 2px 0 4px; + font-size: 1.35rem; + font-weight: 800; + letter-spacing: -0.01em; +} + +.step-members { + color: var(--muted); + overflow-wrap: anywhere; +} + +.step-actions { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.step-buttons { + display: flex; + gap: 8px; +} + +.step-buttons button.action.secondary { + border-color: var(--paper-line); + color: var(--ink); +} + +.how-it-works, +.task-list { + margin: 0 0 12px; + padding-left: 20px; + line-height: 1.55; +} + +.how-it-works li + li, +.task-list li + li { + margin-top: 6px; +} + +.how-it-works { + margin-bottom: 0; +} + +.facts { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 12px; + margin: 0 0 12px; + font-size: 0.92rem; +} + +.facts dt { + color: var(--muted); + font-weight: 700; +} + +.facts dd { + margin: 0; + overflow-wrap: anywhere; +} + +.see-it { + padding: 10px 14px; + border-left: 4px solid var(--coral); border-radius: 8px; - background: rgb(248 255 252 / 10%); + background: #fff0e8; } -.status-value { - font-size: 1.9rem; +.ai-note { + padding: 10px 14px; + border-left: 4px solid var(--gold); + border-radius: 8px; + background: #fff6dc; + color: #5e4508; +} + +.clues { + padding: 10px 14px; + border: 1px dashed var(--paper-line); + border-radius: 8px; + background: var(--paper); +} + +.clues summary, +.guide-log summary { font-weight: 700; - line-height: 1; - font-variant-numeric: tabular-nums; + cursor: pointer; } -.status-label { - font-size: 0.82rem; - color: rgb(248 255 252 / 78%); +.clues ul { + margin: 10px 0 0; + padding-left: 20px; + line-height: 1.55; } -.spinner { +.guide-log-count { + margin-left: 6px; + color: var(--muted); + font-weight: 400; + font-size: 0.85rem; +} + +/* Step results */ + +.badge { display: inline-block; - width: 14px; - height: 14px; - margin-right: 8px; - border: 2px solid rgb(98 112 109 / 35%); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; - vertical-align: -2px; + padding: 2px 10px; + border-radius: 999px; + background: #f1e8d8; + color: var(--muted); + font-size: 0.78rem; + font-weight: 700; + white-space: nowrap; } -@keyframes spin { +.badge-compact { + padding: 0; + background: transparent; + font-size: 0.9rem; +} + +.badge-passed { + background: #e0f3e9; + color: var(--good); +} + +.badge-failed { + background: #fbe3e5; + color: var(--bad); +} + +.badge-running { + background: #fff1cc; + color: #6b4f0b; +} + +.badge-compact.badge-passed, +.badge-compact.badge-failed, +.badge-compact.badge-running { + background: transparent; +} + +.banner { + display: grid; + justify-items: start; + gap: 6px; + padding: 12px 16px; + border: 1px solid var(--paper-line); + border-radius: 12px; + background: var(--paper-raised); + line-height: 1.5; +} + +.banner-passed { + border-color: rgb(15 138 82 / 30%); + background: #e0f3e9; + color: #0b6139; +} + +.banner-failed { + border-color: rgb(200 40 58 / 30%); + background: #fbe3e5; + color: #9d1b2b; +} + +.banner-running { + border-color: rgb(183 134 27 / 40%); + background: #fff1cc; + color: #6b4f0b; +} + +.banner-locked { + color: var(--muted); +} + +.banner-note { + font-size: 0.83rem; + opacity: 0.85; +} + +/* @key recreates the banner for every new result, so this animation plays again after each click */ +.banner-fresh { + animation: banner-in 0.35s ease-out; +} + +@keyframes banner-in { + from { + opacity: 0; + transform: translateY(-6px); + } + to { - transform: rotate(360deg); + opacity: 1; + transform: none; } } -.warning-banner { - margin-bottom: 16px; - padding: 14px 18px; - border: 1px solid rgb(197 157 53 / 45%); - border-left: 4px solid var(--warn); - border-radius: 6px; - background: #fdf8e9; - color: #6b551a; +.link-button { + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + font-weight: 700; + text-decoration: underline; + text-underline-offset: 3px; + cursor: pointer; +} + +.check-list { + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.check { + display: grid; + grid-template-columns: 24px 1fr; + gap: 8px; + padding: 10px 12px; + border: 1px solid var(--paper-line); + border-radius: 10px; +} + +.check-failed { + border-color: rgb(200 40 58 / 28%); + background: #fff4f5; +} + +.check-body { + min-width: 0; +} + +.guide-section .check-description { + margin: 0 0 6px; + font-weight: 600; + line-height: 1.4; +} + +.check-values { + display: grid; + grid-template-columns: max-content 1fr; + gap: 2px 10px; + margin: 0; + font-size: 0.86rem; +} + +.check-values dt { + color: var(--muted); +} + +.check-values dd { + margin: 0; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} + +.guide-section .check-hint { + margin: 8px 0 0; + padding: 8px 10px; + border-radius: 8px; + background: #fde2e5; + color: #7a1522; + font-size: 0.88rem; + line-height: 1.5; +} + +.table-scroll { + margin-top: 10px; + overflow-x: auto; +} + +.step-log { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; +} + +.step-log th, +.step-log td { + padding: 6px 8px; + border-bottom: 1px solid var(--paper-line); + text-align: left; + vertical-align: top; +} + +.step-log th { + color: var(--muted); +} + +.step-log td:nth-child(-n + 3) { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* Narrow screens stack the guide under the app */ + +@media (max-width: 1080px) { + :root { + --guide-width: 380px; + } } -@media (max-width: 640px) { - .hero-panel { - grid-template-columns: 1fr; - align-items: start; +@media (max-width: 860px) { + .workspace { + flex-direction: column; + } + + .guide, + .guide.collapsed { + position: static; + flex-basis: auto; + height: auto; + box-shadow: none; + } + + .guide.collapsed { + grid-auto-flow: column; + justify-content: start; + padding: 12px 16px; + } + + .guide.collapsed .guide-rail { + grid-auto-flow: column; + } + + .guide-rail-label { + writing-mode: horizontal-tb; + } + + .store { + width: calc(100% - 32px); } - .hero-actions, - .status-tile { - justify-items: start; + .store-hero { + padding: 28px 22px; } } diff --git a/5. Asynchronous vs Parallel Programming/AsyncVsParallel.pptx b/5. Asynchronous vs Parallel Programming/AsyncVsParallel.pptx index c270bbc..fa38658 100644 Binary files a/5. Asynchronous vs Parallel Programming/AsyncVsParallel.pptx and b/5. Asynchronous vs Parallel Programming/AsyncVsParallel.pptx differ diff --git a/5. Asynchronous vs Parallel Programming/README.md b/5. Asynchronous vs Parallel Programming/README.md index 7230aba..439ee0b 100644 --- a/5. Asynchronous vs Parallel Programming/README.md +++ b/5. Asynchronous vs Parallel Programming/README.md @@ -11,18 +11,22 @@ The **1. Start** folder contains the intentionally imperfect code you will edit. 3. Build the project once so you can confirm the starter compiles. It builds clean, with no warnings. Every bug in this section is a runtime bug. 4. Run the app and open it in your browser. -The app runs at [http://localhost:5007](http://localhost:5007). +The starter app runs at [http://localhost:5007](http://localhost:5007). The finished app runs at [http://localhost:5008](http://localhost:5008), so you can run both at the same time and compare them later. + +The app opens on the **Checkout page**, with the **workshop guide** docked beside it. Every step in the guide is one bug in OrderPortal's shared services, and every time the app starts it checks your code against them. Right now the guide says it stopped at Step 1, and the terminal running the app shows the same result, with a hint. ## 2. Inspect the Starting Code 1. Open **OrderPortal/Program.cs** and notice that `OrderMetrics`, `TaxRateProvider`, `InventoryLedger` and `CheckoutService` are registered with `AddSingleton`. One instance of each is shared by every request. 2. Open **OrderPortal/Services/CheckoutService.cs** and read `RunCheckoutBurstAsync(int, CancellationToken)`. It queues 2,000 checkouts through `Parallel.ForEachAsync`. So roughly one checkout per CPU core is modifying those singletons at any instant, and all 2,000 pass through the same three objects over the lifetime of the burst. One writer per core is all it takes to lose orders. -3. Open **OrderPortal/Services/OrderMetrics.cs**, **OrderPortal/Services/TaxRateProvider.cs**, and **OrderPortal/Services/InventoryLedger.cs**, and find each `// ToDo Refactor` comment. +3. Open **OrderPortal/Services/OrderMetrics.cs**, **OrderPortal/Services/TaxRateProvider.cs**, and **OrderPortal/Services/InventoryLedger.cs**, and find each `// ToDo Refactor (Step N)` comment. The step number tells you which step checks that line. 4. Open **OrderPortal/Components/Pages/Checkout.razor.cs** and read `ExpectedRevenue`. Nothing in this app is random. Every graded result card has exactly one correct value. +5. Open the **OrderPortal/Steps** folder. Each `Step*.cs` file drives your services the way a sale does, with comments that explain what each expected result checks and why. These files are your guide. +6. Skim **OrderPortal/Verification** and **OrderPortal/Components/Layout/WorkshopGuide.razor**. They are the workshop plumbing that runs the steps and draws the guide. You do not need to change them. -Now run the app and use it: +Now use the **Checkout page**: -1. Press **Run 2000 checkouts**. Four cards appear: Orders recorded, Revenue, Tax table builds, and Elapsed. The first three are graded against a known correct value: a green left edge means the number matched, and a red left edge means it did not. Elapsed stays grey, because it is a timing, not a correctness check. +1. Press **Run 2000 checkouts**. Four cards appear: Orders recorded, Revenue, Tax table builds, and Elapsed. The first three are graded against a known correct value: a green top edge means the number matched, and a red top edge means it did not. Elapsed stays grey, because it is a timing, not a correctness check. 2. Read the Orders recorded card. It expects 2000, and you will see something slightly lower, such as 1996. Those orders were placed. The counter lost them. 3. Read the Revenue card. It is short too, and by considerably more than the missing orders are worth, often several times more. `decimal +=` is a much wider read, modify, write than `int++`, so it loses far more updates, and unlike an `int`, a 16 byte `decimal` can also be read or written half updated. The two shortfalls will not line up no matter how carefully you divide. 4. Read the Tax table builds card. It expects 1 and will show one build per CPU core, so 16 on a 16 core machine and 8 on an 8 core laptop. Building that table takes 120 milliseconds, and it was supposed to happen once for the life of the process. @@ -41,6 +45,14 @@ Pay attention to these clues: 7. `SemaphoreSlim` is not reentrant. Ask which method takes the permit, and which method it calls next. 8. `List` and `Dictionary` are not thread safe. A lock is all you need to make the one in this app safe. There are purpose built collections for this too, and we cover them this afternoon. +The app walks you through the challenge one step at a time: + +1. Step 1 counts every order, Step 2 adds up the revenue, Step 3 builds the tax table once, and Step 4 reserves stock without deadlocking. +2. A step unlocks only after the step before it passes. Select a step in the guide to read the story behind the bug, how to see it on the Checkout page, which file to change, and a task list for that step. If you get stuck, it has clues. +3. Each step drives your code from many threads at once, the way a sale does, and shows a checklist of every expected result next to what actually happened. Every result that does not match comes with a hint, and the same hint is printed in the terminal running the app. +4. Every time the app starts, it checks your code against every step, so the guide always reflects the code you have now. +5. Stop and run the app again after each change. If your IDE applied the change with Hot Reload, click **Run every step** at the top of the guide instead. + ## 3. Challenge: Make the Shared Services Safe Under Load Recommended time: 25 minutes. @@ -51,14 +63,14 @@ Fix **OrderMetrics.cs**, **TaxRateProvider.cs**, and **InventoryLedger.cs** so t Everything you need is in this section's toolbox: `Interlocked`, `lock` with the .NET 9 `Lock` type, `Lazy`, and the `SemaphoreSlim` that is already in `InventoryLedger`. You do not need anything from `System.Collections.Concurrent`. Those types are the subject of Concurrent Collections this afternoon, and reaching for one here would hide the lesson rather than teach it. -Requirements: +Requirements, in the order the steps check them: 1. Update the order count atomically so no increment is ever lost. 2. Guard the revenue total. `Interlocked` cannot help you here, so pick the right lock for a synchronous update. 3. Make the property getters safe too, so the page cannot read a stale count or a half written `decimal`. 4. Keep `OrderMetrics.Reset()` correct when it runs against the same shared state. 5. Build the expensive tax table exactly once, no matter how many threads call `GetRate(string)` at the same moment. -6. Count tax rate lookups atomically. +6. Count tax rate lookups atomically, and read `Lookups` and `Builds` so the page never sees a stale value. 7. Keep `TaxRateProvider.Reset()` working: after a reset, the next burst must build the table again, exactly once. 8. Remove the deadlock in `InventoryLedger.ReserveStockAsync(...)` without losing the guarantee that the stock update and its audit entry happen under the same lock. 9. Keep `WriteAuditEntryAsync(...)` callable on its own, by a caller that does not already hold the ledger lock. @@ -68,12 +80,13 @@ Requirements: Acceptance checks: 1. **OrderPortal.slnx** builds. -2. Orders recorded shows 2000 with a green edge. -3. Revenue matches the expected total shown on the card, with a green edge. -4. Tax table builds shows 1 with a green edge, on your machine and on every other machine in the room. -5. **Reserve stock** answers right away with "Reserved 1 of SKU-1000 and wrote the audit entry." instead of timing out after 5 seconds. -6. Pressing **Run 2000 checkouts** five times in a row gives the same three correct numbers every time. -7. Your code is ready to compare with **2. Finish/OrderPortal**. +2. The workshop guide shows 4 of 4 steps pass after the app starts. +3. On the Checkout page, Orders recorded shows 2000 with a green edge. +4. Revenue matches the expected total shown on the card, with a green edge. +5. Tax table builds shows 1 with a green edge, on your machine and on every other machine in the room. +6. **Reserve stock** answers right away with "Reserved 1 of SKU-1000 and wrote the audit entry." instead of timing out after 5 seconds. +7. Pressing **Run 2000 checkouts** five times in a row gives the same three correct numbers every time. +8. Your code is ready to compare with **2. Finish/OrderPortal**. ## 4. Review the Solution diff --git a/5. Asynchronous vs Parallel Programming/SOLUTION.md b/5. Asynchronous vs Parallel Programming/SOLUTION.md index c0e7300..ce2dfbe 100644 --- a/5. Asynchronous vs Parallel Programming/SOLUTION.md +++ b/5. Asynchronous vs Parallel Programming/SOLUTION.md @@ -2,7 +2,9 @@ Use this during the guided walkthrough after the challenge and group review in [README.md](README.md). -The completed project is **2. Finish/OrderPortal**. Compare your implementation with the finished sample as we rebuild the solution step by step. +The completed project is **2. Finish/OrderPortal**. Compare your implementation with the finished sample as we rebuild the solution step by step. Each section below names the workshop step it makes pass, so you can follow along in the workshop guide. + +**1. Start** and **2. Finish** share the same workshop plumbing: the `Steps` and `Verification` folders, and the workshop guide in `Components/Layout`. Everything you change lives in the three services. Beyond those, only the Checkout page's timeout message, which in the starter points you at the deadlock, and the port in `Properties/launchSettings.json` differ in a way that matters. ## 1. Find the Shared State @@ -10,33 +12,38 @@ Before changing a line, notice why these bugs exist at all. Four services are re ```cs // Registered as singletons, so one instance is shared by every -// concurrent request. That ensures their state is a shared resource. +// concurrent request. That is what makes their state a shared resource. builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); ``` -One `OrderMetrics`, one `TaxRateProvider` and one `InventoryLedger` for the whole application. That is the normal, correct choice for services that hold a cache or a counter. It also means that when `CheckoutService.RunCheckoutBurstAsync(int, CancellationToken)` pushes 2,000 checkouts through `Parallel.ForEachAsync`, one worker per core is inside the same three objects at every instant, and all 2,000 orders pass through them over the life of the burst. `Parallel.ForEachAsync` defaults `MaxDegreeOfParallelism` to `Environment.ProcessorCount`, and that number is exactly why you will see the tax table built once per core in Step 4. +One `OrderMetrics`, one `TaxRateProvider` and one `InventoryLedger` for the whole application. That is the normal, correct choice for services that hold a cache or a counter. It also means that when `CheckoutService.RunCheckoutBurstAsync(int, CancellationToken)` pushes 2,000 checkouts through `Parallel.ForEachAsync`, one worker per core is inside the same three objects at every instant, and all 2,000 orders pass through them over the life of the burst. `Parallel.ForEachAsync` defaults `MaxDegreeOfParallelism` to `Environment.ProcessorCount`, and that number is exactly why you will see the tax table built once per core in Step 3. Nothing about that is exotic. Any service you register with `AddSingleton` is reachable from every concurrent request your server is handling, and every mutable field on it is shared state. -## 2. Make the Order Count Atomic +## 2. Make the Order Count Atomic (Step 1) The starter records an order like this: ```cs -// ToDo Refactor: `++` is a read, an add, and a write. Two threads can read the -// same value before either writes, and one order silently disappears. public void RecordOrder(decimal orderTotal) { + // ToDo Refactor (Step 1): `++` is a read, an add, and a write. Two threads can read the + // same value before either writes, and one order silently disappears. _ordersPlaced++; + + // ToDo Refactor (Step 2): `+=` on a decimal is a much wider read, modify, write than + // `++` on an int, and Interlocked has no overload for decimal. _revenue += orderTotal; } ``` `_ordersPlaced++` looks like one step. It is three: read the current value, add one, write the result back. Two threads can read `1`, both add one, and both write `2`. Two orders happened and the counter moved by one. That is the missing 4 orders out of 2,000. +Step 1 makes the same point far more loudly. After the 2,000 checkout burst, it has 32 threads call `RecordOrder(1m)` 25,000 times each. The starter usually records only a small fraction of those 800,000 orders, which is why a single burst that happens to come out right proves nothing. + `Interlocked` performs the read, the add and the write as a single atomic operation that no other thread can interleave with: ```cs @@ -46,7 +53,7 @@ Interlocked.Increment(ref _ordersPlaced); `Interlocked` is the cheapest tool in the box. It is lock free, so no thread ever waits. `Increment(ref int)`, `Decrement(ref int)` and `Add(ref int, int)` work on `int` and `long`, and `Exchange` and `CompareExchange` also handle `float`, `double`, `nint` and object references. -## 3. Guard the Revenue With a Lock +## 3. Guard the Revenue With a Lock (Step 2) `Interlocked` has no `decimal` overload, because `decimal` is 128 bits and cannot be updated as a single atomic instruction. That is also why the Revenue card is short by much more than the missing orders are worth: `decimal +=` is a far wider read, modify, write than `int++`, so it loses many more updates. Revenue needs a real lock: @@ -98,22 +105,24 @@ Here is the finished `OrderMetrics` in full. Note that the reads are guarded too `Reset()` gets the same treatment. It runs at the start of every burst, so it is shared state too. -## 4. Build the Tax Table Exactly Once +With `OrderMetrics` finished, Steps 1 and 2 pass: the burst records 2,000 orders and the expected revenue, and the 32 thread stress test loses neither orders nor revenue. + +## 4. Build the Tax Table Exactly Once (Step 3) The starter caches the rate table in a nullable field: ```cs - // ToDo Refactor: two threads can both find this null and both build the table + // ToDo Refactor (Step 3): two threads can both find this null and both build the table IReadOnlyDictionary? _rates; ``` ```cs - // ToDo Refactor: `??=` is not atomic. Under load this runs BuildRates() + // ToDo Refactor (Step 3): `??=` is not atomic. Under load this runs BuildRates() // many times, and every caller pays the full build cost. _rates ??= BuildRates(); ``` -`??=` reads as one operation and compiles into two: check whether `_rates` is null, and if it is, assign it. `Parallel.ForEachAsync` runs one checkout per core by default, so on a 16 core machine 16 threads got between the check and the assignment, `BuildRates()` ran 16 times, and each of them paid the full 120 millisecond cost. Your own run shows one build per core, so the number on your screen is your core count. +`??=` reads as one operation and compiles into two: check whether `_rates` is null, and if it is, assign it. `Parallel.ForEachAsync` runs one checkout per core by default, so on a 16 core machine 16 threads got between the check and the assignment, `BuildRates()` ran 16 times, and each of them paid the full 120 millisecond cost. Your own run shows one build per core, so the number on your screen is your core count. Step 3 lines 32 threads up behind a `Barrier` and releases them at the same instant, so the starter builds the table 32 times there, on any machine with more than one core. `Lazy` exists for exactly this. Its default thread safety mode, `LazyThreadSafetyMode.ExecutionAndPublication`, guarantees the factory runs exactly once and that every caller gets the same instance: @@ -142,7 +151,7 @@ The starter caches the rate table in a nullable field: The lookup counter gets `Interlocked.Increment` for the same reason the order count did. -## 5. Keep Reset Honest +## 5. Keep Reset Honest (Step 3) `Reset()` cannot set a `Lazy` back to "not created yet", so it hands out a fresh one: @@ -181,12 +190,12 @@ The counters are read through `Volatile.Read`. `RunCheckoutBurstAsync(int, Cance public int Builds => Volatile.Read(ref _builds); ``` -## 6. Read the Deadlock Before Fixing It +## 6. Read the Deadlock Before Fixing It (Step 4) Nothing about the ledger is careless. It uses `SemaphoreSlim` rather than `lock`, which is right: `lock` cannot be held across an `await`, and this code awaits. The bug is one line: ```cs - // ToDo Refactor: this call also waits on _ledgerSemaphore, which this + // ToDo Refactor (Step 4): this call also waits on _ledgerSemaphore, which this // method is already holding. SemaphoreSlim is not reentrant, so the // thread waits for a permit it will never release. That is a deadlock. await WriteAuditEntryAsync($"Reserved {quantity} of {sku}", token).ConfigureAwait(false); @@ -194,9 +203,9 @@ Nothing about the ledger is careless. It uses `SemaphoreSlim` rather than `lock` `ReserveStockAsync(...)` is holding `_ledgerSemaphore`. `WriteAuditEntryAsync(...)` begins by waiting on `_ledgerSemaphore`. The semaphore has one permit, and the caller that wants it is the caller that already holds it. `SemaphoreSlim` is not reentrant, so it will not notice that this is the same caller. It waits forever. -In the browser that shows up as the request sitting there until the 5 second timeout cancels it. In production there is no timeout, so the request never completes. Notice what does not happen: no thread is blocked. `WaitAsync` is awaited, so the thread went straight back to the pool and the operation is simply parked forever. Nothing is pegged, nothing is starved, and a thread dump shows nothing waiting, which is exactly why this is so easy to miss. What is stuck is the permit. The ledger holds its only permit forever, so every later caller of `ReserveStockAsync(...)` and `WriteAuditEntryAsync(...)` queues up behind a lock that will never be released. +In the browser that shows up as the request sitting there until the 5 second timeout cancels it. Step 4 gives the same call 2 seconds, then reports the deadlock and skips its remaining checks, because every later call on that ledger would wait behind the same permit. In production there is no timeout, so the request never completes. Notice what does not happen: no thread is blocked. `WaitAsync` is awaited, so the thread went straight back to the pool and the operation is simply parked forever. Nothing is pegged, nothing is starved, and a thread dump shows nothing waiting, which is exactly why this is so easy to miss. What is stuck is the permit. The ledger holds its only permit forever, so every later caller of `ReserveStockAsync(...)` and `WriteAuditEntryAsync(...)` queues up behind a lock that will never be released. -## 7. Split the Ledger in Two +## 7. Split the Ledger in Two (Step 4) The fix is a pattern worth memorizing: a public method that takes the lock, and a private `...CoreAsync` method that assumes the lock is already held. Every caller that needs the lock takes it exactly once. @@ -257,15 +266,16 @@ The fix is a pattern worth memorizing: a public method that takes the lock, and `ReserveStockAsync(...)` takes the permit and calls `WriteAuditEntryCoreAsync(...)`, which never touches the semaphore. `WriteAuditEntryAsync(...)` is still there for callers that do not hold the lock, and it takes the permit itself. The stock update and its audit entry still happen under one lock, so the ledger is still atomic. Nothing waits for itself. -Order matters inside that lock, and it is worth saying why. The audit write is the only step that can be cancelled, because it is the only one that awaits, so it runs first and the decrement follows it. Put the decrement first and a token that trips during that 5 millisecond write leaves stock reduced with no audit entry, which is exactly what the comment at the top of the class promises never happens. Measured over 200 reservations cancelled mid write, the decrement-first ordering left the ledger inconsistent every single time and the audit-first ordering never did. The rule generalises: inside a lock, do the work that can fail before the work you cannot undo. +Order matters inside that lock, and it is worth saying why. The audit write is the only step that can be cancelled, because it is the only one that awaits, so it runs first and the decrement follows it. Put the decrement first and a token that trips during that 5 millisecond write leaves stock reduced with no audit entry, which is exactly what the comment at the top of the class promises never happens. Measured over 200 reservations cancelled mid write, the decrement-first ordering left the ledger inconsistent every single time and the audit-first ordering never did. The rule generalizes: inside a lock, do the work that can fail before the work you cannot undo. Notice the naming convention. The `Core` suffix is the signal that says "this method assumes the lock is held". Add the comment above it too. Six months from now, that comment is the only thing standing between someone and a reintroduced deadlock. -## 8. Guard the Audit Trail +## 8. Guard the Audit Trail (Step 4) `List` is not thread safe. In the starter, `AuditEntries` returns `Count` with no lock at all, while the audit writer is appending to the same list: ```cs + // ToDo Refactor (Step 4): List is not thread safe, and this reads it while the audit writer appends to it public int AuditEntries => _auditTrail.Count; ``` @@ -286,6 +296,8 @@ So take a lock in the getter, and take the same lock in the writer: `WriteAuditEntryCoreAsync(...)` adds under that same lock, so a read can never land in the middle of an append. It is the same class of bug as the counter: a collection that only ever had one writer now has several. +This is one of the requirements no step can measure from outside. A torn `Count` read almost never shows up in a test, which is exactly why it has to be fixed on purpose. The step lists it as a task for the same reason, alongside the `Volatile.Read` getters in sections 3 and 5. + While you are in the file, update the comment on the semaphore so the next reader knows the rule: ```cs @@ -298,7 +310,7 @@ While you are in the file, update the comment on the semaphore so the next reade ## 9. Run It Five Times -Run the finished app on [http://localhost:5008](http://localhost:5008) and press **Run 2000 checkouts**. All three graded cards turn green: 2000 orders recorded, revenue matching the expected total, and the tax table built exactly 1 time. Press **Reserve stock** and it answers immediately instead of timing out. +Run the finished app on [http://localhost:5008](http://localhost:5008). The workshop guide shows 4 of 4 steps pass once the checks that run after the app starts have finished. On the **Checkout page** beside it, press **Run 2000 checkouts**. All three graded cards turn green: 2000 orders recorded, revenue matching the expected total, and the tax table built exactly 1 time. Press **Reserve stock** and it answers immediately instead of timing out. Then run the burst four more times. That repeatability is the point. Race conditions are timing dependent, so a single passing run proves nothing; a fixed one is correct every time, on every machine in the room. diff --git a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Components/Layout/MainLayout.razor b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Components/Layout/MainLayout.razor index 1c2a641..f745fc5 100644 --- a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Components/Layout/MainLayout.razor +++ b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Components/Layout/MainLayout.razor @@ -1,5 +1,9 @@ @inherits LayoutComponentBase -
- @Body -
+
+
+ @Body +
+ + +
diff --git a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Components/Layout/StepBadge.razor b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Components/Layout/StepBadge.razor new file mode 100644 index 0000000..352439e --- /dev/null +++ b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Components/Layout/StepBadge.razor @@ -0,0 +1,10 @@ +@inherits StepBadgeBase + +@if (IsCompact) +{ + @Label +} +else +{ + @Icon @Label +} diff --git a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Components/Layout/StepBadge.razor.cs b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Components/Layout/StepBadge.razor.cs new file mode 100644 index 0000000..979cd80 --- /dev/null +++ b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Components/Layout/StepBadge.razor.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Components; + +namespace ProductDetails.Components.Layout; + +public partial class StepBadgeBase : ComponentBase +{ + [Parameter, EditorRequired] + public StepStatus Status { get; set; } + + [Parameter] + public bool IsUnlocked { get; set; } = true; + + // Only the icon, for the collapsed guide + [Parameter] + public bool IsCompact { get; set; } + + public string Icon => Status is StepStatus.Running ? "⏳" : !IsUnlocked ? "🔒" : Status switch + { + StepStatus.Passed => "✅", + StepStatus.Failed or StepStatus.TimedOut or StepStatus.Crashed => "❌", + _ => "•", + }; + + public string Label => Status is StepStatus.Running ? "Running" : !IsUnlocked ? "Locked" : Status switch + { + StepStatus.Passed => "Passed", + StepStatus.Failed => "Failed", + StepStatus.TimedOut => "Timed out", + StepStatus.Crashed => "Crashed", + StepStatus.Stopped => "Stopped", + _ => "Not run", + }; + + public string CssClass => Status is StepStatus.Running ? "badge-running" : !IsUnlocked ? "badge-locked" : Status switch + { + StepStatus.Passed => "badge-passed", + StepStatus.Failed or StepStatus.TimedOut or StepStatus.Crashed => "badge-failed", + _ => "badge-idle", + }; +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Components/Layout/WorkshopGuide.razor b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Components/Layout/WorkshopGuide.razor new file mode 100644 index 0000000..e2777f8 --- /dev/null +++ b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Components/Layout/WorkshopGuide.razor @@ -0,0 +1,321 @@ +@inherits WorkshopGuideBase + + diff --git a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Components/Layout/WorkshopGuide.razor.cs b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Components/Layout/WorkshopGuide.razor.cs new file mode 100644 index 0000000..11d11d5 --- /dev/null +++ b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Components/Layout/WorkshopGuide.razor.cs @@ -0,0 +1,179 @@ +using Microsoft.AspNetCore.Components; + +namespace ProductDetails.Components.Layout; + +// Workshop plumbing: the guide docked beside the app. It shows your challenge, every step's result, and the step you are working on. +public partial class WorkshopGuideBase : ComponentBase, IAsyncDisposable +{ + // Selecting this shows your challenge instead of a step + public const int ChallengeTab = 0; + + // A run that fails quickly can finish faster than anyone can see a spinner + static readonly TimeSpan _minimumActivityIndicatorTime = TimeSpan.FromMilliseconds(600); + + readonly CancellationTokenSource _disposeCancellationTokenSource = new(); + + Task _polling = Task.CompletedTask; + bool _isStartingCheck; + int? _startingRunStepNumber; + + [Inject] + public required StepVerifier Verifier { get; init; } + + [Inject] + public required ILogger Logger { get; init; } + + public int SelectedTab { get; private set; } = ChallengeTab; + + public bool IsCollapsed { get; private set; } + + public string? ErrorMessage { get; private set; } + + public int PassedStepCount => Verifier.Steps.Count(step => Verifier.GetState(step.Number).Status is StepStatus.Passed); + + public bool IsCheckingSteps => _isStartingCheck || Verifier.IsCheckingAllSteps; + + // The first step that does not pass yet, which is the one to work on next + public WorkshopStep? StoppedAtStep => Verifier.Steps.FirstOrDefault(step => Verifier.GetState(step.Number).Status is not StepStatus.Passed); + + // The last step the check reaches: the first one that did not pass, or the last step when they all passed + public WorkshopStep LastCheckedStep => StoppedAtStep ?? Verifier.Steps[^1]; + + public WorkshopStep? SelectedStep => Verifier.FindStep(SelectedTab); + + // The run lives in the verifier, not in this guide, so a step that is still running shows up again after a reload + public ActiveStepRun? SelectedRun => Verifier.ActiveRun is { } run && run.Step.Number == SelectedTab ? run : null; + + public bool IsSelectedStepRunning => _startingRunStepNumber == SelectedTab || SelectedRun is not null; + + public async ValueTask DisposeAsync() + { + // Only stops refreshing the guide. A step that is running keeps running. + await _disposeCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + await _polling.ConfigureAwait(false); + + _disposeCancellationTokenSource.Dispose(); + + GC.SuppressFinalize(this); + } + + protected override void OnInitialized() + { + _polling = PollVerifier(_disposeCancellationTokenSource.Token); + } + + protected void SelectTab(int tab) + { + SelectedTab = tab; + IsCollapsed = false; + ErrorMessage = null; + } + + protected void ToggleCollapsed() => IsCollapsed = !IsCollapsed; + + protected async Task CheckAllStepsAsync() + { + var token = _disposeCancellationTokenSource.Token; + string? errorMessage = null; + + ErrorMessage = null; + _isStartingCheck = true; + + // Show the activity indicator before the check starts + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + try + { + // Keep the indicator on screen long enough to notice, even when Step 1 fails quickly + await Task.WhenAll(Verifier.CheckAllSteps(), Task.Delay(_minimumActivityIndicatorTime, token)).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The guide was closed while the steps were being checked + return; + } + catch (Exception e) + { + Logger.LogError(e, "Checking every step failed"); + + errorMessage = "Checking the steps failed. The terminal running the app has the details."; + } + + // The continuation is off Blazor's renderer, so every component state change goes back through it + await InvokeAsync(() => + { + ErrorMessage = errorMessage; + _isStartingCheck = false; + + StateHasChanged(); + }).ConfigureAwait(false); + } + + protected async Task RunSelectedStepAsync() + { + if (SelectedStep is not { } step) + return; + + var token = _disposeCancellationTokenSource.Token; + string? errorMessage = null; + + ErrorMessage = null; + _startingRunStepNumber = step.Number; + + // Show the activity indicator before the run starts + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + try + { + // Keep the indicator on screen long enough to notice, even when the step fails quickly + await Task.WhenAll(Verifier.RunStep(step), Task.Delay(_minimumActivityIndicatorTime, token)).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The guide was closed while the step was running + return; + } + catch (Exception e) + { + Logger.LogError(e, "Running Step {StepNumber} failed", step.Number); + + errorMessage = "The step could not be run. The terminal running the app has the details."; + } + + // The continuation is off Blazor's renderer, so every component state change goes back through it + await InvokeAsync(() => + { + ErrorMessage = errorMessage; + _startingRunStepNumber = null; + + StateHasChanged(); + }).ConfigureAwait(false); + } + + protected void StopRun() => Verifier.StopRun(); + + // Re-renders while the verifier is busy, plus once more when it finishes, so the live log and results appear without a refresh + async Task PollVerifier(CancellationToken token) + { + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100)); + var wasBusy = true; + + try + { + while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false)) + { + var isBusy = Verifier.IsBusy; + + if (isBusy || wasBusy) + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + wasBusy = isBusy; + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // Expected when the guide is disposed + } + } +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Components/Pages/Product.razor b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Components/Pages/Product.razor index 12489ab..c4aa423 100644 --- a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Components/Pages/Product.razor +++ b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Components/Pages/Product.razor @@ -1,34 +1,73 @@ @page "/" @inherits ProductPageBase -ProductDetails +Wireless Headphones · ProductDetails -
-
-
-

ProductDetails

-

Wireless Headphones

-

This page needs five independent backend services. Watch how long it takes and when each panel appears.

+
+
+ + + Launch day +
+ +
+ -
- +
- @if (TotalSeconds is not null) - { -
- @TotalSeconds.Value.ToString("F1")s - total page load -
- } +
+ + + @if (TotalSeconds is not null) + { +
+ @TotalSeconds.Value.ToString("F1")s + total page load +
+ } +
-
+
@if (PageError is not null) { @@ -38,9 +77,11 @@ } -
-

Page panels

-

Each panel is filled by its own backend service. The slowest service takes 1.2 seconds, and the recommendations service is down.

+
+
+

Page panels

+

Each panel is filled by its own backend service. The slowest service takes 1.2 seconds, and the recommendations service is down. Watch how long the page takes and when each panel appears.

+
@foreach (var panel in Panels) @@ -74,5 +115,5 @@
}
- - + + diff --git a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Components/Pages/Product.razor.cs b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Components/Pages/Product.razor.cs index 2be54ec..e4aad34 100644 --- a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Components/Pages/Product.razor.cs +++ b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Components/Pages/Product.razor.cs @@ -55,7 +55,7 @@ protected async Task LoadProductAsync() try { - // ToDo Refactor: these five services do not depend on each other, but + // ToDo Refactor (Step 1): these five services do not depend on each other, but // each await waits for the previous one to finish. The page costs the // sum of every latency instead of the slowest one. var inventory = await InventoryService.GetInventoryAsync(_sku, CancellationToken.None).ConfigureAwait(false); @@ -70,7 +70,7 @@ protected async Task LoadProductAsync() var shipping = await ShippingService.GetShippingAsync(_sku, CancellationToken.None).ConfigureAwait(false); await SetPanelAsync("Shipping", "ready", $"{shipping.Carrier}, arrives {shipping.EstimatedArrival:MMM d}", stopwatch.Elapsed).ConfigureAwait(false); - // ToDo Refactor: this service is down. Every call shares one try block, + // ToDo Refactor (Step 2): this service is down. Every call shares one try block, // so its failure is the whole page's failure. Move it above Reviews and // two more panels go blank. One flaky service should degrade one panel. var recommendations = await RecommendationsService.GetRecommendationsAsync(_sku, CancellationToken.None).ConfigureAwait(false); @@ -85,6 +85,7 @@ protected async Task LoadProductAsync() // The continuation is off Blazor's renderer, so these writes go back through it await InvokeAsync(() => { + // ToDo Refactor (Step 2): one card's failure is reported here as a failure of the whole page PageError = "A backend service did not respond. Certain panel updates have been skipped."; // Anything still waiting when the load stopped will never arrive @@ -100,6 +101,8 @@ await InvokeAsync(() => TotalSeconds = stopwatch.Elapsed.TotalSeconds; IsLoading = false; + // ToDo Refactor (Step 3): this is the only repaint after the service calls start, + // so no card reaches the screen until the whole load is over StateHasChanged(); }).ConfigureAwait(false); } @@ -115,7 +118,7 @@ protected void ResetPanels() // Product.razor renders Panels with a foreach, and every write here arrives // from a service continuation rather than from the renderer. Writing straight - // to the List would bump its version mid-render, so the write is marshalled. + // to the List would bump its version mid-render, so the write is marshaled. protected Task SetPanelAsync(string name, string status, string? detail, TimeSpan elapsed) => InvokeAsync(() => { diff --git a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Program.cs b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Program.cs index 032ba13..fc1a1c7 100644 --- a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Program.cs +++ b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Program.cs @@ -18,6 +18,11 @@ public static void Main(string[] args) builder.Services.AddSingleton(); builder.Services.AddSingleton(); + // Workshop plumbing: one StepVerifier holds every step's result. It is also a hosted service, + // so it checks your Product page against every step each time the app starts. + builder.Services.AddSingleton(); + builder.Services.AddHostedService(static serviceProvider => serviceProvider.GetRequiredService()); + var app = builder.Build(); if (!app.Environment.IsDevelopment()) diff --git a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Steps/Step1StartEveryCall.cs b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Steps/Step1StartEveryCall.cs new file mode 100644 index 0000000..c5e60b4 --- /dev/null +++ b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Steps/Step1StartEveryCall.cs @@ -0,0 +1,113 @@ +namespace ProductDetails; + +public sealed class Step1StartEveryCall : WorkshopStep +{ + // Every card has answered by the time the slowest service has, give or take a busy laptop + const double _latestCardSeconds = 1.4; + + // An await hands Blazor's renderer back within milliseconds. A blocking wait holds it until every task has finished. + const double _longestRendererPauseSeconds = 0.3; + + const string _neverFinishedHint = "The page never finished loading, and nothing threw. A blocking wait such as .Wait(), .Result or Task.WaitAll holds the thread Blazor renders on, and SetPanelAsync() needs that same thread to record a card, so they wait for each other forever. " + + "Await Task.WhenAll, Task.WhenAny or Task.WhenEach instead. If you loop over Task.WhenAny, remove each finished task from the list, or the loop never ends."; + + const string _oneAfterAnotherHint = "The services still answer one after another. await does not start work: calling the service method does, and each await here holds up the call below it. " + + "Call all five service methods first, keep the Task each one returns, and only then await them together."; + + const string _finishedTooEarlyHint = "The page stopped loading before its slowest service could have answered. Starting every call is only half of it: " + + "the page still has to await all five, with Task.WhenAll, Task.WhenAny or Task.WhenEach, before it stops loading."; + + const string _runningTotalHint = "These cards waited for the services above them before their own calls even started, so each timing is a running total. " + + "Start every call before awaiting any of them, and each card's timing becomes the cost of its own service."; + + const string _blockedRendererHint = "Something held the thread Blazor renders on while the services were running. .Wait(), .Result and Task.WaitAll hold the thread until every task finishes, so the page cannot repaint, and in the browser the tab freezes. " + + "Await Task.WhenAll, Task.WhenAny or Task.WhenEach instead."; + + public override int Number => 1; + + public override string Scenario => "Start every call before awaiting any of them"; + + public override string Title => "LoadProductAsync()"; + + public override string Story => "Wireless Headphones is the launch day deal, and its product page takes over four seconds to load. Analytics shows a third of visitors give up before anything appears. " + + "Every backend service it needs answers in 1.2 seconds or less, and none of them needs another one's answer. " + + "The page is not slow because its services are slow. It is slow because it asks them one at a time."; + + public override string SeeItInTheApp => "Reload the browser tab, or press Load product page. Nothing happens for about four seconds while every card says waiting, then four cards appear at the same instant and the total page load tile reads 4.2s. " + + "The card timings read 0.7s, 1.6s, 2.8s and 3.4s. Those are running totals: each card also paid for every service above it."; + + public override string FileToChange => "Components/Pages/Product.razor.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Start all five backend calls before you await any of them.", + "Coordinate them with Task.WhenAll, Task.WhenAny, or Task.WhenEach, and await it rather than blocking on it.", + "Keep the total page load at the cost of the slowest service, not the sum of all five.", + "Keep passing CancellationToken.None to every service call, so the call sites stay ready for a real token.", + "Keep using ConfigureAwait(false) on every await.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "Every await in LoadProductAsync() sits on the same line that starts the call, so the next service cannot start until the previous one has answered.", + "The five services are injected separately and share no state, so nothing about the data forces this order.", + "The slowest single service takes 1.2 seconds, and 1.2 is a lot smaller than 4.2.", + "await does not start work. Calling GetInventoryAsync() starts it. Call all five methods first, keep the five tasks, and await them afterwards.", + ]; + + public override string TimeoutHint => "The Product page never finished loading. Check for a blocking wait such as .Wait(), .Result or Task.WaitAll, and for a Task.WhenAny loop that never removes the task that finished."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + // Part 1: open the Product page the way a browser tab does, with fresh copies of the five services + await using var session = new ProductPageSession(); + + report.Log($"Opening the Product page. Its five services cost {ProductPageSession.AllServicesSeconds:F1}s one after another, and {ProductPageSession.SlowestServiceSeconds:F1}s side by side"); + + var load = await session.OpenPageAsync(token).ConfigureAwait(false); + + if (!load.Finished) + { + report.Log($"Still loading after {ProductPageSession.LoadTimeout.TotalSeconds:F0} seconds. Giving up"); + report.Expect("The Product page finishes loading", $"within {ProductPageSession.LoadTimeout.TotalSeconds:F0} seconds", "still loading", false, _neverFinishedHint); + return; + } + + var seconds = load.Elapsed.TotalSeconds; + + report.Log($"The page finished loading in {seconds:F2}s, and its total page load tile reads {(load.TotalSeconds is { } totalSeconds ? $"{totalSeconds:F1}s" : "nothing")}"); + report.Log($"The cards read: {ProductPageSession.DescribeCards(load.Panels)}"); + + // Part 2: five independent calls side by side cost the slowest one. Faster than that means the page stopped waiting too soon. + report.Expect( + $"The page load costs the slowest service ({ProductPageSession.SlowestServiceSeconds:F1}s), not the sum of all five ({ProductPageSession.AllServicesSeconds:F1}s)", + $"about {ProductPageSession.SlowestServiceSeconds:F1}s, at most {ProductPageSession.LongestPageLoadSeconds:F1}s", + $"{seconds:F1}s", + seconds is >= ProductPageSession.ShortestPageLoadSeconds and <= ProductPageSession.LongestPageLoadSeconds, + seconds < ProductPageSession.ShortestPageLoadSeconds ? _finishedTooEarlyHint : _oneAfterAnotherHint); + + // Part 3: when every call starts at once, no service can answer later than the slowest one, so no card can report a running total + var lateCards = load.Panels.Where(static panel => panel.Seconds > _latestCardSeconds).ToList(); + + report.Expect( + "No card reports a running total, because every service answers by the time the slowest one does", + $"no card later than {_latestCardSeconds:F1}s", + lateCards.Count is 0 ? $"no card later than {_latestCardSeconds:F1}s" : string.Join(", ", lateCards.Select(static panel => $"{panel.Name} at {panel.Seconds:F1}s")), + lateCards.Count is 0, + _runningTotalHint); + + // Part 4: awaiting never holds Blazor's renderer. Blocking on the tasks does, and the page cannot repaint until they finish. + var longestPause = load.LongestRendererPause.TotalSeconds; + + report.Log($"While the page loaded, Blazor's renderer was held up for {longestPause:F2}s at most"); + report.Expect( + "Blazor's renderer is never held up while the page waits for its services", + $"no pause longer than {_longestRendererPauseSeconds:F1}s", + $"longest pause {longestPause:F2}s", + longestPause <= _longestRendererPauseSeconds, + _blockedRendererHint); + } +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Steps/Step2ContainEachFailure.cs b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Steps/Step2ContainEachFailure.cs new file mode 100644 index 0000000..53ee7da --- /dev/null +++ b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Steps/Step2ContainEachFailure.cs @@ -0,0 +1,138 @@ +namespace ProductDetails; + +public sealed class Step2ContainEachFailure : WorkshopStep +{ + const string _failingCard = "Recommendations"; + + const string _neverFinishedHint = "The page never finished loading, and nothing threw. Check that every call's error handling completes, and that nothing blocks on a task with .Wait(), .Result or Task.WaitAll."; + + const string _failedStatusHint = "The Recommendations service always throws HttpRequestException, and that failure belongs to its card alone. Catch it around that one call and record the card with the status failed, which Product.razor draws in the failure style. " + + "A card that reads skipped was jumped over by a catch it shares with other calls."; + + const string _messageHint = "Put a fixed message on the failed card that tells the customer what happened and what to do next. " + + "The exception's own message describes the backend, and it belongs in the log, not in the browser."; + + const string _otherCardsHint = "One failure is still stopping cards that have nothing to do with it. Give every service call its own error handling, so a failure can only reach the card it belongs to. " + + "How much of the page survives should not depend on where a call sits in the method."; + + const string _bannerHint = "Every failure now belongs to a card, so the page as a whole has nothing left to report. Stop setting PageError when one service fails, so the yellow banner never appears."; + + const string _exceptionTextHint = "The page shows part of the exception. Anything the page renders reaches the browser, so show a message the page owns and keep the exception in the log."; + + const string _loggingHint = "The terminal is where a failure can be acted on, so it needs the whole exception. Pass the HttpRequestException itself to Logger.LogError, the way the starter does, not just its message."; + + // Words from the exception RecommendationsService throws. None of them belongs in the browser. + static readonly string[] _exceptionText = ["503", "Service Unavailable", nameof(HttpRequestException)]; + + public override int Number => 2; + + public override string Scenario => "Let one failing service break only its own card"; + + public override string Title => "LoadProductAsync() and PageError"; + + public override string Story => "The recommendations engine belongs to another team, and it is down again: every call returns 503 Service Unavailable. " + + "Nobody expects the product page to show recommendations while it is down. They do expect to see the price, the stock and the delivery date. " + + "One shared try block turns one broken dependency into a broken page, and how much of the page dies is decided by where the call sits in the method, not by what failed."; + + public override string SeeItInTheApp => "On the Product page, the Recommendations card reads -- and never requested, and a yellow banner says the page load stopped. The banner does not say which service failed. " + + "The terminal running the app does: the logged HttpRequestException reads Recommendations service returned 503 Service Unavailable. " + + "If you already start every call at once inside that same try block, the other four cards may read never requested too."; + + public override string FileToChange => "Components/Pages/Product.razor.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Give each call its own error handling, so a failure is recorded against one card.", + "Catch HttpRequestException, the exception a failing HTTP dependency throws, rather than every exception.", + "Set the failing card's status to failed and put a message the page owns on that card.", + "Keep logging the full exception server-side the way the starter already does, and keep exception text out of the browser.", + "Stop reporting a single card's failure as a failure of the whole page.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "One catch (HttpRequestException) covers all five calls, so the first failure skips everything after it.", + "Move the recommendations call above the reviews call and two more cards go blank. How much of the page dies is decided by the shape of the code, not by the failure.", + "Awaiting Task.WhenAll rethrows one of the failures, but only after every task has finished, and only once for the whole group.", + "A small async method that takes one already started Task can await it inside its own try block and record the result, or the failure, on that one card.", + ]; + + public override string TimeoutHint => "The Product page never finished loading. Check that every call's error handling completes, and that nothing blocks on a task."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + // Part 1: open the Product page. The Recommendations service is down and always throws HttpRequestException. + await using var session = new ProductPageSession(); + + report.Log("Opening the Product page. The Recommendations service is down, and every call to it throws HttpRequestException"); + + var load = await session.OpenPageAsync(token).ConfigureAwait(false); + + if (!load.Finished) + { + report.Log($"Still loading after {ProductPageSession.LoadTimeout.TotalSeconds:F0} seconds. Giving up"); + report.Expect("The Product page finishes loading", $"within {ProductPageSession.LoadTimeout.TotalSeconds:F0} seconds", "still loading", false, _neverFinishedHint); + return; + } + + report.Log($"The page finished loading in {load.Elapsed.TotalSeconds:F2}s"); + report.Log($"The cards read: {ProductPageSession.DescribeCards(load.Panels)}"); + report.Log(load.PageError is null ? "There is no warning banner" : $"The warning banner reads: {load.PageError}"); + + // Part 2: the service that is down damages its own card + var failingPanel = load.FindPanel(_failingCard); + + report.Expect($"The {_failingCard} card reads failed", "failed", failingPanel?.Status ?? "missing", _failedStatusHint); + + var message = failingPanel?.Detail; + + report.Expect( + "The failed card shows a message the page owns", + "a message with no exception text", + string.IsNullOrWhiteSpace(message) ? "no message" : message, + !string.IsNullOrWhiteSpace(message) && !ContainsExceptionText(message), + _messageHint); + + // Part 3: and only its own card + var otherPanels = load.Panels.Where(static panel => panel.Name is not _failingCard).ToList(); + + report.Expect( + $"Inventory, Pricing, Reviews and Shipping still load while {_failingCard} is down", + string.Join(", ", otherPanels.Select(static panel => $"{panel.Name} ready")), + string.Join(", ", otherPanels.Select(static panel => $"{panel.Name} {panel.Status}")), + otherPanels.Count is 4 && otherPanels.All(static panel => panel.Status is "ready"), + _otherCardsHint); + + report.Expect( + "The page itself does not fail, so the yellow warning banner is gone", + "no banner", + load.PageError is null ? "no banner" : $"a banner that reads \"{load.PageError}\"", + load.PageError is null, + _bannerHint); + + // Part 4: the whole exception goes to the log, and none of it reaches the browser + var leakedText = _exceptionText.Where(text => load.Html.Contains(text, StringComparison.OrdinalIgnoreCase)).ToList(); + + report.Expect( + "No exception text reaches the browser", + "none on the page", + leakedText.Count is 0 ? "none on the page" : $"the page shows {string.Join(", ", leakedText)}", + leakedText.Count is 0, + _exceptionTextHint); + + var loggedFailure = session.Logger.Messages.FirstOrDefault(static logged => logged.Level >= LogLevel.Error && (logged.Exception is HttpRequestException || logged.Exception?.InnerException is HttpRequestException)); + + report.Log(loggedFailure is null ? "The page logged no HttpRequestException" : $"The page logged at {loggedFailure.Level}: {loggedFailure.Message}"); + report.Expect( + "The full HttpRequestException is logged server-side", + "logged at Error, with the exception", + loggedFailure is null ? "not logged" : $"logged at {loggedFailure.Level}, with the exception", + loggedFailure is not null, + _loggingHint); + } + + static bool ContainsExceptionText(string text) => _exceptionText.Any(exceptionText => text.Contains(exceptionText, StringComparison.OrdinalIgnoreCase)); +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Steps/Step3PaintEachCard.cs b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Steps/Step3PaintEachCard.cs new file mode 100644 index 0000000..0aaeb76 --- /dev/null +++ b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Steps/Step3PaintEachCard.cs @@ -0,0 +1,169 @@ +namespace ProductDetails; + +public sealed class Step3PaintEachCard : WorkshopStep +{ + // Five services answer at five different moments, so a page that paints each card as it arrives passes through 1, 2, 3 and 4 answered cards on the way to 5. + // Asking for 3 of those 4 paints leaves room for two services answering within the same instant on a busy laptop. + const int _minimumPartialPaints = 3; + + // A card's timing can run a little late on a busy laptop, but never much earlier than its service could answer + const double _earliestCardOffsetSeconds = 0.1; + const double _latestCardOffsetSeconds = 0.25; + + const string _neverFinishedHint = "The page never finished loading, and nothing threw. If you loop over Task.WhenAny, remove each finished task from the list: a finished task stays finished, so the next call returns it again and the loop never ends. " + + "Check too that nothing blocks on a task with .Wait(), .Result or Task.WaitAll."; + + const string _oneRepaintHint = "Every card still reaches the screen in one paint at the end. SetPanelAsync() records a card, but recording is not repainting, and Task.WhenAll only completes once, when the slowest task does. " + + "Repaint with InvokeAsync(StateHasChanged) each time a service answers: stream the tasks as they finish with Task.WhenEach or Task.WhenAny, or repaint from inside each call's own wrapper."; + + const string _declarationOrderHint = "The page repaints, but not each time a service answers, so some cards reach the screen together. That happens when the page waits for the calls in the order you wrote them rather than the order they finish. " + + "Repaint with InvokeAsync(StateHasChanged) as each task finishes: stream the tasks with Task.WhenEach or Task.WhenAny, or repaint from inside each call's own wrapper."; + + const string _shippingHint = "Shipping answers first, at 0.6s, but it did not reach the screen until Reviews answered at 1.2s. " + + "Handle the calls in the order the services finish, not the order you wrote them, and repaint as each one does."; + + const string _timingHint = "A card's timing should be the moment its own service answered, successful or not. A card that waited for another service reports that service's time instead. " + + "Read the stopwatch when that one call finishes."; + + const string _againHint = "The second load did not match the first. LoadProductAsync() runs again every time the button is pressed, so it has to start fresh calls, reset every card, and time the new load from its own start."; + + const string _isLoadingHint = "IsLoading has to go back to false after every load, including one where a service failed, or the Load product page button stays disabled. Set it through InvokeAsync, the way the starter does."; + + public override int Number => 3; + + public override string Scenario => "Paint each card as its service answers"; + + public override string Title => "LoadProductAsync() and StateHasChanged()"; + + public override string Story => "Customers do not wait for a page. They wait for the part they came for. Shipping knows the delivery date in 0.6 seconds, " + + "but a page that paints once at the end hides it until the slowest service has answered. " + + "The page should fill in card by card, and each card's timing should say when its own service answered, so the numbers on the page tell the truth about the backend."; + + public override string SeeItInTheApp => "On the Product page, press Load product page and watch the cards. They should appear one at a time: Shipping at 0.6s, Inventory at 0.7s, Recommendations failing at 0.8s, Pricing at 0.9s and Reviews at 1.2s. " + + "If they all appear together at the end, the page only repaints once. Press the button a few times: the result should be the same every time."; + + public override string FileToChange => "Components/Pages/Product.razor.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Repaint the page as each service answers instead of once at the end.", + "Keep each card's timing the elapsed time at which that service answered.", + "Keep marshaling each repaint back through InvokeAsync(StateHasChanged), because after ConfigureAwait(false) the code is no longer on Blazor's renderer.", + "Make Load product page give the same result every time, and leave the button enabled when the load finishes.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "InvokeAsync(StateHasChanged) only runs before the work starts and after all of it ends, so nothing repaints in between.", + "SetPanelAsync() records a card on the renderer, but it does not repaint the page.", + "Task.WhenAll completes once, when the slowest task finishes. Task.WhenEach hands you each task the moment that task finishes.", + "Task.WhenAny is the manual version of Task.WhenEach. Remove each finished task from the list, or the next call returns it again.", + ]; + + public override string TimeoutHint => "The Product page never finished loading. If you loop over Task.WhenAny, remove each finished task from the list, or the loop never ends."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + // Part 1: open the Product page and record every time it paints + await using var session = new ProductPageSession(); + + report.Log("Opening the Product page and recording every paint"); + + var load = await session.OpenPageAsync(token).ConfigureAwait(false); + + if (!load.Finished) + { + report.Log($"Still loading after {ProductPageSession.LoadTimeout.TotalSeconds:F0} seconds. Giving up"); + report.Expect("The Product page finishes loading", $"within {ProductPageSession.LoadTimeout.TotalSeconds:F0} seconds", "still loading", false, _neverFinishedHint); + return; + } + + // Blazor can paint the same cards more than once in a row, so only a paint that changed a card is logged + PagePaint? previousPaint = null; + + foreach (var paint in load.Paints) + { + if (previousPaint is null || !paint.Panels.SequenceEqual(previousPaint.Panels)) + { + var answeredPanels = paint.Panels.Where(static panel => panel.Status is not "waiting"); + + report.Log(paint.AnsweredCards is 0 + ? $"Painted at {paint.Elapsed.TotalSeconds:F2}s: every card is waiting" + : $"Painted at {paint.Elapsed.TotalSeconds:F2}s: {paint.AnsweredCards} of {paint.Panels.Count} cards answered. {ProductPageSession.DescribeCards(answeredPanels)}"); + } + + previousPaint = paint; + } + + // Part 2: between the first paint and the last, the page paints each time a service answers + var partialPaints = load.Paints + .Select(static paint => paint.AnsweredCards) + .Where(static answeredCards => answeredCards is > 0 and < 5) + .Distinct() + .Order() + .ToList(); + + report.Expect( + "Cards appear one at a time as their services answer", + $"paints with 1, 2, 3 and 4 cards answered (at least {_minimumPartialPaints} of them)", + partialPaints.Count is 0 ? "no paint between the first and the last" : $"paints with {string.Join(", ", partialPaints)} cards answered", + partialPaints.Count >= _minimumPartialPaints, + partialPaints.Count is 0 ? _oneRepaintHint : _declarationOrderHint); + + // Shipping costs 0.6s and Reviews costs 1.2s, so a page that paints as services answer shows one without the other for 0.6 seconds + var shippingBeforeReviews = load.Paints.FirstOrDefault(static paint => paint.StatusOf("Shipping") is "ready" && paint.StatusOf("Reviews") is "waiting"); + + report.Expect( + "A paint shows Shipping (0.6s) ready while Reviews (1.2s) is still waiting", + "painted", + shippingBeforeReviews is null ? "never painted" : $"painted at {shippingBeforeReviews.Elapsed.TotalSeconds:F1}s", + shippingBeforeReviews is not null, + _shippingHint); + + // Part 3: each card's timing is its own service's cost + var timings = ProductPageSession.ServiceLatencies + .Select(service => (service.Name, Expected: service.Seconds, Actual: load.FindPanel(service.Name)?.Seconds)) + .ToList(); + + report.Expect( + "Each card's timing is when its own service answered", + string.Join(", ", timings.Select(static timing => $"{timing.Name} {timing.Expected:F1}s")), + string.Join(", ", timings.Select(static timing => timing.Actual is { } actual ? $"{timing.Name} {actual:F1}s" : $"{timing.Name} --")), + timings.All(static timing => timing.Actual >= timing.Expected - _earliestCardOffsetSeconds && timing.Actual <= timing.Expected + _latestCardOffsetSeconds), + _timingHint); + + // Part 4: press Load product page, and get the same page again + report.Log("Pressing Load product page"); + + var again = await session.PressLoadProductPageAsync(token).ConfigureAwait(false); + + if (!again.Finished) + { + report.Log($"Still loading after {ProductPageSession.LoadTimeout.TotalSeconds:F0} seconds. Giving up"); + report.Expect("Pressing Load product page finishes loading", $"within {ProductPageSession.LoadTimeout.TotalSeconds:F0} seconds", "still loading", false, _neverFinishedHint); + return; + } + + var againSeconds = again.Elapsed.TotalSeconds; + + report.Log($"The page finished loading again in {againSeconds:F2}s, after {again.Paints.Count} paints"); + report.Log($"The cards read: {ProductPageSession.DescribeCards(again.Panels)}"); + + var sameCards = load.Panels.Select(static panel => (panel.Name, panel.Status, panel.Detail)) + .SequenceEqual(again.Panels.Select(static panel => (panel.Name, panel.Status, panel.Detail))); + + report.Expect( + "Pressing Load product page again gives the same cards in the same time", + $"{DescribeStatuses(load.Panels)}, in about {ProductPageSession.SlowestServiceSeconds:F1}s", + $"{DescribeStatuses(again.Panels)}, in {againSeconds:F1}s", + sameCards && againSeconds is >= ProductPageSession.ShortestPageLoadSeconds and <= ProductPageSession.LongestPageLoadSeconds, + _againHint); + + report.Expect("IsLoading is false again when the load finishes, so the button is enabled", false, again.IsLoading, _isLoadingHint); + } + + static string DescribeStatuses(IEnumerable panels) => string.Join(", ", panels.Select(static panel => $"{panel.Name} {panel.Status}")); +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Steps/StepContext.cs b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Steps/StepContext.cs new file mode 100644 index 0000000..2c9dd12 --- /dev/null +++ b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Steps/StepContext.cs @@ -0,0 +1,4 @@ +namespace ProductDetails; + +// Token is cancelled when the run is stopped, times out, or the app shuts down +public sealed record StepContext(StepReport Report, CancellationToken Token); \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Steps/WorkshopStep.cs b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Steps/WorkshopStep.cs new file mode 100644 index 0000000..4a054a3 --- /dev/null +++ b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Steps/WorkshopStep.cs @@ -0,0 +1,37 @@ +namespace ProductDetails; + +// Every step is one thing wrong with how the Product page waits for its five backend services. +// Its tab in the workshop guide tells you what to fix, and Run() renders your Product page the way a browser tab does, logs what happens, +// and records an expected result, with a hint, for everything that should be true once the bug is fixed. +public abstract class WorkshopStep +{ + public abstract int Number { get; } + + // What this step fixes, in a few words + public abstract string Scenario { get; } + + // The members this step exercises + public abstract string Title { get; } + + // What goes wrong in production, and what it costs + public abstract string Story { get; } + + // How to watch this bug happen on the Product page + public abstract string SeeItInTheApp { get; } + + // Where the bug lives + public abstract string FileToChange { get; } + + // What this step asks you to change. Some of it cannot be measured from outside, so not every task has an expected result. + public abstract IReadOnlyList Tasks { get; } + + // Shown only when you ask for them + public abstract IReadOnlyList Clues { get; } + + // What is most likely waiting forever when this step does not finish in time + public abstract string TimeoutHint { get; } + + public string SourceFile => $"Steps/{GetType().Name}.cs"; + + public abstract Task Run(StepContext context); +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/ActiveStepRun.cs b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/ActiveStepRun.cs new file mode 100644 index 0000000..9251bc0 --- /dev/null +++ b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/ActiveStepRun.cs @@ -0,0 +1,3 @@ +namespace ProductDetails; + +public sealed record ActiveStepRun(WorkshopStep Step, StepReport Report); \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/LoggedMessage.cs b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/LoggedMessage.cs new file mode 100644 index 0000000..5d5ee26 --- /dev/null +++ b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/LoggedMessage.cs @@ -0,0 +1,3 @@ +namespace ProductDetails; + +public sealed record LoggedMessage(LogLevel Level, string Message, Exception? Exception); \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/PageLoad.cs b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/PageLoad.cs new file mode 100644 index 0000000..40701ea --- /dev/null +++ b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/PageLoad.cs @@ -0,0 +1,18 @@ +namespace ProductDetails; + +// One load of the Product page, as a step saw it. +// Elapsed is the wall clock time from the start of the load until the page finished loading. LongestRendererPause is the longest Blazor's renderer was held up while it loaded. +// Panels, PageError, TotalSeconds, IsLoading and Html are read from the page after it finished loading, and are empty when it never did. +public sealed record PageLoad( + bool Finished, + TimeSpan Elapsed, + TimeSpan LongestRendererPause, + IReadOnlyList Paints, + IReadOnlyList Panels, + string? PageError, + double? TotalSeconds, + bool IsLoading, + string Html) +{ + public PanelState? FindPanel(string name) => Panels.FirstOrDefault(panel => panel.Name == name); +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/PageLogRecorder.cs b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/PageLogRecorder.cs new file mode 100644 index 0000000..6ecaebe --- /dev/null +++ b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/PageLogRecorder.cs @@ -0,0 +1,37 @@ +using ProductDetails.Components.Pages; + +namespace ProductDetails; + +// Workshop plumbing: stands in for the Product page's ILogger while a step renders it, so the step can check what the page logged server-side. +// The page logs from thread pool threads while the step reads the messages, so every member takes the lock. +public sealed class PageLogRecorder : ILogger +{ + readonly Lock _lock = new(); + readonly List _messages = []; + + public IReadOnlyList Messages + { + get + { + lock (_lock) + { + return [.. _messages]; + } + } + } + + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + var message = new LoggedMessage(logLevel, formatter(state, exception), exception); + + lock (_lock) + { + _messages.Add(message); + } + } +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/PagePaint.cs b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/PagePaint.cs new file mode 100644 index 0000000..1106828 --- /dev/null +++ b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/PagePaint.cs @@ -0,0 +1,9 @@ +namespace ProductDetails; + +// One time the Product page painted, with every card exactly as that paint drew it +public sealed record PagePaint(TimeSpan Elapsed, IReadOnlyList Panels) +{ + public int AnsweredCards => Panels.Count(static panel => panel.Status is not "waiting"); + + public string? StatusOf(string name) => Panels.FirstOrDefault(panel => panel.Name == name)?.Status; +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/ProductPageProbe.cs b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/ProductPageProbe.cs new file mode 100644 index 0000000..8288e4e --- /dev/null +++ b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/ProductPageProbe.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using ProductDetails.Components.Pages; + +namespace ProductDetails; + +// Workshop plumbing: your Product page exactly as you wrote it, plus a record of every paint. +// Only ProductPageSession renders it. It has no route of its own, so the app never shows it. +public sealed class ProductPageProbe : Product +{ + [Parameter, EditorRequired] + public required ProductPageSession Session { get; set; } + + // Does what clicking Load product page does. Blazor repaints a component as soon as its event handler returns, and again when the Task it returned completes. + public async Task PressLoadProductPageAsync() + { + var load = LoadProductAsync(); + + StateHasChanged(); + + await load.ConfigureAwait(false); + + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + } + + protected override void OnInitialized() => Session.Attach(this); + + // Blazor calls this every time the page paints, so the cards recorded here are exactly the cards that paint draws + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + Session.RecordPaint(Panels); + + base.BuildRenderTree(builder); + } +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/ProductPageSession.cs b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/ProductPageSession.cs new file mode 100644 index 0000000..043c2d1 --- /dev/null +++ b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/ProductPageSession.cs @@ -0,0 +1,215 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.Web.HtmlRendering; +using ProductDetails.Components.Pages; + +namespace ProductDetails; + +// Workshop plumbing: renders your Product page in the background, the way a browser tab would, with fresh copies of the five services. +// It times every load, records every paint, and watches for anything that holds up Blazor's renderer while the page waits. +public sealed class ProductPageSession : IAsyncDisposable +{ + // What the five services cost, from BackendServices.cs: 4.2 seconds one after another, and 1.2 seconds, the slowest one, side by side + public const double SlowestServiceSeconds = 1.2; + public const double AllServicesSeconds = 4.2; + + // A load that answers faster than the slowest service did not wait for it. One that takes longer than 1.6 seconds still waits for some services one after another. + public const double ShortestPageLoadSeconds = 1.1; + public const double LongestPageLoadSeconds = 1.6; + + // Each service's cost, in the order the page shows the cards + public static readonly IReadOnlyList<(string Name, double Seconds)> ServiceLatencies = + [ + ("Inventory", 0.7), + ("Pricing", 0.9), + ("Reviews", 1.2), + ("Shipping", 0.6), + ("Recommendations", 0.8), + ]; + + // The starter takes 4.2 seconds, so a load only runs out of time when something waits forever + public static readonly TimeSpan LoadTimeout = TimeSpan.FromSeconds(8); + + // A load paints about ten times. A page stuck in a loop that repaints can paint millions of times, so stop recording long before that uses up memory. + const int _maximumRecordedPaints = 1_000; + + static readonly TimeSpan _heartbeatInterval = TimeSpan.FromMilliseconds(10); + static readonly TimeSpan _disposeTimeout = TimeSpan.FromSeconds(2); + + readonly Lock _lock = new(); + readonly List _paints = []; + readonly ServiceProvider _serviceProvider; + readonly HtmlRenderer _renderer; + + long _loadStartedAt; + bool _isClosed; + HtmlRootComponent _root; + ProductPageProbe? _page; + + public ProductPageSession() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton>(Logger); + + // Fresh instances, never the singletons the Product page uses + services.AddSingleton(new InventoryService()); + services.AddSingleton(new PricingService()); + services.AddSingleton(new ReviewsService()); + services.AddSingleton(new ShippingService()); + services.AddSingleton(new RecommendationsService()); + + _serviceProvider = services.BuildServiceProvider(); + _renderer = new HtmlRenderer(_serviceProvider, _serviceProvider.GetRequiredService()); + } + + // Everything the page logged while this session rendered it + public PageLogRecorder Logger { get; } = new(); + + public static string DescribeCards(IEnumerable panels) => + string.Join(", ", panels.Select(static panel => panel.Seconds is { } seconds ? $"{panel.Name} {panel.Status} at {seconds:F1}s" : $"{panel.Name} {panel.Status}")); + + public async ValueTask DisposeAsync() + { + // From here on, a page that is still repainting gets an exception instead of a paint, which ends a load that would otherwise loop forever + lock (_lock) + { + _isClosed = true; + } + + // A page that still holds the renderer can keep it from ever disposing, so stop waiting after a moment and leave it to the garbage collector + try + { + await _renderer.DisposeAsync().AsTask().WaitAsync(_disposeTimeout).ConfigureAwait(false); + await _serviceProvider.DisposeAsync().ConfigureAwait(false); + } + catch (TimeoutException) + { + } + } + + // Opens the page, the way a browser tab does, which runs OnInitializedAsync() and loads the product + public Task OpenPageAsync(CancellationToken token) => MeasureLoadAsync( + () => + { + var parameters = ParameterView.FromDictionary(new Dictionary { [nameof(ProductPageProbe.Session)] = this }); + + _root = _renderer.BeginRenderingComponent(parameters); + + // Completes when OnInitializedAsync() has finished and the page has painted its result + return _root.QuiescenceTask; + }, + token); + + // Does what clicking Load product page does, on the page OpenPageAsync() opened + public Task PressLoadProductPageAsync(CancellationToken token) => MeasureLoadAsync( + () => _page?.PressLoadProductPageAsync() ?? throw new InvalidOperationException("Open the page before pressing Load product page"), + token); + + public void Attach(ProductPageProbe page) => _page = page; + + public void RecordPaint(IReadOnlyList panels) + { + lock (_lock) + { + ObjectDisposedException.ThrowIf(_isClosed, this); + + if (_paints.Count < _maximumRecordedPaints) + _paints.Add(new PagePaint(Stopwatch.GetElapsedTime(_loadStartedAt), [.. panels])); + } + } + + async Task MeasureLoadAsync(Func load, CancellationToken token) + { + lock (_lock) + { + _paints.Clear(); + _loadStartedAt = Stopwatch.GetTimestamp(); + } + + var loadStartedAt = Stopwatch.GetTimestamp(); + + using var heartbeatCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + var heartbeat = WatchRendererAsync(heartbeatCancellationTokenSource.Token); + + var finished = false; + + try + { + // The page's own code runs on Blazor's renderer, exactly as it does in the browser. + // An idle renderer runs work on the thread that asks for it, so ask from a thread pool thread: a page that blocks forever must not take this step's thread with it. + await Task.Run(() => _renderer.Dispatcher.InvokeAsync(load), token).WaitAsync(LoadTimeout, token).ConfigureAwait(false); + + finished = true; + } + catch (TimeoutException) + { + // Still loading. The step reports it. + } + finally + { + await heartbeatCancellationTokenSource.CancelAsync().ConfigureAwait(false); + } + + var elapsed = Stopwatch.GetElapsedTime(loadStartedAt); + var longestRendererPause = await heartbeat.ConfigureAwait(false); + + List paints; + + lock (_lock) + { + paints = [.. _paints]; + } + + if (!finished || _page is not { } page) + return new PageLoad(false, elapsed, longestRendererPause, paints, [], null, null, true, string.Empty); + + // The page's state is only safe to read on the renderer + return await _renderer.Dispatcher.InvokeAsync(() => new PageLoad( + true, + elapsed, + longestRendererPause, + paints, + [.. page.Panels], + page.PageError, + page.TotalSeconds, + page.IsLoading, + _root.ToHtmlString())).WaitAsync(LoadTimeout, token).ConfigureAwait(false); + } + + // Queues an empty work item on Blazor's renderer every few milliseconds and measures how long each one waits to run. + // An await gives the renderer back straight away. A blocking wait such as .Wait(), .Result or Task.WaitAll holds it until the tasks finish. + async Task WatchRendererAsync(CancellationToken token) + { + var longestPause = TimeSpan.Zero; + + while (true) + { + var queuedAt = Stopwatch.GetTimestamp(); + + try + { + await _renderer.Dispatcher.InvokeAsync(static () => { }).WaitAsync(token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The load ended while this work item was still waiting, so it waited at least this long + return Longest(longestPause, Stopwatch.GetElapsedTime(queuedAt)); + } + + longestPause = Longest(longestPause, Stopwatch.GetElapsedTime(queuedAt)); + + try + { + await Task.Delay(_heartbeatInterval, token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + return longestPause; + } + } + + static TimeSpan Longest(TimeSpan first, TimeSpan second) => first > second ? first : second; + } +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/StepCheck.cs b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/StepCheck.cs new file mode 100644 index 0000000..d683aae --- /dev/null +++ b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/StepCheck.cs @@ -0,0 +1,3 @@ +namespace ProductDetails; + +public sealed record StepCheck(string Description, string Expected, string Actual, bool Passed, string Hint); \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/StepLogEntry.cs b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/StepLogEntry.cs new file mode 100644 index 0000000..9d9eedb --- /dev/null +++ b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/StepLogEntry.cs @@ -0,0 +1,3 @@ +namespace ProductDetails; + +public sealed record StepLogEntry(TimeSpan Elapsed, int ThreadId, bool IsThreadPoolThread, string Message); \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/StepReport.cs b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/StepReport.cs new file mode 100644 index 0000000..5faa35c --- /dev/null +++ b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/StepReport.cs @@ -0,0 +1,82 @@ +using System.Diagnostics; + +namespace ProductDetails; + +// Collects what a step logged and every expected result it checked. +// Steps write to it from thread pool threads while the workshop guide reads it, so every member takes the lock. +public sealed class StepReport +{ + readonly Lock _lock = new(); + readonly Stopwatch _stopwatch = Stopwatch.StartNew(); + readonly List _entries = []; + readonly List _checks = []; + + bool _isComplete; + + public IReadOnlyList Entries + { + get + { + lock (_lock) + { + return [.. _entries]; + } + } + } + + public IReadOnlyList Checks + { + get + { + lock (_lock) + { + return [.. _checks]; + } + } + } + + public void Log(string message) + { + var threadId = Environment.CurrentManagedThreadId; + var isThreadPoolThread = Thread.CurrentThread.IsThreadPoolThread; + + lock (_lock) + { + if (!_isComplete) + _entries.Add(new StepLogEntry(_stopwatch.Elapsed, threadId, isThreadPoolThread, message)); + } + } + + // The hint is shown under the expected result only when it does not match + public void Expect(string description, T expected, T actual, string hint) + { + Expect(description, Format(expected), Format(actual), EqualityComparer.Default.Equals(expected, actual), hint); + } + + public void Expect(string description, string expected, string actual, bool passed, string hint) + { + lock (_lock) + { + if (!_isComplete) + _checks.Add(new StepCheck(description, expected, actual, passed, hint)); + } + } + + // Called when the run ends, including a run that timed out or was stopped while its step is still blocked somewhere. + // Anything that abandoned step writes later is ignored, so the result in the workshop guide can no longer change. + public void Complete() + { + lock (_lock) + { + _isComplete = true; + _stopwatch.Stop(); + } + } + + static string Format(T value) => value switch + { + null => "null", + int or long => $"{value:N0}", + _ => value.ToString() ?? "null", + }; +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/StepState.cs b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/StepState.cs new file mode 100644 index 0000000..113f3cf --- /dev/null +++ b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/StepState.cs @@ -0,0 +1,9 @@ +namespace ProductDetails; + +// ExceptionType is set for Crashed. Only the name is kept: exception messages and stack traces stay in the terminal. +// CompletedAt is when the run that produced this state ended, so the workshop guide can show that a click really ran again. +public sealed record StepState( + StepStatus Status, + StepReport? Report, + string? ExceptionType = null, + DateTimeOffset? CompletedAt = null); \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/StepStatus.cs b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/StepStatus.cs new file mode 100644 index 0000000..49bd53b --- /dev/null +++ b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/StepStatus.cs @@ -0,0 +1,12 @@ +namespace ProductDetails; + +public enum StepStatus +{ + NotRun, + Running, + Passed, + Failed, + TimedOut, + Crashed, + Stopped, +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/StepVerifier.cs b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/StepVerifier.cs new file mode 100644 index 0000000..f6a45ff --- /dev/null +++ b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/Verification/StepVerifier.cs @@ -0,0 +1,230 @@ +namespace ProductDetails; + +// Workshop plumbing: runs the steps against your Product page and keeps their results. +// Every step renders its own copy of the page with its own instances of the services, so a check never disturbs the singletons the Product page uses. +// Only one step runs at a time, so a step that measures how long the page takes never competes with another run for thread pool threads. +public sealed class StepVerifier(ILogger logger, IHostApplicationLifetime applicationLifetime) : BackgroundService +{ + public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); + + readonly Lock _lock = new(); + readonly StepState[] _states = [.. Enumerable.Repeat(new StepState(StepStatus.NotRun, null), 3)]; + + // Both start at 1: the app checks every step on startup, and nothing else may run before that finishes + int _isBusy = 1; + int _isCheckingAllSteps = 1; + + ActiveStepRun? _activeRun; + TaskCompletionSource? _stopRequested; + + public IReadOnlyList Steps { get; } = + [ + new Step1StartEveryCall(), + new Step2ContainEachFailure(), + new Step3PaintEachCard(), + ]; + + public bool IsBusy => Volatile.Read(ref _isBusy) is 1; + + public bool IsCheckingAllSteps => Volatile.Read(ref _isCheckingAllSteps) is 1; + + public ActiveStepRun? ActiveRun + { + get + { + lock (_lock) + { + return _activeRun; + } + } + } + + public WorkshopStep? FindStep(int number) => Steps.FirstOrDefault(step => step.Number == number); + + public StepState GetState(int number) + { + lock (_lock) + { + return _states[number - 1]; + } + } + + public bool IsUnlocked(int number) => number is 1 || GetState(number - 1).Status is StepStatus.Passed; + + // Re-runs every step, the same way startup does + public async Task CheckAllSteps() + { + if (Interlocked.CompareExchange(ref _isBusy, 1, 0) is not 0) + return; + + Volatile.Write(ref _isCheckingAllSteps, 1); + + try + { + await CheckSteps(applicationLifetime.ApplicationStopping).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isCheckingAllSteps, 0); + Volatile.Write(ref _isBusy, 0); + } + } + + public async Task RunStep(WorkshopStep step) + { + if (!IsUnlocked(step.Number) || Interlocked.CompareExchange(ref _isBusy, 1, 0) is not 0) + return GetState(step.Number); + + try + { + return await Run(step, applicationLifetime.ApplicationStopping).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isBusy, 0); + } + } + + public void StopRun() + { + lock (_lock) + { + _stopRequested?.TrySetResult(); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await CheckSteps(stoppingToken).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isCheckingAllSteps, 0); + Volatile.Write(ref _isBusy, 0); + } + } + + async Task CheckSteps(CancellationToken token) + { + try + { + // Results from before the latest change to the page are not trusted + lock (_lock) + { + Array.Fill(_states, new StepState(StepStatus.NotRun, null)); + } + + logger.LogInformation("Checking your Product page against every step. Open the app in your browser: the workshop guide beside the Product page shows what to fix next"); + + foreach (var step in Steps) + { + var state = await Run(step, token).ConfigureAwait(false); + + if (state.Status is not StepStatus.Passed) + { + logger.LogInformation("Stopped checking at Step {StepNumber}. Open it in the app to see what to change, with a hint for every result that did not match. Later steps unlock after it passes", step.Number); + return; + } + } + + logger.LogInformation("Every step passed. Pause here for the group review"); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The app is shutting down + } + catch (Exception e) + { + logger.LogError(e, "Checking the steps failed unexpectedly"); + } + } + + async Task Run(WorkshopStep step, CancellationToken token) + { + var report = new StepReport(); + var stopRequested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var previousState = GetState(step.Number); + + lock (_lock) + { + _activeRun = new ActiveStepRun(step, report); + _stopRequested = stopRequested; + _states[step.Number - 1] = new StepState(StepStatus.Running, report); + } + + // Cancelled when the run ends for any reason, so a step that is still working can stop + using var runCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + + var state = previousState; + + try + { + state = await Execute(step, report, stopRequested.Task, runCancellationTokenSource.Token, token).ConfigureAwait(false); + } + finally + { + await runCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + report.Complete(); + + lock (_lock) + { + _activeRun = null; + _stopRequested = null; + _states[step.Number - 1] = state with { CompletedAt = DateTimeOffset.Now }; + } + } + + return state; + } + + async Task Execute(WorkshopStep step, StepReport report, Task stopRequested, CancellationToken runToken, CancellationToken token) + { + try + { + // A dedicated thread runs the step until its first await. If a deadlock blocks it forever, + // a thread pool thread is not lost for the rest of the app's life. + var stepTask = Task.Factory.StartNew(() => step.Run(new StepContext(report, runToken)), token, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); + + var finished = await Task.WhenAny(stepTask, stopRequested).WaitAsync(Timeout, token).ConfigureAwait(false); + + if (finished == stopRequested) + { + logger.LogInformation("Step {StepNumber} was stopped", step.Number); + return new StepState(StepStatus.Stopped, report); + } + + await stepTask.ConfigureAwait(false); + + var checks = report.Checks; + var passedChecks = checks.Count(static check => check.Passed); + var status = checks.Count > 0 && passedChecks == checks.Count ? StepStatus.Passed : StepStatus.Failed; + + logger.LogInformation("Step {StepNumber} {Result} ({PassedChecks}/{TotalChecks} checks)", step.Number, status is StepStatus.Passed ? "passed" : "failed", passedChecks, checks.Count); + + // The hints belong in the terminal too, next to anything the app logged while the step ran + foreach (var check in checks.Where(static check => !check.Passed)) + { + logger.LogWarning("Step {StepNumber}: {Description}. Expected {Expected}, actual {Actual}. Hint: {Hint}", step.Number, check.Description, check.Expected, check.Actual, check.Hint); + } + + return new StepState(status, report); + } + catch (TimeoutException) + { + logger.LogWarning("Step {StepNumber} did not finish within {Timeout}. {TimeoutHint}", step.Number, Timeout, step.TimeoutHint); + return new StepState(StepStatus.TimedOut, report); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + return new StepState(StepStatus.NotRun, null); + } + catch (Exception e) + { + logger.LogError(e, "Step {StepNumber} threw an unexpected exception", step.Number); + return new StepState(StepStatus.Crashed, report, e.GetType().Name); + } + } +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/wwwroot/app.css b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/wwwroot/app.css index c5a79b2..a7fb00c 100644 --- a/6. Coordinating Multiple Tasks/1. Start/ProductDetails/wwwroot/app.css +++ b/6. Coordinating Multiple Tasks/1. Start/ProductDetails/wwwroot/app.css @@ -1,19 +1,42 @@ +/* ProductDetails: a launch day product page for an electronics store, with the workshop guide docked beside it as a QA sign-off ticket */ + :root { - --canvas: #eef3f1; - --panel: #ffffff; - --panel-strong: #112a2d; - --ink: #18211f; - --muted: #62706d; - --line: #d7e0dd; - --accent: #f46a1f; - --teal: #0f766e; - --good: #0f8a52; - --bad: #cf202f; - --warn: #c59d35; - --shadow: 0 18px 55px rgb(28 45 42 / 14%); + --porcelain: #f6f4ef; + --card: #ffffff; + --line: #e6e1d8; + --ink: #16161a; + --muted: #6a665e; + --ready: #17804a; + --ready-soft: #e7f5ec; + --failed: #c5302b; + --failed-soft: #fff4f2; + --failed-line: #f2cfca; + --waiting: #a19c92; + --qa-canvas: #f3f4f9; + --qa-surface: #ffffff; + --qa-line: #dde0ea; + --qa-ink: #1b1d2e; + --qa-muted: #585d75; + --indigo: #4f46e5; + --indigo-dark: #3730a3; + --indigo-soft: #eef0ff; + --violet: #7c3aed; + --good: #166534; + --good-soft: #e8f6ed; + --good-line: #b9e0c6; + --bad: #b42318; + --bad-soft: #fdeeed; + --bad-line: #f1c0bb; + --warn: #8a5a00; + --warn-soft: #fff4d6; + --warn-line: #efd48c; + --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif; + --serif: "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif; + --mono: "Cascadia Code", "SF Mono", Menlo, Consolas, monospace; + --guide-width: 460px; color-scheme: light; - font-family: "Aptos", "Avenir Next", "Segoe UI", sans-serif; - background: var(--canvas); + font-family: var(--sans); + background: var(--porcelain); color: var(--ink); } @@ -24,248 +47,1308 @@ body { margin: 0; overflow-x: clip; - background: - linear-gradient(135deg, rgb(15 118 110 / 14%), transparent 32%), - radial-gradient(circle at top right, rgb(244 106 31 / 16%), transparent 30%), - var(--canvas); + background: var(--porcelain); +} + +/* FocusOnNavigate moves focus to the page heading for screen readers; the heading is not interactive, so hide the ring */ +h1:focus { + outline: none; +} + +code { + font-family: var(--mono); + font-size: 0.88em; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; } -.shell { +/* The app and the guide share the screen */ + +.workspace { + display: flex; min-height: 100vh; } -.page { - width: min(1120px, calc(100% - 32px)); +.app-pane { + flex: 1 1 auto; + min-width: 0; + container-type: inline-size; +} + +/* Buttons */ + +button.action { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 14px 26px; + border: 1px solid transparent; + border-radius: 999px; + background: var(--ink); + color: #fff; + font: inherit; + font-weight: 650; + cursor: pointer; +} + +button.action:hover:not(:disabled) { + background: #34343c; +} + +button.action:disabled { + background: #8c8a93; + cursor: not-allowed; +} + +button.action.secondary { + border-color: var(--qa-line); + background: var(--qa-surface); + color: var(--qa-ink); +} + +button.action.secondary:hover:not(:disabled) { + background: var(--qa-canvas); +} + +button.action.small { + padding: 7px 14px; + font-size: 0.86rem; +} + +.spinner { + display: inline-block; + width: 14px; + height: 14px; + margin-right: 8px; + border: 2px solid rgb(255 255 255 / 35%); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.banner .spinner { + border-color: rgb(138 90 0 / 25%); + border-top-color: var(--warn); + vertical-align: -2px; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes shimmer { + from { + background-position: 100% 0; + } + + to { + background-position: -100% 0; + } +} + +/* The store */ + +.store { + width: min(1080px, calc(100% - 56px)); margin: 0 auto; - padding: 32px 0 56px; + padding: 18px 0 64px; +} + +.store-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 28px; + padding: 6px 0 18px; + border-bottom: 1px solid var(--line); +} + +.store-logo { + font-size: 1.1rem; + font-weight: 750; + letter-spacing: -0.01em; +} + +.store-logo-mark { + color: var(--indigo); } -.hero-panel { +.store-nav { + display: flex; + flex: 1; + gap: 22px; + color: var(--muted); + font-size: 0.9rem; +} + +.store-event { + padding: 5px 12px; + border: 1px solid var(--ink); + border-radius: 999px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.product-hero { display: grid; - grid-template-columns: 1fr auto; - gap: 28px; - align-items: end; - margin-bottom: 22px; - padding: 30px; - border: 1px solid rgb(36 35 31 / 10%); - border-radius: 8px; - background: linear-gradient(135deg, var(--panel-strong), #15463e 58%, #6f3b17); - box-shadow: var(--shadow); - color: #f8fffc; + grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr); + gap: 44px; + align-items: center; + margin: 24px 0 0; } -.hero-copy { - max-width: 680px; +/* The product shot is drawn with CSS, so the page needs no images and works offline */ + +.product-stage { + position: relative; + display: grid; + place-items: center; + aspect-ratio: 1 / 0.8; + overflow: hidden; + border-radius: 32px; + background: + radial-gradient(circle at 50% 46%, rgb(255 255 255 / 95%) 0 20%, transparent 56%), + radial-gradient(circle at 12% 92%, rgb(255 196 164 / 75%), transparent 46%), + radial-gradient(circle at 90% 6%, rgb(190 198 255 / 85%), transparent 50%), + linear-gradient(160deg, #eceefb, #f8ebe2); } -.eyebrow { - margin: 0 0 6px; +.product-stage::after { + content: ""; + position: absolute; + left: 24%; + right: 24%; + bottom: 12%; + height: 7%; + border-radius: 50%; + background: radial-gradient(closest-side, rgb(36 32 64 / 30%), transparent); +} + +.headphones { + position: relative; + z-index: 1; + width: 56%; + aspect-ratio: 1 / 0.9; + margin-bottom: 8%; + container-type: inline-size; +} + +.headphones-band { + position: absolute; + top: 0; + left: 10cqi; + right: 10cqi; + height: 60cqi; + border: 6.5cqi solid #2b2b34; + border-bottom: 0; + border-radius: 40cqi 40cqi 0 0; +} + +.headphones-cup { + position: absolute; + bottom: 0; + width: 30cqi; + height: 44cqi; + border-radius: 14cqi; + background: linear-gradient(160deg, #4d4d59, #17171d 72%); + box-shadow: + inset 1.5cqi 1.5cqi 3cqi rgb(255 255 255 / 14%), + 0 5cqi 9cqi rgb(30 24 60 / 28%); +} + +.headphones-cup::before { + content: ""; + position: absolute; + top: 6cqi; + bottom: 6cqi; + width: 9cqi; + border-radius: 6cqi; + background: linear-gradient(#ddd3c8, #b8ada1); +} + +.headphones-cup::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 10cqi; + height: 10cqi; + border: 1cqi solid rgb(255 255 255 / 22%); + border-radius: 50%; + translate: -50% -50%; +} + +.headphones-cup.left { + left: 0; +} + +.headphones-cup.left::before { + right: -3.5cqi; +} + +.headphones-cup.right { + right: 0; +} + +.headphones-cup.right::before { + left: -3.5cqi; +} + +.product-eyebrow { + margin: 0 0 12px; + color: var(--indigo); font-size: 0.78rem; - font-weight: 600; - letter-spacing: 0.12em; + font-weight: 700; + letter-spacing: 0.14em; text-transform: uppercase; - color: rgb(248 255 252 / 72%); } -.hero-panel h1 { - margin: 0 0 10px; - font-size: clamp(1.8rem, 3vw, 2.5rem); - line-height: 1.1; +.product-copy h1 { + margin: 0 0 12px; + font-family: var(--serif); + font-size: clamp(2.3rem, 5cqi, 3.5rem); + font-weight: 600; + line-height: 1; + letter-spacing: -0.02em; } -.hero-summary { +.product-summary { + max-width: 44ch; margin: 0; - color: rgb(248 255 252 / 80%); + color: var(--muted); + font-size: 1.05rem; + line-height: 1.55; } -.hero-actions { +.product-price { display: grid; - gap: 10px; - justify-items: end; + gap: 6px; + margin: 18px 0 20px; + padding: 12px 0; + border-block: 1px solid var(--line); } -button.action { - padding: 12px 22px; - border: 0; - border-radius: 6px; - background: var(--accent); - color: #fff; - font: inherit; - font-weight: 600; - cursor: pointer; +.product-price-label { + color: var(--muted); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; } -button.action:hover:not(:disabled) { - background: #d4560f; +.product-price-value { + font-size: clamp(1.5rem, 3.2cqi, 2.1rem); + font-weight: 700; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + animation: arrive 0.45s ease-out; } -button.action:disabled { - background: var(--muted); - cursor: progress; +.product-price-skeleton { + display: block; + width: min(320px, 70%); + height: 2.1rem; + border-radius: 10px; + background: linear-gradient(90deg, #ebe7df 25%, #f8f6f2 50%, #ebe7df 75%); + background-size: 200% 100%; + animation: shimmer 1.4s linear infinite; } -button.action.secondary { - background: rgb(248 255 252 / 14%); - border: 1px solid rgb(248 255 252 / 30%); +.product-price-missing { + color: var(--failed); + font-size: 1.1rem; + font-weight: 650; } -button.action.secondary:hover:not(:disabled) { - background: rgb(248 255 252 / 24%); +.product-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 14px 22px; } -.panel { - margin-bottom: 16px; - padding: 22px 24px; - border: 1px solid var(--line); - border-radius: 8px; - background: var(--panel); - box-shadow: 0 10px 26px rgb(28 45 42 / 8%); +.status-tile { + display: grid; + padding-left: 22px; + border-left: 1px solid var(--line); } -.panel h2 { - margin: 0 0 4px; - font-size: 1.15rem; +.status-value { + font-size: 1.6rem; + font-weight: 750; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} + +.status-label { + color: var(--muted); + font-size: 0.82rem; +} + +.warning-banner { + margin: 22px 0 0; + padding: 12px 18px 12px 22px; + border: 1px solid #edcf6f; + border-radius: 16px; + background: #fff5cf; + box-shadow: inset 5px 0 0 #e2a90b; + color: #573f00; +} + +.warning-banner p { + margin: 4px 0 0; + line-height: 1.5; +} + +/* The five service panels */ + +.panels { + margin-top: 28px; +} + +.panels-heading { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 6px 28px; +} + +.panels-heading h2 { + margin: 0; + font-family: var(--serif); + font-size: 1.6rem; + font-weight: 600; } -.panel-hint { - margin: 0 0 16px; +.panels-hint { + max-width: 62ch; + margin: 0; color: var(--muted); font-size: 0.92rem; + line-height: 1.5; } .result-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); - gap: 12px; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 14px; + margin-top: 18px; } .result-card { + --rail: var(--waiting); + position: relative; display: grid; - gap: 4px; - padding: 14px 16px; + grid-column: span 2; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + "label timing" + "value value" + "note note"; + align-content: start; + column-gap: 10px; + min-height: 118px; + padding: 16px 20px 16px 26px; border: 1px solid var(--line); - border-left: 4px solid var(--muted); - border-radius: 6px; - background: #fbfdfc; + border-radius: 20px; + background: var(--card); + box-shadow: + 0 1px 2px rgb(40 30 20 / 5%), + 0 12px 28px rgb(40 30 20 / 6%); } -.result-card.ok { - border-left-color: var(--good); +/* The card's left edge shows its state: grey while waiting, green when ready, red when it failed or was never requested */ +.result-card::after { + content: ""; + position: absolute; + top: 16px; + bottom: 16px; + left: -1px; + width: 5px; + border-radius: 0 5px 5px 0; + background: var(--rail); } -.result-card.bad { - border-left-color: var(--bad); +.result-card:nth-child(n + 4) { + grid-column: span 3; } .result-card.pending { - border-left-color: var(--warn); + background: linear-gradient(100deg, var(--card) 35%, #f4f1eb 50%, var(--card) 65%); + background-size: 250% 100%; + animation: shimmer 1.6s linear infinite; +} + +.result-card.ok { + --rail: var(--ready); + animation: arrive-ready 0.6s ease-out; +} + +.result-card.bad { + --rail: var(--failed); + border-color: var(--failed-line); + background: var(--failed-soft); + animation: arrive-failed 0.6s ease-out; } .result-label { - font-size: 0.78rem; - font-weight: 600; - letter-spacing: 0.06em; - text-transform: uppercase; + grid-area: label; + display: flex; + align-items: center; + gap: 8px; color: var(--muted); + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.result-label::before { + content: ""; + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--rail); +} + +.result-card.pending .result-label::before { + animation: pulse 1.2s ease-in-out infinite; } .result-value { + grid-area: value; + margin-top: 12px; + color: var(--waiting); font-size: 1.5rem; font-weight: 700; - font-variant-numeric: tabular-nums; + letter-spacing: -0.01em; +} + +.result-card.bad .result-value { + color: var(--failed); } .result-note { - font-size: 0.85rem; + grid-area: note; + margin-top: 6px; color: var(--muted); + font-size: 0.9rem; + line-height: 1.4; } .result-card.ok .result-note { - color: var(--good); + margin-top: 12px; + color: var(--ink); + font-size: 1.02rem; + font-weight: 600; } .result-card.bad .result-note { - color: var(--bad); + color: #8e231f; } -.timing { +.result-card .result-note.timing { + grid-area: timing; + align-self: start; + margin: 0; + padding: 2px 9px; + border-radius: 999px; + background: var(--ready-soft); + color: var(--ready); + font-size: 0.78rem; + font-weight: 700; font-variant-numeric: tabular-nums; - font-weight: 600; } -.log { - max-height: 260px; +.result-card.bad .result-note.timing { + background: #fde2de; + color: var(--failed); +} + +@keyframes arrive { + from { + opacity: 0; + transform: translateY(4px); + } + + to { + opacity: 1; + transform: none; + } +} + +@keyframes arrive-ready { + from { + background-color: #d9f1e2; + transform: translateY(3px); + } + + to { + background-color: var(--card); + transform: none; + } +} + +@keyframes arrive-failed { + from { + background-color: #fbd9d4; + transform: translateY(3px); + } + + to { + background-color: var(--failed-soft); + transform: none; + } +} + +@keyframes pulse { + 50% { + opacity: 0.3; + } +} + +/* The workshop guide: a QA sign-off ticket, docked beside the product page */ + +.guide { + position: sticky; + top: 0; + flex: 0 0 var(--guide-width); + height: 100vh; overflow-y: auto; + border-left: 1px solid var(--qa-line); + background: var(--qa-canvas); + box-shadow: -18px 0 48px rgb(40 44 90 / 8%); + color: var(--qa-ink); + font-family: var(--sans); + font-size: 0.93rem; +} + +.guide.collapsed { + flex-basis: 76px; + display: grid; + align-content: start; + justify-items: center; + gap: 14px; + padding: 14px 0; + border-top: 4px solid var(--indigo); +} + +.guide-header { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 20px 22px 12px; + background: + linear-gradient(90deg, var(--indigo), var(--violet)) top / 100% 4px no-repeat, + var(--qa-surface); +} + +.guide-eyebrow { + margin: 0 0 6px; + color: var(--qa-muted); + font-size: 0.8rem; + font-weight: 600; +} + +/* The ticket key, shown like an issue tracker breadcrumb */ +.guide-eyebrow::after { + content: " / QA-318"; + color: var(--indigo-dark); + font-family: var(--mono); + font-size: 0.78rem; +} + +.guide-title { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; margin: 0; - padding: 14px 16px; - border: 1px solid var(--line); + font-size: 1.45rem; + font-weight: 700; + letter-spacing: -0.01em; +} + +/* The ticket's status: In QA until every progress segment is done, then Signed off */ +.guide-title::after { + content: "Signed off"; + padding: 3px 9px; + border: 1px solid var(--good-line); border-radius: 6px; - background: #1e2227; - color: #d7dde6; - font-family: "Menlo", "Consolas", monospace; - font-size: 0.82rem; - line-height: 1.6; - white-space: pre-wrap; + background: var(--good-soft); + color: var(--good); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; } -.status-tile { +.guide:has(.progress-segment:not(.done)) .guide-title::after { + content: "In QA"; + border-color: #c9ccf8; + background: var(--indigo-soft); + color: var(--indigo-dark); +} + +.guide-subtitle { + margin: 6px 0 0; + color: var(--qa-muted); + line-height: 1.45; +} + +.guide-toggle { + flex: none; + align-self: start; display: grid; - gap: 4px; - justify-items: end; - padding: 14px 18px; - border: 1px solid rgb(248 255 252 / 22%); + justify-items: center; + gap: 8px; + min-width: 34px; + padding: 5px 8px; + border: 1px solid var(--qa-line); + border-radius: 7px; + background: var(--qa-surface); + color: var(--qa-ink); + font: inherit; + font-size: 1.05rem; + font-weight: 700; + cursor: pointer; +} + +.guide-toggle:hover { + border-color: var(--indigo); + color: var(--indigo-dark); +} + +.guide-rail-label { + color: var(--qa-muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + writing-mode: vertical-rl; +} + +.guide-rail { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.guide-rail-step { + display: grid; + justify-items: center; + gap: 2px; + width: 52px; + padding: 6px 0; + border: 1px solid var(--qa-line); border-radius: 8px; - background: rgb(248 255 252 / 10%); + background: var(--qa-surface); + color: var(--qa-ink); + font: inherit; + cursor: pointer; } -.status-value { - font-size: 1.9rem; +.guide-rail-step:hover { + border-color: var(--indigo); +} + +.guide-rail-number { font-weight: 700; - line-height: 1; - font-variant-numeric: tabular-nums; } -.status-label { - font-size: 0.82rem; - color: rgb(248 255 252 / 78%); +.guide-progress { + display: grid; + gap: 10px; + padding: 4px 22px 16px; + border-bottom: 1px solid var(--qa-line); + background: var(--qa-surface); } -.spinner { +.progress-meter { + display: flex; + gap: 4px; +} + +.progress-segment { + flex: 1; + height: 6px; + border-radius: 3px; + background: #dfe2ec; +} + +.progress-segment.done { + background: #16a34a; +} + +.guide-progress-row { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.guide-progress-count { + margin: 0; + color: var(--qa-muted); +} + +.guide-progress-count strong { + color: var(--qa-ink); + font-size: 1.05rem; +} + +.guide button.action { + border-radius: 7px; + background: var(--indigo); + font-weight: 600; +} + +.guide button.action:hover:not(:disabled) { + background: var(--indigo-dark); +} + +.guide button.action:disabled { + background: #a5a8c0; +} + +.guide button.action.secondary { + border-color: var(--qa-line); + background: var(--qa-surface); + color: var(--qa-ink); +} + +.guide button.action.secondary:hover:not(:disabled) { + background: var(--qa-canvas); +} + +.guide-tabs { + display: grid; + margin: 16px 18px 0; + overflow: hidden; + border: 1px solid var(--qa-line); + border-radius: 10px; + background: var(--qa-surface); +} + +.guide-tab { + display: grid; + grid-template-columns: 26px 1fr auto; + gap: 10px; + align-items: center; + padding: 10px 12px 10px 10px; + border: 0; + border-left: 3px solid transparent; + background: transparent; + color: var(--qa-ink); + font: inherit; + text-align: left; + cursor: pointer; +} + +.guide-tab + .guide-tab { + border-top: 1px solid var(--qa-line); +} + +.guide-tab:hover { + background: #f8f9fd; +} + +.guide-tab.selected { + border-left-color: var(--indigo); + background: var(--indigo-soft); + font-weight: 600; +} + +.guide-tab-number { + display: grid; + place-items: center; + width: 24px; + height: 24px; + border: 1.5px solid #c3c8d8; + border-radius: 6px; + background: var(--qa-surface); + color: var(--qa-muted); + font-size: 0.78rem; + font-weight: 700; +} + +.guide-tab.selected .guide-tab-number { + border-color: var(--indigo); + background: var(--indigo); + color: #fff; +} + +.guide-body { + display: grid; + gap: 14px; + padding: 16px 18px 40px; +} + +.guide-section { + padding: 16px 18px; + border: 1px solid var(--qa-line); + border-radius: 10px; + background: var(--qa-surface); +} + +/* Section headings read like the field labels on a ticket */ +.guide-section h3, +.guide-section h4 { + margin: 0 0 10px; + color: var(--qa-muted); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.guide-section p { + margin: 0 0 10px; + line-height: 1.55; +} + +.guide-section p:last-child { + margin-bottom: 0; +} + +.guide-note { + color: var(--qa-muted); + font-size: 0.87rem; +} + +.step-header { + display: grid; + gap: 10px; + padding: 2px 2px 0; +} + +.step-eyebrow { + margin: 0; + color: var(--indigo-dark); + font-family: var(--mono); + font-size: 0.8rem; + font-weight: 600; +} + +.step-title { + margin: 2px 0 6px; + font-size: 1.3rem; + font-weight: 700; + letter-spacing: -0.01em; + line-height: 1.25; +} + +.step-members { + padding: 1px 6px; + border-radius: 5px; + background: #e7e9f2; + color: var(--qa-ink); + overflow-wrap: anywhere; +} + +.step-actions { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.step-buttons { + display: flex; + gap: 8px; +} + +.how-it-works, +.task-list { + margin: 0 0 12px; + padding-left: 20px; + line-height: 1.55; +} + +.how-it-works li + li, +.task-list li + li { + margin-top: 6px; +} + +.how-it-works { + margin-bottom: 0; +} + +.facts { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 12px; + margin: 0 0 12px; + font-size: 0.9rem; +} + +.facts dt { + color: var(--qa-muted); + font-weight: 600; +} + +.facts dd { + margin: 0; + overflow-wrap: anywhere; +} + +.see-it { + padding: 10px 14px; + border-left: 4px solid var(--indigo); + border-radius: 6px; + background: var(--indigo-soft); +} + +.ai-note { + padding: 10px 14px; + border-left: 4px solid #d99a00; + border-radius: 6px; + background: #fff6dc; + color: #533c00; +} + +.clues { + padding: 10px 14px; + border: 1px dashed #c3c8d8; + border-radius: 8px; + background: #f8f9fc; +} + +.clues summary, +.guide-log summary { + color: var(--indigo-dark); + font-weight: 600; + cursor: pointer; +} + +.clues ul { + margin: 10px 0 0; + padding-left: 20px; + line-height: 1.55; +} + +.guide-log-count { + margin-left: 6px; + color: var(--qa-muted); + font-size: 0.85rem; + font-weight: 400; +} + +/* Step results */ + +.badge { display: inline-block; - width: 14px; - height: 14px; - margin-right: 8px; - border: 2px solid rgb(98 112 109 / 35%); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; - vertical-align: -2px; + padding: 2px 9px; + border: 1px solid var(--qa-line); + border-radius: 999px; + background: #f1f2f7; + color: var(--qa-muted); + font-size: 0.76rem; + font-weight: 600; + white-space: nowrap; } -@keyframes spin { +.badge-compact { + padding: 0; + border: 0; + background: transparent; + font-size: 0.9rem; +} + +.badge-passed { + border-color: var(--good-line); + background: var(--good-soft); + color: var(--good); +} + +.badge-failed { + border-color: var(--bad-line); + background: var(--bad-soft); + color: var(--bad); +} + +.badge-running { + border-color: var(--warn-line); + background: var(--warn-soft); + color: var(--warn); +} + +.badge-compact.badge-passed, +.badge-compact.badge-failed, +.badge-compact.badge-running { + background: transparent; +} + +.banner { + display: grid; + justify-items: start; + gap: 6px; + padding: 12px 16px; + border: 1px solid var(--qa-line); + border-radius: 10px; + background: var(--qa-surface); + line-height: 1.5; +} + +.banner-passed { + border-color: var(--good-line); + background: var(--good-soft); + box-shadow: inset 4px 0 0 #16a34a; + color: #14532d; +} + +.banner-failed { + border-color: var(--bad-line); + background: var(--bad-soft); + box-shadow: inset 4px 0 0 #d92d20; + color: #8f1d15; +} + +.banner-running { + border-color: var(--warn-line); + background: var(--warn-soft); + color: #6b4500; +} + +.banner-locked { + background: #f7f8fb; + color: var(--qa-muted); +} + +.banner-note { + font-size: 0.82rem; + opacity: 0.85; +} + +/* @key recreates the banner for every new result, so this animation plays again after each click */ +.banner-fresh { + animation: banner-in 0.35s ease-out; +} + +@keyframes banner-in { + from { + opacity: 0; + transform: translateY(-6px); + } + to { - transform: rotate(360deg); + opacity: 1; + transform: none; } } -.warning-banner { - margin-bottom: 16px; - padding: 14px 18px; - border: 1px solid rgb(197 157 53 / 45%); - border-left: 4px solid var(--warn); +.link-button { + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + font-weight: 700; + text-decoration: underline; + text-underline-offset: 3px; + cursor: pointer; +} + +.check-list { + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.check { + display: grid; + grid-template-columns: 24px 1fr; + gap: 8px; + padding: 10px 12px; + border: 1px solid var(--qa-line); + border-radius: 8px; +} + +.check-failed { + border-color: var(--bad-line); + background: #fff8f7; + box-shadow: inset 3px 0 0 #d92d20; +} + +.check-body { + min-width: 0; +} + +.guide-section .check-description { + margin: 0 0 6px; + font-weight: 600; + line-height: 1.4; +} + +.check-values { + display: grid; + grid-template-columns: max-content 1fr; + gap: 2px 10px; + margin: 0; + font-size: 0.86rem; +} + +.check-values dt { + color: var(--qa-muted); +} + +.check-values dd { + margin: 0; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} + +.guide-section .check-hint { + margin: 8px 0 0; + padding: 8px 10px; + border-left: 3px solid #d92d20; border-radius: 6px; - background: #fdf8e9; - color: #6b551a; + background: #fde6e3; + color: #761a12; + font-size: 0.88rem; + line-height: 1.5; +} + +.table-scroll { + margin-top: 10px; + overflow-x: auto; +} + +.step-log { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} + +.step-log th, +.step-log td { + padding: 6px 8px; + border-bottom: 1px solid var(--qa-line); + text-align: left; + vertical-align: top; +} + +.step-log th { + color: var(--qa-muted); +} + +.step-log td:nth-child(-n + 3) { + font-family: var(--mono); + font-variant-numeric: tabular-nums; + white-space: nowrap; } -@media (max-width: 640px) { - .hero-panel { - grid-template-columns: 1fr; - align-items: start; +/* The product page follows the width of its own pane, which changes when the guide collapses */ + +@container (max-width: 760px) { + .product-hero { + grid-template-columns: minmax(0, 1fr); + gap: 24px; + } + + .product-stage { + aspect-ratio: 16 / 10; + } + + .headphones { + width: 38%; + } + + .result-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .result-card, + .result-card:nth-child(n + 4) { + grid-column: auto; + } + + .result-card:last-child { + grid-column: 1 / -1; + } +} + +@container (max-width: 520px) { + .store { + width: calc(100% - 32px); + } + + .store-nav { + display: none; + } + + .store-bar { + justify-content: space-between; + } + + .result-grid { + grid-template-columns: minmax(0, 1fr); + } + + .result-card:last-child { + grid-column: auto; } - .hero-actions, .status-tile { - justify-items: start; + padding-left: 0; + border-left: 0; + } +} + +/* Narrow screens stack the guide under the app */ + +@media (max-width: 1080px) { + :root { + --guide-width: 380px; + } +} + +@media (max-width: 860px) { + .workspace { + flex-direction: column; + } + + .guide, + .guide.collapsed { + position: static; + flex-basis: auto; + height: auto; + border-left: 0; + border-top: 1px solid var(--qa-line); + box-shadow: none; + } + + .guide.collapsed { + grid-auto-flow: column; + justify-content: start; + padding: 12px 16px; + border-top: 4px solid var(--indigo); + } + + .guide.collapsed .guide-rail { + grid-auto-flow: column; + } + + .guide-rail-label { + writing-mode: horizontal-tb; } } diff --git a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Components/Layout/MainLayout.razor b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Components/Layout/MainLayout.razor index 1c2a641..f745fc5 100644 --- a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Components/Layout/MainLayout.razor +++ b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Components/Layout/MainLayout.razor @@ -1,5 +1,9 @@ @inherits LayoutComponentBase -
- @Body -
+
+
+ @Body +
+ + +
diff --git a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Components/Layout/StepBadge.razor b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Components/Layout/StepBadge.razor new file mode 100644 index 0000000..352439e --- /dev/null +++ b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Components/Layout/StepBadge.razor @@ -0,0 +1,10 @@ +@inherits StepBadgeBase + +@if (IsCompact) +{ + @Label +} +else +{ + @Icon @Label +} diff --git a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Components/Layout/StepBadge.razor.cs b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Components/Layout/StepBadge.razor.cs new file mode 100644 index 0000000..979cd80 --- /dev/null +++ b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Components/Layout/StepBadge.razor.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Components; + +namespace ProductDetails.Components.Layout; + +public partial class StepBadgeBase : ComponentBase +{ + [Parameter, EditorRequired] + public StepStatus Status { get; set; } + + [Parameter] + public bool IsUnlocked { get; set; } = true; + + // Only the icon, for the collapsed guide + [Parameter] + public bool IsCompact { get; set; } + + public string Icon => Status is StepStatus.Running ? "⏳" : !IsUnlocked ? "🔒" : Status switch + { + StepStatus.Passed => "✅", + StepStatus.Failed or StepStatus.TimedOut or StepStatus.Crashed => "❌", + _ => "•", + }; + + public string Label => Status is StepStatus.Running ? "Running" : !IsUnlocked ? "Locked" : Status switch + { + StepStatus.Passed => "Passed", + StepStatus.Failed => "Failed", + StepStatus.TimedOut => "Timed out", + StepStatus.Crashed => "Crashed", + StepStatus.Stopped => "Stopped", + _ => "Not run", + }; + + public string CssClass => Status is StepStatus.Running ? "badge-running" : !IsUnlocked ? "badge-locked" : Status switch + { + StepStatus.Passed => "badge-passed", + StepStatus.Failed or StepStatus.TimedOut or StepStatus.Crashed => "badge-failed", + _ => "badge-idle", + }; +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Components/Layout/WorkshopGuide.razor b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Components/Layout/WorkshopGuide.razor new file mode 100644 index 0000000..e2777f8 --- /dev/null +++ b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Components/Layout/WorkshopGuide.razor @@ -0,0 +1,321 @@ +@inherits WorkshopGuideBase + + diff --git a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Components/Layout/WorkshopGuide.razor.cs b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Components/Layout/WorkshopGuide.razor.cs new file mode 100644 index 0000000..11d11d5 --- /dev/null +++ b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Components/Layout/WorkshopGuide.razor.cs @@ -0,0 +1,179 @@ +using Microsoft.AspNetCore.Components; + +namespace ProductDetails.Components.Layout; + +// Workshop plumbing: the guide docked beside the app. It shows your challenge, every step's result, and the step you are working on. +public partial class WorkshopGuideBase : ComponentBase, IAsyncDisposable +{ + // Selecting this shows your challenge instead of a step + public const int ChallengeTab = 0; + + // A run that fails quickly can finish faster than anyone can see a spinner + static readonly TimeSpan _minimumActivityIndicatorTime = TimeSpan.FromMilliseconds(600); + + readonly CancellationTokenSource _disposeCancellationTokenSource = new(); + + Task _polling = Task.CompletedTask; + bool _isStartingCheck; + int? _startingRunStepNumber; + + [Inject] + public required StepVerifier Verifier { get; init; } + + [Inject] + public required ILogger Logger { get; init; } + + public int SelectedTab { get; private set; } = ChallengeTab; + + public bool IsCollapsed { get; private set; } + + public string? ErrorMessage { get; private set; } + + public int PassedStepCount => Verifier.Steps.Count(step => Verifier.GetState(step.Number).Status is StepStatus.Passed); + + public bool IsCheckingSteps => _isStartingCheck || Verifier.IsCheckingAllSteps; + + // The first step that does not pass yet, which is the one to work on next + public WorkshopStep? StoppedAtStep => Verifier.Steps.FirstOrDefault(step => Verifier.GetState(step.Number).Status is not StepStatus.Passed); + + // The last step the check reaches: the first one that did not pass, or the last step when they all passed + public WorkshopStep LastCheckedStep => StoppedAtStep ?? Verifier.Steps[^1]; + + public WorkshopStep? SelectedStep => Verifier.FindStep(SelectedTab); + + // The run lives in the verifier, not in this guide, so a step that is still running shows up again after a reload + public ActiveStepRun? SelectedRun => Verifier.ActiveRun is { } run && run.Step.Number == SelectedTab ? run : null; + + public bool IsSelectedStepRunning => _startingRunStepNumber == SelectedTab || SelectedRun is not null; + + public async ValueTask DisposeAsync() + { + // Only stops refreshing the guide. A step that is running keeps running. + await _disposeCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + await _polling.ConfigureAwait(false); + + _disposeCancellationTokenSource.Dispose(); + + GC.SuppressFinalize(this); + } + + protected override void OnInitialized() + { + _polling = PollVerifier(_disposeCancellationTokenSource.Token); + } + + protected void SelectTab(int tab) + { + SelectedTab = tab; + IsCollapsed = false; + ErrorMessage = null; + } + + protected void ToggleCollapsed() => IsCollapsed = !IsCollapsed; + + protected async Task CheckAllStepsAsync() + { + var token = _disposeCancellationTokenSource.Token; + string? errorMessage = null; + + ErrorMessage = null; + _isStartingCheck = true; + + // Show the activity indicator before the check starts + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + try + { + // Keep the indicator on screen long enough to notice, even when Step 1 fails quickly + await Task.WhenAll(Verifier.CheckAllSteps(), Task.Delay(_minimumActivityIndicatorTime, token)).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The guide was closed while the steps were being checked + return; + } + catch (Exception e) + { + Logger.LogError(e, "Checking every step failed"); + + errorMessage = "Checking the steps failed. The terminal running the app has the details."; + } + + // The continuation is off Blazor's renderer, so every component state change goes back through it + await InvokeAsync(() => + { + ErrorMessage = errorMessage; + _isStartingCheck = false; + + StateHasChanged(); + }).ConfigureAwait(false); + } + + protected async Task RunSelectedStepAsync() + { + if (SelectedStep is not { } step) + return; + + var token = _disposeCancellationTokenSource.Token; + string? errorMessage = null; + + ErrorMessage = null; + _startingRunStepNumber = step.Number; + + // Show the activity indicator before the run starts + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + try + { + // Keep the indicator on screen long enough to notice, even when the step fails quickly + await Task.WhenAll(Verifier.RunStep(step), Task.Delay(_minimumActivityIndicatorTime, token)).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The guide was closed while the step was running + return; + } + catch (Exception e) + { + Logger.LogError(e, "Running Step {StepNumber} failed", step.Number); + + errorMessage = "The step could not be run. The terminal running the app has the details."; + } + + // The continuation is off Blazor's renderer, so every component state change goes back through it + await InvokeAsync(() => + { + ErrorMessage = errorMessage; + _startingRunStepNumber = null; + + StateHasChanged(); + }).ConfigureAwait(false); + } + + protected void StopRun() => Verifier.StopRun(); + + // Re-renders while the verifier is busy, plus once more when it finishes, so the live log and results appear without a refresh + async Task PollVerifier(CancellationToken token) + { + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100)); + var wasBusy = true; + + try + { + while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false)) + { + var isBusy = Verifier.IsBusy; + + if (isBusy || wasBusy) + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + wasBusy = isBusy; + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // Expected when the guide is disposed + } + } +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Components/Pages/Product.razor b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Components/Pages/Product.razor index 12489ab..c4aa423 100644 --- a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Components/Pages/Product.razor +++ b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Components/Pages/Product.razor @@ -1,34 +1,73 @@ @page "/" @inherits ProductPageBase -ProductDetails +Wireless Headphones · ProductDetails -
-
-
-

ProductDetails

-

Wireless Headphones

-

This page needs five independent backend services. Watch how long it takes and when each panel appears.

+
+
+ + + Launch day +
+ +
+ -
- +
- @if (TotalSeconds is not null) - { -
- @TotalSeconds.Value.ToString("F1")s - total page load -
- } +
+ + + @if (TotalSeconds is not null) + { +
+ @TotalSeconds.Value.ToString("F1")s + total page load +
+ } +
-
+
@if (PageError is not null) { @@ -38,9 +77,11 @@ } -
-

Page panels

-

Each panel is filled by its own backend service. The slowest service takes 1.2 seconds, and the recommendations service is down.

+
+
+

Page panels

+

Each panel is filled by its own backend service. The slowest service takes 1.2 seconds, and the recommendations service is down. Watch how long the page takes and when each panel appears.

+
@foreach (var panel in Panels) @@ -74,5 +115,5 @@
}
- - + + diff --git a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Components/Pages/Product.razor.cs b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Components/Pages/Product.razor.cs index 9f20080..4a9d7e1 100644 --- a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Components/Pages/Product.razor.cs +++ b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Components/Pages/Product.razor.cs @@ -105,7 +105,7 @@ protected void ResetPanels() // All five wrappers write here at once, from whichever Thread Pool thread // their own service finished on, while Product.razor renders Panels with a - // foreach. Marshalling the write keeps every mutation on the renderer. + // foreach. Marshaling the write keeps every mutation on the renderer. protected Task SetPanelAsync(string name, string status, string? detail, TimeSpan elapsed) => InvokeAsync(() => { diff --git a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Program.cs b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Program.cs index 032ba13..fc1a1c7 100644 --- a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Program.cs +++ b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Program.cs @@ -18,6 +18,11 @@ public static void Main(string[] args) builder.Services.AddSingleton(); builder.Services.AddSingleton(); + // Workshop plumbing: one StepVerifier holds every step's result. It is also a hosted service, + // so it checks your Product page against every step each time the app starts. + builder.Services.AddSingleton(); + builder.Services.AddHostedService(static serviceProvider => serviceProvider.GetRequiredService()); + var app = builder.Build(); if (!app.Environment.IsDevelopment()) diff --git a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Steps/Step1StartEveryCall.cs b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Steps/Step1StartEveryCall.cs new file mode 100644 index 0000000..c5e60b4 --- /dev/null +++ b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Steps/Step1StartEveryCall.cs @@ -0,0 +1,113 @@ +namespace ProductDetails; + +public sealed class Step1StartEveryCall : WorkshopStep +{ + // Every card has answered by the time the slowest service has, give or take a busy laptop + const double _latestCardSeconds = 1.4; + + // An await hands Blazor's renderer back within milliseconds. A blocking wait holds it until every task has finished. + const double _longestRendererPauseSeconds = 0.3; + + const string _neverFinishedHint = "The page never finished loading, and nothing threw. A blocking wait such as .Wait(), .Result or Task.WaitAll holds the thread Blazor renders on, and SetPanelAsync() needs that same thread to record a card, so they wait for each other forever. " + + "Await Task.WhenAll, Task.WhenAny or Task.WhenEach instead. If you loop over Task.WhenAny, remove each finished task from the list, or the loop never ends."; + + const string _oneAfterAnotherHint = "The services still answer one after another. await does not start work: calling the service method does, and each await here holds up the call below it. " + + "Call all five service methods first, keep the Task each one returns, and only then await them together."; + + const string _finishedTooEarlyHint = "The page stopped loading before its slowest service could have answered. Starting every call is only half of it: " + + "the page still has to await all five, with Task.WhenAll, Task.WhenAny or Task.WhenEach, before it stops loading."; + + const string _runningTotalHint = "These cards waited for the services above them before their own calls even started, so each timing is a running total. " + + "Start every call before awaiting any of them, and each card's timing becomes the cost of its own service."; + + const string _blockedRendererHint = "Something held the thread Blazor renders on while the services were running. .Wait(), .Result and Task.WaitAll hold the thread until every task finishes, so the page cannot repaint, and in the browser the tab freezes. " + + "Await Task.WhenAll, Task.WhenAny or Task.WhenEach instead."; + + public override int Number => 1; + + public override string Scenario => "Start every call before awaiting any of them"; + + public override string Title => "LoadProductAsync()"; + + public override string Story => "Wireless Headphones is the launch day deal, and its product page takes over four seconds to load. Analytics shows a third of visitors give up before anything appears. " + + "Every backend service it needs answers in 1.2 seconds or less, and none of them needs another one's answer. " + + "The page is not slow because its services are slow. It is slow because it asks them one at a time."; + + public override string SeeItInTheApp => "Reload the browser tab, or press Load product page. Nothing happens for about four seconds while every card says waiting, then four cards appear at the same instant and the total page load tile reads 4.2s. " + + "The card timings read 0.7s, 1.6s, 2.8s and 3.4s. Those are running totals: each card also paid for every service above it."; + + public override string FileToChange => "Components/Pages/Product.razor.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Start all five backend calls before you await any of them.", + "Coordinate them with Task.WhenAll, Task.WhenAny, or Task.WhenEach, and await it rather than blocking on it.", + "Keep the total page load at the cost of the slowest service, not the sum of all five.", + "Keep passing CancellationToken.None to every service call, so the call sites stay ready for a real token.", + "Keep using ConfigureAwait(false) on every await.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "Every await in LoadProductAsync() sits on the same line that starts the call, so the next service cannot start until the previous one has answered.", + "The five services are injected separately and share no state, so nothing about the data forces this order.", + "The slowest single service takes 1.2 seconds, and 1.2 is a lot smaller than 4.2.", + "await does not start work. Calling GetInventoryAsync() starts it. Call all five methods first, keep the five tasks, and await them afterwards.", + ]; + + public override string TimeoutHint => "The Product page never finished loading. Check for a blocking wait such as .Wait(), .Result or Task.WaitAll, and for a Task.WhenAny loop that never removes the task that finished."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + // Part 1: open the Product page the way a browser tab does, with fresh copies of the five services + await using var session = new ProductPageSession(); + + report.Log($"Opening the Product page. Its five services cost {ProductPageSession.AllServicesSeconds:F1}s one after another, and {ProductPageSession.SlowestServiceSeconds:F1}s side by side"); + + var load = await session.OpenPageAsync(token).ConfigureAwait(false); + + if (!load.Finished) + { + report.Log($"Still loading after {ProductPageSession.LoadTimeout.TotalSeconds:F0} seconds. Giving up"); + report.Expect("The Product page finishes loading", $"within {ProductPageSession.LoadTimeout.TotalSeconds:F0} seconds", "still loading", false, _neverFinishedHint); + return; + } + + var seconds = load.Elapsed.TotalSeconds; + + report.Log($"The page finished loading in {seconds:F2}s, and its total page load tile reads {(load.TotalSeconds is { } totalSeconds ? $"{totalSeconds:F1}s" : "nothing")}"); + report.Log($"The cards read: {ProductPageSession.DescribeCards(load.Panels)}"); + + // Part 2: five independent calls side by side cost the slowest one. Faster than that means the page stopped waiting too soon. + report.Expect( + $"The page load costs the slowest service ({ProductPageSession.SlowestServiceSeconds:F1}s), not the sum of all five ({ProductPageSession.AllServicesSeconds:F1}s)", + $"about {ProductPageSession.SlowestServiceSeconds:F1}s, at most {ProductPageSession.LongestPageLoadSeconds:F1}s", + $"{seconds:F1}s", + seconds is >= ProductPageSession.ShortestPageLoadSeconds and <= ProductPageSession.LongestPageLoadSeconds, + seconds < ProductPageSession.ShortestPageLoadSeconds ? _finishedTooEarlyHint : _oneAfterAnotherHint); + + // Part 3: when every call starts at once, no service can answer later than the slowest one, so no card can report a running total + var lateCards = load.Panels.Where(static panel => panel.Seconds > _latestCardSeconds).ToList(); + + report.Expect( + "No card reports a running total, because every service answers by the time the slowest one does", + $"no card later than {_latestCardSeconds:F1}s", + lateCards.Count is 0 ? $"no card later than {_latestCardSeconds:F1}s" : string.Join(", ", lateCards.Select(static panel => $"{panel.Name} at {panel.Seconds:F1}s")), + lateCards.Count is 0, + _runningTotalHint); + + // Part 4: awaiting never holds Blazor's renderer. Blocking on the tasks does, and the page cannot repaint until they finish. + var longestPause = load.LongestRendererPause.TotalSeconds; + + report.Log($"While the page loaded, Blazor's renderer was held up for {longestPause:F2}s at most"); + report.Expect( + "Blazor's renderer is never held up while the page waits for its services", + $"no pause longer than {_longestRendererPauseSeconds:F1}s", + $"longest pause {longestPause:F2}s", + longestPause <= _longestRendererPauseSeconds, + _blockedRendererHint); + } +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Steps/Step2ContainEachFailure.cs b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Steps/Step2ContainEachFailure.cs new file mode 100644 index 0000000..53ee7da --- /dev/null +++ b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Steps/Step2ContainEachFailure.cs @@ -0,0 +1,138 @@ +namespace ProductDetails; + +public sealed class Step2ContainEachFailure : WorkshopStep +{ + const string _failingCard = "Recommendations"; + + const string _neverFinishedHint = "The page never finished loading, and nothing threw. Check that every call's error handling completes, and that nothing blocks on a task with .Wait(), .Result or Task.WaitAll."; + + const string _failedStatusHint = "The Recommendations service always throws HttpRequestException, and that failure belongs to its card alone. Catch it around that one call and record the card with the status failed, which Product.razor draws in the failure style. " + + "A card that reads skipped was jumped over by a catch it shares with other calls."; + + const string _messageHint = "Put a fixed message on the failed card that tells the customer what happened and what to do next. " + + "The exception's own message describes the backend, and it belongs in the log, not in the browser."; + + const string _otherCardsHint = "One failure is still stopping cards that have nothing to do with it. Give every service call its own error handling, so a failure can only reach the card it belongs to. " + + "How much of the page survives should not depend on where a call sits in the method."; + + const string _bannerHint = "Every failure now belongs to a card, so the page as a whole has nothing left to report. Stop setting PageError when one service fails, so the yellow banner never appears."; + + const string _exceptionTextHint = "The page shows part of the exception. Anything the page renders reaches the browser, so show a message the page owns and keep the exception in the log."; + + const string _loggingHint = "The terminal is where a failure can be acted on, so it needs the whole exception. Pass the HttpRequestException itself to Logger.LogError, the way the starter does, not just its message."; + + // Words from the exception RecommendationsService throws. None of them belongs in the browser. + static readonly string[] _exceptionText = ["503", "Service Unavailable", nameof(HttpRequestException)]; + + public override int Number => 2; + + public override string Scenario => "Let one failing service break only its own card"; + + public override string Title => "LoadProductAsync() and PageError"; + + public override string Story => "The recommendations engine belongs to another team, and it is down again: every call returns 503 Service Unavailable. " + + "Nobody expects the product page to show recommendations while it is down. They do expect to see the price, the stock and the delivery date. " + + "One shared try block turns one broken dependency into a broken page, and how much of the page dies is decided by where the call sits in the method, not by what failed."; + + public override string SeeItInTheApp => "On the Product page, the Recommendations card reads -- and never requested, and a yellow banner says the page load stopped. The banner does not say which service failed. " + + "The terminal running the app does: the logged HttpRequestException reads Recommendations service returned 503 Service Unavailable. " + + "If you already start every call at once inside that same try block, the other four cards may read never requested too."; + + public override string FileToChange => "Components/Pages/Product.razor.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Give each call its own error handling, so a failure is recorded against one card.", + "Catch HttpRequestException, the exception a failing HTTP dependency throws, rather than every exception.", + "Set the failing card's status to failed and put a message the page owns on that card.", + "Keep logging the full exception server-side the way the starter already does, and keep exception text out of the browser.", + "Stop reporting a single card's failure as a failure of the whole page.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "One catch (HttpRequestException) covers all five calls, so the first failure skips everything after it.", + "Move the recommendations call above the reviews call and two more cards go blank. How much of the page dies is decided by the shape of the code, not by the failure.", + "Awaiting Task.WhenAll rethrows one of the failures, but only after every task has finished, and only once for the whole group.", + "A small async method that takes one already started Task can await it inside its own try block and record the result, or the failure, on that one card.", + ]; + + public override string TimeoutHint => "The Product page never finished loading. Check that every call's error handling completes, and that nothing blocks on a task."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + // Part 1: open the Product page. The Recommendations service is down and always throws HttpRequestException. + await using var session = new ProductPageSession(); + + report.Log("Opening the Product page. The Recommendations service is down, and every call to it throws HttpRequestException"); + + var load = await session.OpenPageAsync(token).ConfigureAwait(false); + + if (!load.Finished) + { + report.Log($"Still loading after {ProductPageSession.LoadTimeout.TotalSeconds:F0} seconds. Giving up"); + report.Expect("The Product page finishes loading", $"within {ProductPageSession.LoadTimeout.TotalSeconds:F0} seconds", "still loading", false, _neverFinishedHint); + return; + } + + report.Log($"The page finished loading in {load.Elapsed.TotalSeconds:F2}s"); + report.Log($"The cards read: {ProductPageSession.DescribeCards(load.Panels)}"); + report.Log(load.PageError is null ? "There is no warning banner" : $"The warning banner reads: {load.PageError}"); + + // Part 2: the service that is down damages its own card + var failingPanel = load.FindPanel(_failingCard); + + report.Expect($"The {_failingCard} card reads failed", "failed", failingPanel?.Status ?? "missing", _failedStatusHint); + + var message = failingPanel?.Detail; + + report.Expect( + "The failed card shows a message the page owns", + "a message with no exception text", + string.IsNullOrWhiteSpace(message) ? "no message" : message, + !string.IsNullOrWhiteSpace(message) && !ContainsExceptionText(message), + _messageHint); + + // Part 3: and only its own card + var otherPanels = load.Panels.Where(static panel => panel.Name is not _failingCard).ToList(); + + report.Expect( + $"Inventory, Pricing, Reviews and Shipping still load while {_failingCard} is down", + string.Join(", ", otherPanels.Select(static panel => $"{panel.Name} ready")), + string.Join(", ", otherPanels.Select(static panel => $"{panel.Name} {panel.Status}")), + otherPanels.Count is 4 && otherPanels.All(static panel => panel.Status is "ready"), + _otherCardsHint); + + report.Expect( + "The page itself does not fail, so the yellow warning banner is gone", + "no banner", + load.PageError is null ? "no banner" : $"a banner that reads \"{load.PageError}\"", + load.PageError is null, + _bannerHint); + + // Part 4: the whole exception goes to the log, and none of it reaches the browser + var leakedText = _exceptionText.Where(text => load.Html.Contains(text, StringComparison.OrdinalIgnoreCase)).ToList(); + + report.Expect( + "No exception text reaches the browser", + "none on the page", + leakedText.Count is 0 ? "none on the page" : $"the page shows {string.Join(", ", leakedText)}", + leakedText.Count is 0, + _exceptionTextHint); + + var loggedFailure = session.Logger.Messages.FirstOrDefault(static logged => logged.Level >= LogLevel.Error && (logged.Exception is HttpRequestException || logged.Exception?.InnerException is HttpRequestException)); + + report.Log(loggedFailure is null ? "The page logged no HttpRequestException" : $"The page logged at {loggedFailure.Level}: {loggedFailure.Message}"); + report.Expect( + "The full HttpRequestException is logged server-side", + "logged at Error, with the exception", + loggedFailure is null ? "not logged" : $"logged at {loggedFailure.Level}, with the exception", + loggedFailure is not null, + _loggingHint); + } + + static bool ContainsExceptionText(string text) => _exceptionText.Any(exceptionText => text.Contains(exceptionText, StringComparison.OrdinalIgnoreCase)); +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Steps/Step3PaintEachCard.cs b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Steps/Step3PaintEachCard.cs new file mode 100644 index 0000000..0aaeb76 --- /dev/null +++ b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Steps/Step3PaintEachCard.cs @@ -0,0 +1,169 @@ +namespace ProductDetails; + +public sealed class Step3PaintEachCard : WorkshopStep +{ + // Five services answer at five different moments, so a page that paints each card as it arrives passes through 1, 2, 3 and 4 answered cards on the way to 5. + // Asking for 3 of those 4 paints leaves room for two services answering within the same instant on a busy laptop. + const int _minimumPartialPaints = 3; + + // A card's timing can run a little late on a busy laptop, but never much earlier than its service could answer + const double _earliestCardOffsetSeconds = 0.1; + const double _latestCardOffsetSeconds = 0.25; + + const string _neverFinishedHint = "The page never finished loading, and nothing threw. If you loop over Task.WhenAny, remove each finished task from the list: a finished task stays finished, so the next call returns it again and the loop never ends. " + + "Check too that nothing blocks on a task with .Wait(), .Result or Task.WaitAll."; + + const string _oneRepaintHint = "Every card still reaches the screen in one paint at the end. SetPanelAsync() records a card, but recording is not repainting, and Task.WhenAll only completes once, when the slowest task does. " + + "Repaint with InvokeAsync(StateHasChanged) each time a service answers: stream the tasks as they finish with Task.WhenEach or Task.WhenAny, or repaint from inside each call's own wrapper."; + + const string _declarationOrderHint = "The page repaints, but not each time a service answers, so some cards reach the screen together. That happens when the page waits for the calls in the order you wrote them rather than the order they finish. " + + "Repaint with InvokeAsync(StateHasChanged) as each task finishes: stream the tasks with Task.WhenEach or Task.WhenAny, or repaint from inside each call's own wrapper."; + + const string _shippingHint = "Shipping answers first, at 0.6s, but it did not reach the screen until Reviews answered at 1.2s. " + + "Handle the calls in the order the services finish, not the order you wrote them, and repaint as each one does."; + + const string _timingHint = "A card's timing should be the moment its own service answered, successful or not. A card that waited for another service reports that service's time instead. " + + "Read the stopwatch when that one call finishes."; + + const string _againHint = "The second load did not match the first. LoadProductAsync() runs again every time the button is pressed, so it has to start fresh calls, reset every card, and time the new load from its own start."; + + const string _isLoadingHint = "IsLoading has to go back to false after every load, including one where a service failed, or the Load product page button stays disabled. Set it through InvokeAsync, the way the starter does."; + + public override int Number => 3; + + public override string Scenario => "Paint each card as its service answers"; + + public override string Title => "LoadProductAsync() and StateHasChanged()"; + + public override string Story => "Customers do not wait for a page. They wait for the part they came for. Shipping knows the delivery date in 0.6 seconds, " + + "but a page that paints once at the end hides it until the slowest service has answered. " + + "The page should fill in card by card, and each card's timing should say when its own service answered, so the numbers on the page tell the truth about the backend."; + + public override string SeeItInTheApp => "On the Product page, press Load product page and watch the cards. They should appear one at a time: Shipping at 0.6s, Inventory at 0.7s, Recommendations failing at 0.8s, Pricing at 0.9s and Reviews at 1.2s. " + + "If they all appear together at the end, the page only repaints once. Press the button a few times: the result should be the same every time."; + + public override string FileToChange => "Components/Pages/Product.razor.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Repaint the page as each service answers instead of once at the end.", + "Keep each card's timing the elapsed time at which that service answered.", + "Keep marshaling each repaint back through InvokeAsync(StateHasChanged), because after ConfigureAwait(false) the code is no longer on Blazor's renderer.", + "Make Load product page give the same result every time, and leave the button enabled when the load finishes.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "InvokeAsync(StateHasChanged) only runs before the work starts and after all of it ends, so nothing repaints in between.", + "SetPanelAsync() records a card on the renderer, but it does not repaint the page.", + "Task.WhenAll completes once, when the slowest task finishes. Task.WhenEach hands you each task the moment that task finishes.", + "Task.WhenAny is the manual version of Task.WhenEach. Remove each finished task from the list, or the next call returns it again.", + ]; + + public override string TimeoutHint => "The Product page never finished loading. If you loop over Task.WhenAny, remove each finished task from the list, or the loop never ends."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + // Part 1: open the Product page and record every time it paints + await using var session = new ProductPageSession(); + + report.Log("Opening the Product page and recording every paint"); + + var load = await session.OpenPageAsync(token).ConfigureAwait(false); + + if (!load.Finished) + { + report.Log($"Still loading after {ProductPageSession.LoadTimeout.TotalSeconds:F0} seconds. Giving up"); + report.Expect("The Product page finishes loading", $"within {ProductPageSession.LoadTimeout.TotalSeconds:F0} seconds", "still loading", false, _neverFinishedHint); + return; + } + + // Blazor can paint the same cards more than once in a row, so only a paint that changed a card is logged + PagePaint? previousPaint = null; + + foreach (var paint in load.Paints) + { + if (previousPaint is null || !paint.Panels.SequenceEqual(previousPaint.Panels)) + { + var answeredPanels = paint.Panels.Where(static panel => panel.Status is not "waiting"); + + report.Log(paint.AnsweredCards is 0 + ? $"Painted at {paint.Elapsed.TotalSeconds:F2}s: every card is waiting" + : $"Painted at {paint.Elapsed.TotalSeconds:F2}s: {paint.AnsweredCards} of {paint.Panels.Count} cards answered. {ProductPageSession.DescribeCards(answeredPanels)}"); + } + + previousPaint = paint; + } + + // Part 2: between the first paint and the last, the page paints each time a service answers + var partialPaints = load.Paints + .Select(static paint => paint.AnsweredCards) + .Where(static answeredCards => answeredCards is > 0 and < 5) + .Distinct() + .Order() + .ToList(); + + report.Expect( + "Cards appear one at a time as their services answer", + $"paints with 1, 2, 3 and 4 cards answered (at least {_minimumPartialPaints} of them)", + partialPaints.Count is 0 ? "no paint between the first and the last" : $"paints with {string.Join(", ", partialPaints)} cards answered", + partialPaints.Count >= _minimumPartialPaints, + partialPaints.Count is 0 ? _oneRepaintHint : _declarationOrderHint); + + // Shipping costs 0.6s and Reviews costs 1.2s, so a page that paints as services answer shows one without the other for 0.6 seconds + var shippingBeforeReviews = load.Paints.FirstOrDefault(static paint => paint.StatusOf("Shipping") is "ready" && paint.StatusOf("Reviews") is "waiting"); + + report.Expect( + "A paint shows Shipping (0.6s) ready while Reviews (1.2s) is still waiting", + "painted", + shippingBeforeReviews is null ? "never painted" : $"painted at {shippingBeforeReviews.Elapsed.TotalSeconds:F1}s", + shippingBeforeReviews is not null, + _shippingHint); + + // Part 3: each card's timing is its own service's cost + var timings = ProductPageSession.ServiceLatencies + .Select(service => (service.Name, Expected: service.Seconds, Actual: load.FindPanel(service.Name)?.Seconds)) + .ToList(); + + report.Expect( + "Each card's timing is when its own service answered", + string.Join(", ", timings.Select(static timing => $"{timing.Name} {timing.Expected:F1}s")), + string.Join(", ", timings.Select(static timing => timing.Actual is { } actual ? $"{timing.Name} {actual:F1}s" : $"{timing.Name} --")), + timings.All(static timing => timing.Actual >= timing.Expected - _earliestCardOffsetSeconds && timing.Actual <= timing.Expected + _latestCardOffsetSeconds), + _timingHint); + + // Part 4: press Load product page, and get the same page again + report.Log("Pressing Load product page"); + + var again = await session.PressLoadProductPageAsync(token).ConfigureAwait(false); + + if (!again.Finished) + { + report.Log($"Still loading after {ProductPageSession.LoadTimeout.TotalSeconds:F0} seconds. Giving up"); + report.Expect("Pressing Load product page finishes loading", $"within {ProductPageSession.LoadTimeout.TotalSeconds:F0} seconds", "still loading", false, _neverFinishedHint); + return; + } + + var againSeconds = again.Elapsed.TotalSeconds; + + report.Log($"The page finished loading again in {againSeconds:F2}s, after {again.Paints.Count} paints"); + report.Log($"The cards read: {ProductPageSession.DescribeCards(again.Panels)}"); + + var sameCards = load.Panels.Select(static panel => (panel.Name, panel.Status, panel.Detail)) + .SequenceEqual(again.Panels.Select(static panel => (panel.Name, panel.Status, panel.Detail))); + + report.Expect( + "Pressing Load product page again gives the same cards in the same time", + $"{DescribeStatuses(load.Panels)}, in about {ProductPageSession.SlowestServiceSeconds:F1}s", + $"{DescribeStatuses(again.Panels)}, in {againSeconds:F1}s", + sameCards && againSeconds is >= ProductPageSession.ShortestPageLoadSeconds and <= ProductPageSession.LongestPageLoadSeconds, + _againHint); + + report.Expect("IsLoading is false again when the load finishes, so the button is enabled", false, again.IsLoading, _isLoadingHint); + } + + static string DescribeStatuses(IEnumerable panels) => string.Join(", ", panels.Select(static panel => $"{panel.Name} {panel.Status}")); +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Steps/StepContext.cs b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Steps/StepContext.cs new file mode 100644 index 0000000..2c9dd12 --- /dev/null +++ b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Steps/StepContext.cs @@ -0,0 +1,4 @@ +namespace ProductDetails; + +// Token is cancelled when the run is stopped, times out, or the app shuts down +public sealed record StepContext(StepReport Report, CancellationToken Token); \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Steps/WorkshopStep.cs b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Steps/WorkshopStep.cs new file mode 100644 index 0000000..4a054a3 --- /dev/null +++ b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Steps/WorkshopStep.cs @@ -0,0 +1,37 @@ +namespace ProductDetails; + +// Every step is one thing wrong with how the Product page waits for its five backend services. +// Its tab in the workshop guide tells you what to fix, and Run() renders your Product page the way a browser tab does, logs what happens, +// and records an expected result, with a hint, for everything that should be true once the bug is fixed. +public abstract class WorkshopStep +{ + public abstract int Number { get; } + + // What this step fixes, in a few words + public abstract string Scenario { get; } + + // The members this step exercises + public abstract string Title { get; } + + // What goes wrong in production, and what it costs + public abstract string Story { get; } + + // How to watch this bug happen on the Product page + public abstract string SeeItInTheApp { get; } + + // Where the bug lives + public abstract string FileToChange { get; } + + // What this step asks you to change. Some of it cannot be measured from outside, so not every task has an expected result. + public abstract IReadOnlyList Tasks { get; } + + // Shown only when you ask for them + public abstract IReadOnlyList Clues { get; } + + // What is most likely waiting forever when this step does not finish in time + public abstract string TimeoutHint { get; } + + public string SourceFile => $"Steps/{GetType().Name}.cs"; + + public abstract Task Run(StepContext context); +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/ActiveStepRun.cs b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/ActiveStepRun.cs new file mode 100644 index 0000000..9251bc0 --- /dev/null +++ b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/ActiveStepRun.cs @@ -0,0 +1,3 @@ +namespace ProductDetails; + +public sealed record ActiveStepRun(WorkshopStep Step, StepReport Report); \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/LoggedMessage.cs b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/LoggedMessage.cs new file mode 100644 index 0000000..5d5ee26 --- /dev/null +++ b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/LoggedMessage.cs @@ -0,0 +1,3 @@ +namespace ProductDetails; + +public sealed record LoggedMessage(LogLevel Level, string Message, Exception? Exception); \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/PageLoad.cs b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/PageLoad.cs new file mode 100644 index 0000000..40701ea --- /dev/null +++ b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/PageLoad.cs @@ -0,0 +1,18 @@ +namespace ProductDetails; + +// One load of the Product page, as a step saw it. +// Elapsed is the wall clock time from the start of the load until the page finished loading. LongestRendererPause is the longest Blazor's renderer was held up while it loaded. +// Panels, PageError, TotalSeconds, IsLoading and Html are read from the page after it finished loading, and are empty when it never did. +public sealed record PageLoad( + bool Finished, + TimeSpan Elapsed, + TimeSpan LongestRendererPause, + IReadOnlyList Paints, + IReadOnlyList Panels, + string? PageError, + double? TotalSeconds, + bool IsLoading, + string Html) +{ + public PanelState? FindPanel(string name) => Panels.FirstOrDefault(panel => panel.Name == name); +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/PageLogRecorder.cs b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/PageLogRecorder.cs new file mode 100644 index 0000000..6ecaebe --- /dev/null +++ b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/PageLogRecorder.cs @@ -0,0 +1,37 @@ +using ProductDetails.Components.Pages; + +namespace ProductDetails; + +// Workshop plumbing: stands in for the Product page's ILogger while a step renders it, so the step can check what the page logged server-side. +// The page logs from thread pool threads while the step reads the messages, so every member takes the lock. +public sealed class PageLogRecorder : ILogger +{ + readonly Lock _lock = new(); + readonly List _messages = []; + + public IReadOnlyList Messages + { + get + { + lock (_lock) + { + return [.. _messages]; + } + } + } + + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + var message = new LoggedMessage(logLevel, formatter(state, exception), exception); + + lock (_lock) + { + _messages.Add(message); + } + } +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/PagePaint.cs b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/PagePaint.cs new file mode 100644 index 0000000..1106828 --- /dev/null +++ b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/PagePaint.cs @@ -0,0 +1,9 @@ +namespace ProductDetails; + +// One time the Product page painted, with every card exactly as that paint drew it +public sealed record PagePaint(TimeSpan Elapsed, IReadOnlyList Panels) +{ + public int AnsweredCards => Panels.Count(static panel => panel.Status is not "waiting"); + + public string? StatusOf(string name) => Panels.FirstOrDefault(panel => panel.Name == name)?.Status; +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/ProductPageProbe.cs b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/ProductPageProbe.cs new file mode 100644 index 0000000..8288e4e --- /dev/null +++ b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/ProductPageProbe.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using ProductDetails.Components.Pages; + +namespace ProductDetails; + +// Workshop plumbing: your Product page exactly as you wrote it, plus a record of every paint. +// Only ProductPageSession renders it. It has no route of its own, so the app never shows it. +public sealed class ProductPageProbe : Product +{ + [Parameter, EditorRequired] + public required ProductPageSession Session { get; set; } + + // Does what clicking Load product page does. Blazor repaints a component as soon as its event handler returns, and again when the Task it returned completes. + public async Task PressLoadProductPageAsync() + { + var load = LoadProductAsync(); + + StateHasChanged(); + + await load.ConfigureAwait(false); + + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + } + + protected override void OnInitialized() => Session.Attach(this); + + // Blazor calls this every time the page paints, so the cards recorded here are exactly the cards that paint draws + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + Session.RecordPaint(Panels); + + base.BuildRenderTree(builder); + } +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/ProductPageSession.cs b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/ProductPageSession.cs new file mode 100644 index 0000000..043c2d1 --- /dev/null +++ b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/ProductPageSession.cs @@ -0,0 +1,215 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.Web.HtmlRendering; +using ProductDetails.Components.Pages; + +namespace ProductDetails; + +// Workshop plumbing: renders your Product page in the background, the way a browser tab would, with fresh copies of the five services. +// It times every load, records every paint, and watches for anything that holds up Blazor's renderer while the page waits. +public sealed class ProductPageSession : IAsyncDisposable +{ + // What the five services cost, from BackendServices.cs: 4.2 seconds one after another, and 1.2 seconds, the slowest one, side by side + public const double SlowestServiceSeconds = 1.2; + public const double AllServicesSeconds = 4.2; + + // A load that answers faster than the slowest service did not wait for it. One that takes longer than 1.6 seconds still waits for some services one after another. + public const double ShortestPageLoadSeconds = 1.1; + public const double LongestPageLoadSeconds = 1.6; + + // Each service's cost, in the order the page shows the cards + public static readonly IReadOnlyList<(string Name, double Seconds)> ServiceLatencies = + [ + ("Inventory", 0.7), + ("Pricing", 0.9), + ("Reviews", 1.2), + ("Shipping", 0.6), + ("Recommendations", 0.8), + ]; + + // The starter takes 4.2 seconds, so a load only runs out of time when something waits forever + public static readonly TimeSpan LoadTimeout = TimeSpan.FromSeconds(8); + + // A load paints about ten times. A page stuck in a loop that repaints can paint millions of times, so stop recording long before that uses up memory. + const int _maximumRecordedPaints = 1_000; + + static readonly TimeSpan _heartbeatInterval = TimeSpan.FromMilliseconds(10); + static readonly TimeSpan _disposeTimeout = TimeSpan.FromSeconds(2); + + readonly Lock _lock = new(); + readonly List _paints = []; + readonly ServiceProvider _serviceProvider; + readonly HtmlRenderer _renderer; + + long _loadStartedAt; + bool _isClosed; + HtmlRootComponent _root; + ProductPageProbe? _page; + + public ProductPageSession() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton>(Logger); + + // Fresh instances, never the singletons the Product page uses + services.AddSingleton(new InventoryService()); + services.AddSingleton(new PricingService()); + services.AddSingleton(new ReviewsService()); + services.AddSingleton(new ShippingService()); + services.AddSingleton(new RecommendationsService()); + + _serviceProvider = services.BuildServiceProvider(); + _renderer = new HtmlRenderer(_serviceProvider, _serviceProvider.GetRequiredService()); + } + + // Everything the page logged while this session rendered it + public PageLogRecorder Logger { get; } = new(); + + public static string DescribeCards(IEnumerable panels) => + string.Join(", ", panels.Select(static panel => panel.Seconds is { } seconds ? $"{panel.Name} {panel.Status} at {seconds:F1}s" : $"{panel.Name} {panel.Status}")); + + public async ValueTask DisposeAsync() + { + // From here on, a page that is still repainting gets an exception instead of a paint, which ends a load that would otherwise loop forever + lock (_lock) + { + _isClosed = true; + } + + // A page that still holds the renderer can keep it from ever disposing, so stop waiting after a moment and leave it to the garbage collector + try + { + await _renderer.DisposeAsync().AsTask().WaitAsync(_disposeTimeout).ConfigureAwait(false); + await _serviceProvider.DisposeAsync().ConfigureAwait(false); + } + catch (TimeoutException) + { + } + } + + // Opens the page, the way a browser tab does, which runs OnInitializedAsync() and loads the product + public Task OpenPageAsync(CancellationToken token) => MeasureLoadAsync( + () => + { + var parameters = ParameterView.FromDictionary(new Dictionary { [nameof(ProductPageProbe.Session)] = this }); + + _root = _renderer.BeginRenderingComponent(parameters); + + // Completes when OnInitializedAsync() has finished and the page has painted its result + return _root.QuiescenceTask; + }, + token); + + // Does what clicking Load product page does, on the page OpenPageAsync() opened + public Task PressLoadProductPageAsync(CancellationToken token) => MeasureLoadAsync( + () => _page?.PressLoadProductPageAsync() ?? throw new InvalidOperationException("Open the page before pressing Load product page"), + token); + + public void Attach(ProductPageProbe page) => _page = page; + + public void RecordPaint(IReadOnlyList panels) + { + lock (_lock) + { + ObjectDisposedException.ThrowIf(_isClosed, this); + + if (_paints.Count < _maximumRecordedPaints) + _paints.Add(new PagePaint(Stopwatch.GetElapsedTime(_loadStartedAt), [.. panels])); + } + } + + async Task MeasureLoadAsync(Func load, CancellationToken token) + { + lock (_lock) + { + _paints.Clear(); + _loadStartedAt = Stopwatch.GetTimestamp(); + } + + var loadStartedAt = Stopwatch.GetTimestamp(); + + using var heartbeatCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + var heartbeat = WatchRendererAsync(heartbeatCancellationTokenSource.Token); + + var finished = false; + + try + { + // The page's own code runs on Blazor's renderer, exactly as it does in the browser. + // An idle renderer runs work on the thread that asks for it, so ask from a thread pool thread: a page that blocks forever must not take this step's thread with it. + await Task.Run(() => _renderer.Dispatcher.InvokeAsync(load), token).WaitAsync(LoadTimeout, token).ConfigureAwait(false); + + finished = true; + } + catch (TimeoutException) + { + // Still loading. The step reports it. + } + finally + { + await heartbeatCancellationTokenSource.CancelAsync().ConfigureAwait(false); + } + + var elapsed = Stopwatch.GetElapsedTime(loadStartedAt); + var longestRendererPause = await heartbeat.ConfigureAwait(false); + + List paints; + + lock (_lock) + { + paints = [.. _paints]; + } + + if (!finished || _page is not { } page) + return new PageLoad(false, elapsed, longestRendererPause, paints, [], null, null, true, string.Empty); + + // The page's state is only safe to read on the renderer + return await _renderer.Dispatcher.InvokeAsync(() => new PageLoad( + true, + elapsed, + longestRendererPause, + paints, + [.. page.Panels], + page.PageError, + page.TotalSeconds, + page.IsLoading, + _root.ToHtmlString())).WaitAsync(LoadTimeout, token).ConfigureAwait(false); + } + + // Queues an empty work item on Blazor's renderer every few milliseconds and measures how long each one waits to run. + // An await gives the renderer back straight away. A blocking wait such as .Wait(), .Result or Task.WaitAll holds it until the tasks finish. + async Task WatchRendererAsync(CancellationToken token) + { + var longestPause = TimeSpan.Zero; + + while (true) + { + var queuedAt = Stopwatch.GetTimestamp(); + + try + { + await _renderer.Dispatcher.InvokeAsync(static () => { }).WaitAsync(token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The load ended while this work item was still waiting, so it waited at least this long + return Longest(longestPause, Stopwatch.GetElapsedTime(queuedAt)); + } + + longestPause = Longest(longestPause, Stopwatch.GetElapsedTime(queuedAt)); + + try + { + await Task.Delay(_heartbeatInterval, token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + return longestPause; + } + } + + static TimeSpan Longest(TimeSpan first, TimeSpan second) => first > second ? first : second; + } +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/StepCheck.cs b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/StepCheck.cs new file mode 100644 index 0000000..d683aae --- /dev/null +++ b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/StepCheck.cs @@ -0,0 +1,3 @@ +namespace ProductDetails; + +public sealed record StepCheck(string Description, string Expected, string Actual, bool Passed, string Hint); \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/StepLogEntry.cs b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/StepLogEntry.cs new file mode 100644 index 0000000..9d9eedb --- /dev/null +++ b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/StepLogEntry.cs @@ -0,0 +1,3 @@ +namespace ProductDetails; + +public sealed record StepLogEntry(TimeSpan Elapsed, int ThreadId, bool IsThreadPoolThread, string Message); \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/StepReport.cs b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/StepReport.cs new file mode 100644 index 0000000..5faa35c --- /dev/null +++ b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/StepReport.cs @@ -0,0 +1,82 @@ +using System.Diagnostics; + +namespace ProductDetails; + +// Collects what a step logged and every expected result it checked. +// Steps write to it from thread pool threads while the workshop guide reads it, so every member takes the lock. +public sealed class StepReport +{ + readonly Lock _lock = new(); + readonly Stopwatch _stopwatch = Stopwatch.StartNew(); + readonly List _entries = []; + readonly List _checks = []; + + bool _isComplete; + + public IReadOnlyList Entries + { + get + { + lock (_lock) + { + return [.. _entries]; + } + } + } + + public IReadOnlyList Checks + { + get + { + lock (_lock) + { + return [.. _checks]; + } + } + } + + public void Log(string message) + { + var threadId = Environment.CurrentManagedThreadId; + var isThreadPoolThread = Thread.CurrentThread.IsThreadPoolThread; + + lock (_lock) + { + if (!_isComplete) + _entries.Add(new StepLogEntry(_stopwatch.Elapsed, threadId, isThreadPoolThread, message)); + } + } + + // The hint is shown under the expected result only when it does not match + public void Expect(string description, T expected, T actual, string hint) + { + Expect(description, Format(expected), Format(actual), EqualityComparer.Default.Equals(expected, actual), hint); + } + + public void Expect(string description, string expected, string actual, bool passed, string hint) + { + lock (_lock) + { + if (!_isComplete) + _checks.Add(new StepCheck(description, expected, actual, passed, hint)); + } + } + + // Called when the run ends, including a run that timed out or was stopped while its step is still blocked somewhere. + // Anything that abandoned step writes later is ignored, so the result in the workshop guide can no longer change. + public void Complete() + { + lock (_lock) + { + _isComplete = true; + _stopwatch.Stop(); + } + } + + static string Format(T value) => value switch + { + null => "null", + int or long => $"{value:N0}", + _ => value.ToString() ?? "null", + }; +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/StepState.cs b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/StepState.cs new file mode 100644 index 0000000..113f3cf --- /dev/null +++ b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/StepState.cs @@ -0,0 +1,9 @@ +namespace ProductDetails; + +// ExceptionType is set for Crashed. Only the name is kept: exception messages and stack traces stay in the terminal. +// CompletedAt is when the run that produced this state ended, so the workshop guide can show that a click really ran again. +public sealed record StepState( + StepStatus Status, + StepReport? Report, + string? ExceptionType = null, + DateTimeOffset? CompletedAt = null); \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/StepStatus.cs b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/StepStatus.cs new file mode 100644 index 0000000..49bd53b --- /dev/null +++ b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/StepStatus.cs @@ -0,0 +1,12 @@ +namespace ProductDetails; + +public enum StepStatus +{ + NotRun, + Running, + Passed, + Failed, + TimedOut, + Crashed, + Stopped, +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/StepVerifier.cs b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/StepVerifier.cs new file mode 100644 index 0000000..f6a45ff --- /dev/null +++ b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/Verification/StepVerifier.cs @@ -0,0 +1,230 @@ +namespace ProductDetails; + +// Workshop plumbing: runs the steps against your Product page and keeps their results. +// Every step renders its own copy of the page with its own instances of the services, so a check never disturbs the singletons the Product page uses. +// Only one step runs at a time, so a step that measures how long the page takes never competes with another run for thread pool threads. +public sealed class StepVerifier(ILogger logger, IHostApplicationLifetime applicationLifetime) : BackgroundService +{ + public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); + + readonly Lock _lock = new(); + readonly StepState[] _states = [.. Enumerable.Repeat(new StepState(StepStatus.NotRun, null), 3)]; + + // Both start at 1: the app checks every step on startup, and nothing else may run before that finishes + int _isBusy = 1; + int _isCheckingAllSteps = 1; + + ActiveStepRun? _activeRun; + TaskCompletionSource? _stopRequested; + + public IReadOnlyList Steps { get; } = + [ + new Step1StartEveryCall(), + new Step2ContainEachFailure(), + new Step3PaintEachCard(), + ]; + + public bool IsBusy => Volatile.Read(ref _isBusy) is 1; + + public bool IsCheckingAllSteps => Volatile.Read(ref _isCheckingAllSteps) is 1; + + public ActiveStepRun? ActiveRun + { + get + { + lock (_lock) + { + return _activeRun; + } + } + } + + public WorkshopStep? FindStep(int number) => Steps.FirstOrDefault(step => step.Number == number); + + public StepState GetState(int number) + { + lock (_lock) + { + return _states[number - 1]; + } + } + + public bool IsUnlocked(int number) => number is 1 || GetState(number - 1).Status is StepStatus.Passed; + + // Re-runs every step, the same way startup does + public async Task CheckAllSteps() + { + if (Interlocked.CompareExchange(ref _isBusy, 1, 0) is not 0) + return; + + Volatile.Write(ref _isCheckingAllSteps, 1); + + try + { + await CheckSteps(applicationLifetime.ApplicationStopping).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isCheckingAllSteps, 0); + Volatile.Write(ref _isBusy, 0); + } + } + + public async Task RunStep(WorkshopStep step) + { + if (!IsUnlocked(step.Number) || Interlocked.CompareExchange(ref _isBusy, 1, 0) is not 0) + return GetState(step.Number); + + try + { + return await Run(step, applicationLifetime.ApplicationStopping).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isBusy, 0); + } + } + + public void StopRun() + { + lock (_lock) + { + _stopRequested?.TrySetResult(); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await CheckSteps(stoppingToken).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isCheckingAllSteps, 0); + Volatile.Write(ref _isBusy, 0); + } + } + + async Task CheckSteps(CancellationToken token) + { + try + { + // Results from before the latest change to the page are not trusted + lock (_lock) + { + Array.Fill(_states, new StepState(StepStatus.NotRun, null)); + } + + logger.LogInformation("Checking your Product page against every step. Open the app in your browser: the workshop guide beside the Product page shows what to fix next"); + + foreach (var step in Steps) + { + var state = await Run(step, token).ConfigureAwait(false); + + if (state.Status is not StepStatus.Passed) + { + logger.LogInformation("Stopped checking at Step {StepNumber}. Open it in the app to see what to change, with a hint for every result that did not match. Later steps unlock after it passes", step.Number); + return; + } + } + + logger.LogInformation("Every step passed. Pause here for the group review"); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The app is shutting down + } + catch (Exception e) + { + logger.LogError(e, "Checking the steps failed unexpectedly"); + } + } + + async Task Run(WorkshopStep step, CancellationToken token) + { + var report = new StepReport(); + var stopRequested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var previousState = GetState(step.Number); + + lock (_lock) + { + _activeRun = new ActiveStepRun(step, report); + _stopRequested = stopRequested; + _states[step.Number - 1] = new StepState(StepStatus.Running, report); + } + + // Cancelled when the run ends for any reason, so a step that is still working can stop + using var runCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + + var state = previousState; + + try + { + state = await Execute(step, report, stopRequested.Task, runCancellationTokenSource.Token, token).ConfigureAwait(false); + } + finally + { + await runCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + report.Complete(); + + lock (_lock) + { + _activeRun = null; + _stopRequested = null; + _states[step.Number - 1] = state with { CompletedAt = DateTimeOffset.Now }; + } + } + + return state; + } + + async Task Execute(WorkshopStep step, StepReport report, Task stopRequested, CancellationToken runToken, CancellationToken token) + { + try + { + // A dedicated thread runs the step until its first await. If a deadlock blocks it forever, + // a thread pool thread is not lost for the rest of the app's life. + var stepTask = Task.Factory.StartNew(() => step.Run(new StepContext(report, runToken)), token, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); + + var finished = await Task.WhenAny(stepTask, stopRequested).WaitAsync(Timeout, token).ConfigureAwait(false); + + if (finished == stopRequested) + { + logger.LogInformation("Step {StepNumber} was stopped", step.Number); + return new StepState(StepStatus.Stopped, report); + } + + await stepTask.ConfigureAwait(false); + + var checks = report.Checks; + var passedChecks = checks.Count(static check => check.Passed); + var status = checks.Count > 0 && passedChecks == checks.Count ? StepStatus.Passed : StepStatus.Failed; + + logger.LogInformation("Step {StepNumber} {Result} ({PassedChecks}/{TotalChecks} checks)", step.Number, status is StepStatus.Passed ? "passed" : "failed", passedChecks, checks.Count); + + // The hints belong in the terminal too, next to anything the app logged while the step ran + foreach (var check in checks.Where(static check => !check.Passed)) + { + logger.LogWarning("Step {StepNumber}: {Description}. Expected {Expected}, actual {Actual}. Hint: {Hint}", step.Number, check.Description, check.Expected, check.Actual, check.Hint); + } + + return new StepState(status, report); + } + catch (TimeoutException) + { + logger.LogWarning("Step {StepNumber} did not finish within {Timeout}. {TimeoutHint}", step.Number, Timeout, step.TimeoutHint); + return new StepState(StepStatus.TimedOut, report); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + return new StepState(StepStatus.NotRun, null); + } + catch (Exception e) + { + logger.LogError(e, "Step {StepNumber} threw an unexpected exception", step.Number); + return new StepState(StepStatus.Crashed, report, e.GetType().Name); + } + } +} \ No newline at end of file diff --git a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/wwwroot/app.css b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/wwwroot/app.css index c5a79b2..a7fb00c 100644 --- a/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/wwwroot/app.css +++ b/6. Coordinating Multiple Tasks/2. Finish/ProductDetails/wwwroot/app.css @@ -1,19 +1,42 @@ +/* ProductDetails: a launch day product page for an electronics store, with the workshop guide docked beside it as a QA sign-off ticket */ + :root { - --canvas: #eef3f1; - --panel: #ffffff; - --panel-strong: #112a2d; - --ink: #18211f; - --muted: #62706d; - --line: #d7e0dd; - --accent: #f46a1f; - --teal: #0f766e; - --good: #0f8a52; - --bad: #cf202f; - --warn: #c59d35; - --shadow: 0 18px 55px rgb(28 45 42 / 14%); + --porcelain: #f6f4ef; + --card: #ffffff; + --line: #e6e1d8; + --ink: #16161a; + --muted: #6a665e; + --ready: #17804a; + --ready-soft: #e7f5ec; + --failed: #c5302b; + --failed-soft: #fff4f2; + --failed-line: #f2cfca; + --waiting: #a19c92; + --qa-canvas: #f3f4f9; + --qa-surface: #ffffff; + --qa-line: #dde0ea; + --qa-ink: #1b1d2e; + --qa-muted: #585d75; + --indigo: #4f46e5; + --indigo-dark: #3730a3; + --indigo-soft: #eef0ff; + --violet: #7c3aed; + --good: #166534; + --good-soft: #e8f6ed; + --good-line: #b9e0c6; + --bad: #b42318; + --bad-soft: #fdeeed; + --bad-line: #f1c0bb; + --warn: #8a5a00; + --warn-soft: #fff4d6; + --warn-line: #efd48c; + --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif; + --serif: "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif; + --mono: "Cascadia Code", "SF Mono", Menlo, Consolas, monospace; + --guide-width: 460px; color-scheme: light; - font-family: "Aptos", "Avenir Next", "Segoe UI", sans-serif; - background: var(--canvas); + font-family: var(--sans); + background: var(--porcelain); color: var(--ink); } @@ -24,248 +47,1308 @@ body { margin: 0; overflow-x: clip; - background: - linear-gradient(135deg, rgb(15 118 110 / 14%), transparent 32%), - radial-gradient(circle at top right, rgb(244 106 31 / 16%), transparent 30%), - var(--canvas); + background: var(--porcelain); +} + +/* FocusOnNavigate moves focus to the page heading for screen readers; the heading is not interactive, so hide the ring */ +h1:focus { + outline: none; +} + +code { + font-family: var(--mono); + font-size: 0.88em; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; } -.shell { +/* The app and the guide share the screen */ + +.workspace { + display: flex; min-height: 100vh; } -.page { - width: min(1120px, calc(100% - 32px)); +.app-pane { + flex: 1 1 auto; + min-width: 0; + container-type: inline-size; +} + +/* Buttons */ + +button.action { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 14px 26px; + border: 1px solid transparent; + border-radius: 999px; + background: var(--ink); + color: #fff; + font: inherit; + font-weight: 650; + cursor: pointer; +} + +button.action:hover:not(:disabled) { + background: #34343c; +} + +button.action:disabled { + background: #8c8a93; + cursor: not-allowed; +} + +button.action.secondary { + border-color: var(--qa-line); + background: var(--qa-surface); + color: var(--qa-ink); +} + +button.action.secondary:hover:not(:disabled) { + background: var(--qa-canvas); +} + +button.action.small { + padding: 7px 14px; + font-size: 0.86rem; +} + +.spinner { + display: inline-block; + width: 14px; + height: 14px; + margin-right: 8px; + border: 2px solid rgb(255 255 255 / 35%); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.banner .spinner { + border-color: rgb(138 90 0 / 25%); + border-top-color: var(--warn); + vertical-align: -2px; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes shimmer { + from { + background-position: 100% 0; + } + + to { + background-position: -100% 0; + } +} + +/* The store */ + +.store { + width: min(1080px, calc(100% - 56px)); margin: 0 auto; - padding: 32px 0 56px; + padding: 18px 0 64px; +} + +.store-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 28px; + padding: 6px 0 18px; + border-bottom: 1px solid var(--line); +} + +.store-logo { + font-size: 1.1rem; + font-weight: 750; + letter-spacing: -0.01em; +} + +.store-logo-mark { + color: var(--indigo); } -.hero-panel { +.store-nav { + display: flex; + flex: 1; + gap: 22px; + color: var(--muted); + font-size: 0.9rem; +} + +.store-event { + padding: 5px 12px; + border: 1px solid var(--ink); + border-radius: 999px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.product-hero { display: grid; - grid-template-columns: 1fr auto; - gap: 28px; - align-items: end; - margin-bottom: 22px; - padding: 30px; - border: 1px solid rgb(36 35 31 / 10%); - border-radius: 8px; - background: linear-gradient(135deg, var(--panel-strong), #15463e 58%, #6f3b17); - box-shadow: var(--shadow); - color: #f8fffc; + grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr); + gap: 44px; + align-items: center; + margin: 24px 0 0; } -.hero-copy { - max-width: 680px; +/* The product shot is drawn with CSS, so the page needs no images and works offline */ + +.product-stage { + position: relative; + display: grid; + place-items: center; + aspect-ratio: 1 / 0.8; + overflow: hidden; + border-radius: 32px; + background: + radial-gradient(circle at 50% 46%, rgb(255 255 255 / 95%) 0 20%, transparent 56%), + radial-gradient(circle at 12% 92%, rgb(255 196 164 / 75%), transparent 46%), + radial-gradient(circle at 90% 6%, rgb(190 198 255 / 85%), transparent 50%), + linear-gradient(160deg, #eceefb, #f8ebe2); } -.eyebrow { - margin: 0 0 6px; +.product-stage::after { + content: ""; + position: absolute; + left: 24%; + right: 24%; + bottom: 12%; + height: 7%; + border-radius: 50%; + background: radial-gradient(closest-side, rgb(36 32 64 / 30%), transparent); +} + +.headphones { + position: relative; + z-index: 1; + width: 56%; + aspect-ratio: 1 / 0.9; + margin-bottom: 8%; + container-type: inline-size; +} + +.headphones-band { + position: absolute; + top: 0; + left: 10cqi; + right: 10cqi; + height: 60cqi; + border: 6.5cqi solid #2b2b34; + border-bottom: 0; + border-radius: 40cqi 40cqi 0 0; +} + +.headphones-cup { + position: absolute; + bottom: 0; + width: 30cqi; + height: 44cqi; + border-radius: 14cqi; + background: linear-gradient(160deg, #4d4d59, #17171d 72%); + box-shadow: + inset 1.5cqi 1.5cqi 3cqi rgb(255 255 255 / 14%), + 0 5cqi 9cqi rgb(30 24 60 / 28%); +} + +.headphones-cup::before { + content: ""; + position: absolute; + top: 6cqi; + bottom: 6cqi; + width: 9cqi; + border-radius: 6cqi; + background: linear-gradient(#ddd3c8, #b8ada1); +} + +.headphones-cup::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 10cqi; + height: 10cqi; + border: 1cqi solid rgb(255 255 255 / 22%); + border-radius: 50%; + translate: -50% -50%; +} + +.headphones-cup.left { + left: 0; +} + +.headphones-cup.left::before { + right: -3.5cqi; +} + +.headphones-cup.right { + right: 0; +} + +.headphones-cup.right::before { + left: -3.5cqi; +} + +.product-eyebrow { + margin: 0 0 12px; + color: var(--indigo); font-size: 0.78rem; - font-weight: 600; - letter-spacing: 0.12em; + font-weight: 700; + letter-spacing: 0.14em; text-transform: uppercase; - color: rgb(248 255 252 / 72%); } -.hero-panel h1 { - margin: 0 0 10px; - font-size: clamp(1.8rem, 3vw, 2.5rem); - line-height: 1.1; +.product-copy h1 { + margin: 0 0 12px; + font-family: var(--serif); + font-size: clamp(2.3rem, 5cqi, 3.5rem); + font-weight: 600; + line-height: 1; + letter-spacing: -0.02em; } -.hero-summary { +.product-summary { + max-width: 44ch; margin: 0; - color: rgb(248 255 252 / 80%); + color: var(--muted); + font-size: 1.05rem; + line-height: 1.55; } -.hero-actions { +.product-price { display: grid; - gap: 10px; - justify-items: end; + gap: 6px; + margin: 18px 0 20px; + padding: 12px 0; + border-block: 1px solid var(--line); } -button.action { - padding: 12px 22px; - border: 0; - border-radius: 6px; - background: var(--accent); - color: #fff; - font: inherit; - font-weight: 600; - cursor: pointer; +.product-price-label { + color: var(--muted); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; } -button.action:hover:not(:disabled) { - background: #d4560f; +.product-price-value { + font-size: clamp(1.5rem, 3.2cqi, 2.1rem); + font-weight: 700; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + animation: arrive 0.45s ease-out; } -button.action:disabled { - background: var(--muted); - cursor: progress; +.product-price-skeleton { + display: block; + width: min(320px, 70%); + height: 2.1rem; + border-radius: 10px; + background: linear-gradient(90deg, #ebe7df 25%, #f8f6f2 50%, #ebe7df 75%); + background-size: 200% 100%; + animation: shimmer 1.4s linear infinite; } -button.action.secondary { - background: rgb(248 255 252 / 14%); - border: 1px solid rgb(248 255 252 / 30%); +.product-price-missing { + color: var(--failed); + font-size: 1.1rem; + font-weight: 650; } -button.action.secondary:hover:not(:disabled) { - background: rgb(248 255 252 / 24%); +.product-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 14px 22px; } -.panel { - margin-bottom: 16px; - padding: 22px 24px; - border: 1px solid var(--line); - border-radius: 8px; - background: var(--panel); - box-shadow: 0 10px 26px rgb(28 45 42 / 8%); +.status-tile { + display: grid; + padding-left: 22px; + border-left: 1px solid var(--line); } -.panel h2 { - margin: 0 0 4px; - font-size: 1.15rem; +.status-value { + font-size: 1.6rem; + font-weight: 750; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} + +.status-label { + color: var(--muted); + font-size: 0.82rem; +} + +.warning-banner { + margin: 22px 0 0; + padding: 12px 18px 12px 22px; + border: 1px solid #edcf6f; + border-radius: 16px; + background: #fff5cf; + box-shadow: inset 5px 0 0 #e2a90b; + color: #573f00; +} + +.warning-banner p { + margin: 4px 0 0; + line-height: 1.5; +} + +/* The five service panels */ + +.panels { + margin-top: 28px; +} + +.panels-heading { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 6px 28px; +} + +.panels-heading h2 { + margin: 0; + font-family: var(--serif); + font-size: 1.6rem; + font-weight: 600; } -.panel-hint { - margin: 0 0 16px; +.panels-hint { + max-width: 62ch; + margin: 0; color: var(--muted); font-size: 0.92rem; + line-height: 1.5; } .result-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); - gap: 12px; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 14px; + margin-top: 18px; } .result-card { + --rail: var(--waiting); + position: relative; display: grid; - gap: 4px; - padding: 14px 16px; + grid-column: span 2; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + "label timing" + "value value" + "note note"; + align-content: start; + column-gap: 10px; + min-height: 118px; + padding: 16px 20px 16px 26px; border: 1px solid var(--line); - border-left: 4px solid var(--muted); - border-radius: 6px; - background: #fbfdfc; + border-radius: 20px; + background: var(--card); + box-shadow: + 0 1px 2px rgb(40 30 20 / 5%), + 0 12px 28px rgb(40 30 20 / 6%); } -.result-card.ok { - border-left-color: var(--good); +/* The card's left edge shows its state: grey while waiting, green when ready, red when it failed or was never requested */ +.result-card::after { + content: ""; + position: absolute; + top: 16px; + bottom: 16px; + left: -1px; + width: 5px; + border-radius: 0 5px 5px 0; + background: var(--rail); } -.result-card.bad { - border-left-color: var(--bad); +.result-card:nth-child(n + 4) { + grid-column: span 3; } .result-card.pending { - border-left-color: var(--warn); + background: linear-gradient(100deg, var(--card) 35%, #f4f1eb 50%, var(--card) 65%); + background-size: 250% 100%; + animation: shimmer 1.6s linear infinite; +} + +.result-card.ok { + --rail: var(--ready); + animation: arrive-ready 0.6s ease-out; +} + +.result-card.bad { + --rail: var(--failed); + border-color: var(--failed-line); + background: var(--failed-soft); + animation: arrive-failed 0.6s ease-out; } .result-label { - font-size: 0.78rem; - font-weight: 600; - letter-spacing: 0.06em; - text-transform: uppercase; + grid-area: label; + display: flex; + align-items: center; + gap: 8px; color: var(--muted); + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.result-label::before { + content: ""; + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--rail); +} + +.result-card.pending .result-label::before { + animation: pulse 1.2s ease-in-out infinite; } .result-value { + grid-area: value; + margin-top: 12px; + color: var(--waiting); font-size: 1.5rem; font-weight: 700; - font-variant-numeric: tabular-nums; + letter-spacing: -0.01em; +} + +.result-card.bad .result-value { + color: var(--failed); } .result-note { - font-size: 0.85rem; + grid-area: note; + margin-top: 6px; color: var(--muted); + font-size: 0.9rem; + line-height: 1.4; } .result-card.ok .result-note { - color: var(--good); + margin-top: 12px; + color: var(--ink); + font-size: 1.02rem; + font-weight: 600; } .result-card.bad .result-note { - color: var(--bad); + color: #8e231f; } -.timing { +.result-card .result-note.timing { + grid-area: timing; + align-self: start; + margin: 0; + padding: 2px 9px; + border-radius: 999px; + background: var(--ready-soft); + color: var(--ready); + font-size: 0.78rem; + font-weight: 700; font-variant-numeric: tabular-nums; - font-weight: 600; } -.log { - max-height: 260px; +.result-card.bad .result-note.timing { + background: #fde2de; + color: var(--failed); +} + +@keyframes arrive { + from { + opacity: 0; + transform: translateY(4px); + } + + to { + opacity: 1; + transform: none; + } +} + +@keyframes arrive-ready { + from { + background-color: #d9f1e2; + transform: translateY(3px); + } + + to { + background-color: var(--card); + transform: none; + } +} + +@keyframes arrive-failed { + from { + background-color: #fbd9d4; + transform: translateY(3px); + } + + to { + background-color: var(--failed-soft); + transform: none; + } +} + +@keyframes pulse { + 50% { + opacity: 0.3; + } +} + +/* The workshop guide: a QA sign-off ticket, docked beside the product page */ + +.guide { + position: sticky; + top: 0; + flex: 0 0 var(--guide-width); + height: 100vh; overflow-y: auto; + border-left: 1px solid var(--qa-line); + background: var(--qa-canvas); + box-shadow: -18px 0 48px rgb(40 44 90 / 8%); + color: var(--qa-ink); + font-family: var(--sans); + font-size: 0.93rem; +} + +.guide.collapsed { + flex-basis: 76px; + display: grid; + align-content: start; + justify-items: center; + gap: 14px; + padding: 14px 0; + border-top: 4px solid var(--indigo); +} + +.guide-header { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 20px 22px 12px; + background: + linear-gradient(90deg, var(--indigo), var(--violet)) top / 100% 4px no-repeat, + var(--qa-surface); +} + +.guide-eyebrow { + margin: 0 0 6px; + color: var(--qa-muted); + font-size: 0.8rem; + font-weight: 600; +} + +/* The ticket key, shown like an issue tracker breadcrumb */ +.guide-eyebrow::after { + content: " / QA-318"; + color: var(--indigo-dark); + font-family: var(--mono); + font-size: 0.78rem; +} + +.guide-title { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; margin: 0; - padding: 14px 16px; - border: 1px solid var(--line); + font-size: 1.45rem; + font-weight: 700; + letter-spacing: -0.01em; +} + +/* The ticket's status: In QA until every progress segment is done, then Signed off */ +.guide-title::after { + content: "Signed off"; + padding: 3px 9px; + border: 1px solid var(--good-line); border-radius: 6px; - background: #1e2227; - color: #d7dde6; - font-family: "Menlo", "Consolas", monospace; - font-size: 0.82rem; - line-height: 1.6; - white-space: pre-wrap; + background: var(--good-soft); + color: var(--good); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; } -.status-tile { +.guide:has(.progress-segment:not(.done)) .guide-title::after { + content: "In QA"; + border-color: #c9ccf8; + background: var(--indigo-soft); + color: var(--indigo-dark); +} + +.guide-subtitle { + margin: 6px 0 0; + color: var(--qa-muted); + line-height: 1.45; +} + +.guide-toggle { + flex: none; + align-self: start; display: grid; - gap: 4px; - justify-items: end; - padding: 14px 18px; - border: 1px solid rgb(248 255 252 / 22%); + justify-items: center; + gap: 8px; + min-width: 34px; + padding: 5px 8px; + border: 1px solid var(--qa-line); + border-radius: 7px; + background: var(--qa-surface); + color: var(--qa-ink); + font: inherit; + font-size: 1.05rem; + font-weight: 700; + cursor: pointer; +} + +.guide-toggle:hover { + border-color: var(--indigo); + color: var(--indigo-dark); +} + +.guide-rail-label { + color: var(--qa-muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + writing-mode: vertical-rl; +} + +.guide-rail { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.guide-rail-step { + display: grid; + justify-items: center; + gap: 2px; + width: 52px; + padding: 6px 0; + border: 1px solid var(--qa-line); border-radius: 8px; - background: rgb(248 255 252 / 10%); + background: var(--qa-surface); + color: var(--qa-ink); + font: inherit; + cursor: pointer; } -.status-value { - font-size: 1.9rem; +.guide-rail-step:hover { + border-color: var(--indigo); +} + +.guide-rail-number { font-weight: 700; - line-height: 1; - font-variant-numeric: tabular-nums; } -.status-label { - font-size: 0.82rem; - color: rgb(248 255 252 / 78%); +.guide-progress { + display: grid; + gap: 10px; + padding: 4px 22px 16px; + border-bottom: 1px solid var(--qa-line); + background: var(--qa-surface); } -.spinner { +.progress-meter { + display: flex; + gap: 4px; +} + +.progress-segment { + flex: 1; + height: 6px; + border-radius: 3px; + background: #dfe2ec; +} + +.progress-segment.done { + background: #16a34a; +} + +.guide-progress-row { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.guide-progress-count { + margin: 0; + color: var(--qa-muted); +} + +.guide-progress-count strong { + color: var(--qa-ink); + font-size: 1.05rem; +} + +.guide button.action { + border-radius: 7px; + background: var(--indigo); + font-weight: 600; +} + +.guide button.action:hover:not(:disabled) { + background: var(--indigo-dark); +} + +.guide button.action:disabled { + background: #a5a8c0; +} + +.guide button.action.secondary { + border-color: var(--qa-line); + background: var(--qa-surface); + color: var(--qa-ink); +} + +.guide button.action.secondary:hover:not(:disabled) { + background: var(--qa-canvas); +} + +.guide-tabs { + display: grid; + margin: 16px 18px 0; + overflow: hidden; + border: 1px solid var(--qa-line); + border-radius: 10px; + background: var(--qa-surface); +} + +.guide-tab { + display: grid; + grid-template-columns: 26px 1fr auto; + gap: 10px; + align-items: center; + padding: 10px 12px 10px 10px; + border: 0; + border-left: 3px solid transparent; + background: transparent; + color: var(--qa-ink); + font: inherit; + text-align: left; + cursor: pointer; +} + +.guide-tab + .guide-tab { + border-top: 1px solid var(--qa-line); +} + +.guide-tab:hover { + background: #f8f9fd; +} + +.guide-tab.selected { + border-left-color: var(--indigo); + background: var(--indigo-soft); + font-weight: 600; +} + +.guide-tab-number { + display: grid; + place-items: center; + width: 24px; + height: 24px; + border: 1.5px solid #c3c8d8; + border-radius: 6px; + background: var(--qa-surface); + color: var(--qa-muted); + font-size: 0.78rem; + font-weight: 700; +} + +.guide-tab.selected .guide-tab-number { + border-color: var(--indigo); + background: var(--indigo); + color: #fff; +} + +.guide-body { + display: grid; + gap: 14px; + padding: 16px 18px 40px; +} + +.guide-section { + padding: 16px 18px; + border: 1px solid var(--qa-line); + border-radius: 10px; + background: var(--qa-surface); +} + +/* Section headings read like the field labels on a ticket */ +.guide-section h3, +.guide-section h4 { + margin: 0 0 10px; + color: var(--qa-muted); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.guide-section p { + margin: 0 0 10px; + line-height: 1.55; +} + +.guide-section p:last-child { + margin-bottom: 0; +} + +.guide-note { + color: var(--qa-muted); + font-size: 0.87rem; +} + +.step-header { + display: grid; + gap: 10px; + padding: 2px 2px 0; +} + +.step-eyebrow { + margin: 0; + color: var(--indigo-dark); + font-family: var(--mono); + font-size: 0.8rem; + font-weight: 600; +} + +.step-title { + margin: 2px 0 6px; + font-size: 1.3rem; + font-weight: 700; + letter-spacing: -0.01em; + line-height: 1.25; +} + +.step-members { + padding: 1px 6px; + border-radius: 5px; + background: #e7e9f2; + color: var(--qa-ink); + overflow-wrap: anywhere; +} + +.step-actions { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.step-buttons { + display: flex; + gap: 8px; +} + +.how-it-works, +.task-list { + margin: 0 0 12px; + padding-left: 20px; + line-height: 1.55; +} + +.how-it-works li + li, +.task-list li + li { + margin-top: 6px; +} + +.how-it-works { + margin-bottom: 0; +} + +.facts { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 12px; + margin: 0 0 12px; + font-size: 0.9rem; +} + +.facts dt { + color: var(--qa-muted); + font-weight: 600; +} + +.facts dd { + margin: 0; + overflow-wrap: anywhere; +} + +.see-it { + padding: 10px 14px; + border-left: 4px solid var(--indigo); + border-radius: 6px; + background: var(--indigo-soft); +} + +.ai-note { + padding: 10px 14px; + border-left: 4px solid #d99a00; + border-radius: 6px; + background: #fff6dc; + color: #533c00; +} + +.clues { + padding: 10px 14px; + border: 1px dashed #c3c8d8; + border-radius: 8px; + background: #f8f9fc; +} + +.clues summary, +.guide-log summary { + color: var(--indigo-dark); + font-weight: 600; + cursor: pointer; +} + +.clues ul { + margin: 10px 0 0; + padding-left: 20px; + line-height: 1.55; +} + +.guide-log-count { + margin-left: 6px; + color: var(--qa-muted); + font-size: 0.85rem; + font-weight: 400; +} + +/* Step results */ + +.badge { display: inline-block; - width: 14px; - height: 14px; - margin-right: 8px; - border: 2px solid rgb(98 112 109 / 35%); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; - vertical-align: -2px; + padding: 2px 9px; + border: 1px solid var(--qa-line); + border-radius: 999px; + background: #f1f2f7; + color: var(--qa-muted); + font-size: 0.76rem; + font-weight: 600; + white-space: nowrap; } -@keyframes spin { +.badge-compact { + padding: 0; + border: 0; + background: transparent; + font-size: 0.9rem; +} + +.badge-passed { + border-color: var(--good-line); + background: var(--good-soft); + color: var(--good); +} + +.badge-failed { + border-color: var(--bad-line); + background: var(--bad-soft); + color: var(--bad); +} + +.badge-running { + border-color: var(--warn-line); + background: var(--warn-soft); + color: var(--warn); +} + +.badge-compact.badge-passed, +.badge-compact.badge-failed, +.badge-compact.badge-running { + background: transparent; +} + +.banner { + display: grid; + justify-items: start; + gap: 6px; + padding: 12px 16px; + border: 1px solid var(--qa-line); + border-radius: 10px; + background: var(--qa-surface); + line-height: 1.5; +} + +.banner-passed { + border-color: var(--good-line); + background: var(--good-soft); + box-shadow: inset 4px 0 0 #16a34a; + color: #14532d; +} + +.banner-failed { + border-color: var(--bad-line); + background: var(--bad-soft); + box-shadow: inset 4px 0 0 #d92d20; + color: #8f1d15; +} + +.banner-running { + border-color: var(--warn-line); + background: var(--warn-soft); + color: #6b4500; +} + +.banner-locked { + background: #f7f8fb; + color: var(--qa-muted); +} + +.banner-note { + font-size: 0.82rem; + opacity: 0.85; +} + +/* @key recreates the banner for every new result, so this animation plays again after each click */ +.banner-fresh { + animation: banner-in 0.35s ease-out; +} + +@keyframes banner-in { + from { + opacity: 0; + transform: translateY(-6px); + } + to { - transform: rotate(360deg); + opacity: 1; + transform: none; } } -.warning-banner { - margin-bottom: 16px; - padding: 14px 18px; - border: 1px solid rgb(197 157 53 / 45%); - border-left: 4px solid var(--warn); +.link-button { + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + font-weight: 700; + text-decoration: underline; + text-underline-offset: 3px; + cursor: pointer; +} + +.check-list { + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.check { + display: grid; + grid-template-columns: 24px 1fr; + gap: 8px; + padding: 10px 12px; + border: 1px solid var(--qa-line); + border-radius: 8px; +} + +.check-failed { + border-color: var(--bad-line); + background: #fff8f7; + box-shadow: inset 3px 0 0 #d92d20; +} + +.check-body { + min-width: 0; +} + +.guide-section .check-description { + margin: 0 0 6px; + font-weight: 600; + line-height: 1.4; +} + +.check-values { + display: grid; + grid-template-columns: max-content 1fr; + gap: 2px 10px; + margin: 0; + font-size: 0.86rem; +} + +.check-values dt { + color: var(--qa-muted); +} + +.check-values dd { + margin: 0; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} + +.guide-section .check-hint { + margin: 8px 0 0; + padding: 8px 10px; + border-left: 3px solid #d92d20; border-radius: 6px; - background: #fdf8e9; - color: #6b551a; + background: #fde6e3; + color: #761a12; + font-size: 0.88rem; + line-height: 1.5; +} + +.table-scroll { + margin-top: 10px; + overflow-x: auto; +} + +.step-log { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} + +.step-log th, +.step-log td { + padding: 6px 8px; + border-bottom: 1px solid var(--qa-line); + text-align: left; + vertical-align: top; +} + +.step-log th { + color: var(--qa-muted); +} + +.step-log td:nth-child(-n + 3) { + font-family: var(--mono); + font-variant-numeric: tabular-nums; + white-space: nowrap; } -@media (max-width: 640px) { - .hero-panel { - grid-template-columns: 1fr; - align-items: start; +/* The product page follows the width of its own pane, which changes when the guide collapses */ + +@container (max-width: 760px) { + .product-hero { + grid-template-columns: minmax(0, 1fr); + gap: 24px; + } + + .product-stage { + aspect-ratio: 16 / 10; + } + + .headphones { + width: 38%; + } + + .result-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .result-card, + .result-card:nth-child(n + 4) { + grid-column: auto; + } + + .result-card:last-child { + grid-column: 1 / -1; + } +} + +@container (max-width: 520px) { + .store { + width: calc(100% - 32px); + } + + .store-nav { + display: none; + } + + .store-bar { + justify-content: space-between; + } + + .result-grid { + grid-template-columns: minmax(0, 1fr); + } + + .result-card:last-child { + grid-column: auto; } - .hero-actions, .status-tile { - justify-items: start; + padding-left: 0; + border-left: 0; + } +} + +/* Narrow screens stack the guide under the app */ + +@media (max-width: 1080px) { + :root { + --guide-width: 380px; + } +} + +@media (max-width: 860px) { + .workspace { + flex-direction: column; + } + + .guide, + .guide.collapsed { + position: static; + flex-basis: auto; + height: auto; + border-left: 0; + border-top: 1px solid var(--qa-line); + box-shadow: none; + } + + .guide.collapsed { + grid-auto-flow: column; + justify-content: start; + padding: 12px 16px; + border-top: 4px solid var(--indigo); + } + + .guide.collapsed .guide-rail { + grid-auto-flow: column; + } + + .guide-rail-label { + writing-mode: horizontal-tb; } } diff --git a/6. Coordinating Multiple Tasks/CoordinatingTasks.pptx b/6. Coordinating Multiple Tasks/CoordinatingTasks.pptx index fc29f5c..a76a352 100644 Binary files a/6. Coordinating Multiple Tasks/CoordinatingTasks.pptx and b/6. Coordinating Multiple Tasks/CoordinatingTasks.pptx differ diff --git a/6. Coordinating Multiple Tasks/README.md b/6. Coordinating Multiple Tasks/README.md index 9106c08..33f4c46 100644 --- a/6. Coordinating Multiple Tasks/README.md +++ b/6. Coordinating Multiple Tasks/README.md @@ -12,24 +12,28 @@ The **1. Start** folder contains the intentionally imperfect code you will edit. 4. Build the project once so you can confirm your environment is ready. 5. Run the project and leave it running while you read the code. -The app runs at [http://localhost:5009](http://localhost:5009). +The starter app runs at [http://localhost:5009](http://localhost:5009). The finished app runs at [http://localhost:5010](http://localhost:5010), so you can run both at the same time and compare them later. + +The app opens on the **Product page**, with the workshop guide docked beside it. Every step in the guide is one thing wrong with how the product page waits for its backend services, and every time the app starts it checks your code against them. Right now the guide says it stopped at Step 1, and the terminal running the app shows the same result, with a hint. ## 2. Inspect the Starting Code 1. Open **ProductDetails/Services/BackendServices.cs** and note what each service costs: inventory 700ms, pricing 900ms, reviews 1200ms, shipping 600ms, and recommendations 800ms. 2. Notice that `RecommendationsService.GetRecommendationsAsync(...)` always throws `HttpRequestException`. The flaky service is always the one you depend on. -3. Open **ProductDetails/Components/Pages/Product.razor.cs** and find each `// ToDo Refactor` comment. +3. Open **ProductDetails/Components/Pages/Product.razor.cs** and find each `// ToDo Refactor (Step N)` comment. The step number tells you which step checks that line. 4. Read `LoadProductAsync()` from top to bottom before you change anything. 5. Open **ProductDetails/Components/Pages/Product.razor** and see how one card renders in each of its four states: `waiting`, `ready`, `failed`, and `skipped`. +6. Open the **ProductDetails/Steps** folder. Each `Step*.cs` file renders your product page in the background with fresh copies of the five services, times it, and records every repaint, with comments that explain what each expected result checks and why. These files are your guide. +7. Skim **ProductDetails/Verification** and **ProductDetails/Components/Layout/WorkshopGuide.razor**. They are the workshop plumbing that runs the steps and draws the guide beside the app. You do not need to change them. -Now watch the app in the browser. The page loads once when it opens, and the **Load product page** button runs the same code again: +Now watch the **Product page** beside the guide. The page loads once when it opens, and the **Load product page** button runs the same code again: -1. Nothing happens for about four seconds. The button spins and all five cards say `waiting`. +1. Nothing happens for about four seconds. The button spins, the launch price is a grey placeholder, and all five cards say `waiting`. 2. Four cards then appear at the same instant, at the very end, instead of appearing as their services answer. 3. The total page load tile reads **4.2s**. 4. The per-card timings read 0.7s, 1.6s, 2.8s, and 3.4s. Those are running totals, and they add up to the sum of every service call. 5. The Recommendations card shows `--` and the words `never requested` with a red left edge. Its service was next in line and never got called. -6. A yellow banner sits across the top of the page: "The page load stopped. A backend service did not respond. Every panel below it was never requested." The banner does not name the service. The terminal running the app does: the logged `HttpRequestException` reads "Recommendations service returned 503 Service Unavailable". +6. A yellow banner sits between the product details and the page panels: "The page load stopped. A backend service did not respond. Certain panel updates have been skipped." The banner does not name the service. The terminal running the app does: the logged `HttpRequestException` reads "Recommendations service returned 503 Service Unavailable". Pay attention to these clues: @@ -40,37 +44,46 @@ Pay attention to these clues: 5. One `catch (HttpRequestException)` covers all five calls, so the first failure skips everything after it. 6. Move the recommendations call above the reviews call and two more cards go blank. How much of the page dies is decided by the shape of the code, not by the failure. +The app walks you through the challenge one step at a time: + +1. The steps, in order, are **Start every call before awaiting any of them**, **Let one failing service break only its own card**, and **Paint each card as its service answers**. +2. A step unlocks only after the step before it passes. Each step's tab in the guide tells the story behind the bug, how to see it on the Product page, which file to change, and a task list for that step. If you get stuck, it has clues. +3. Each step renders your product page the way a browser tab does and shows a checklist of every expected result next to what actually happened: how long the page took, what every card says, and every time the page repainted. Every result that does not match comes with a hint, and the same hint is printed in the terminal running the app. +4. Every time the app starts, it checks your code against every step, so the workshop guide always reflects the code you have now. +5. Stop and run the app again after each change. If your IDE applied the change with Hot Reload, click **Run every step** in the workshop guide instead. + ## 3. Challenge: Coordinate the Product Page Recommended time: 20 minutes. > **Note:** Please avoid letting AI Agents solve the challenges for you. You're smart. You got this. Use them to understand the existing code, clarify how `Task.WhenAll`, `Task.WhenAny`, and `Task.WhenEach` differ, interpret errors, and ask questions that help you decide what to change. The goal is to practice the reasoning yourself. -Refactor **Product.razor.cs** so the five backend calls are all in flight at the same time and a single failing service can only damage its own card. +Refactor **Product.razor.cs** so the five backend calls are all in flight at the same time and a single failing service can only damage its own card. Leave the services in **BackendServices.cs**, the models, and the markup in **Product.razor** alone. -Requirements: +Requirements, in the order the steps check them: 1. Start all five backend calls before you await any of them. 2. Coordinate them with `Task.WhenAll`, `Task.WhenAny`, or `Task.WhenEach`. 3. Keep the total page load at the cost of the slowest service, not the sum of all five. -4. Give each call its own error handling so a failure is recorded against one card. -5. Set the failing card's status to `"failed"` and put a message the page owns on that card. Keep logging the full exception server-side the way the starter already does, and keep exception text out of the browser. -6. Repaint the page as each service answers instead of once at the end. -7. Keep each card's timing the elapsed time at which that service answered. -8. Stop reporting a single card's failure as a failure of the whole page. -9. Keep passing a `CancellationToken` to every service call. The starter passes `CancellationToken.None`, and so does the finished version, so keep it there and the call sites stay ready for a real token. +4. Keep passing a `CancellationToken` to every service call. The starter passes `CancellationToken.None`, and so does the finished version, so keep it there and the call sites stay ready for a real token. +5. Give each call its own error handling so a failure is recorded against one card. Catch `HttpRequestException`, the exception a failing HTTP dependency throws, rather than every exception. +6. Set the failing card's status to `"failed"` and put a message the page owns on that card. Keep logging the full exception server-side the way the starter already does, and keep exception text out of the browser. +7. Stop reporting a single card's failure as a failure of the whole page. +8. Repaint the page as each service answers instead of once at the end. +9. Keep each card's timing the elapsed time at which that service answered. 10. Keep using `ConfigureAwait(false)`, and keep marshaling each repaint back through `InvokeAsync(StateHasChanged)`. Acceptance checks: 1. **ProductDetails.slnx** builds. -2. The total page load tile reads about **1.2s** instead of 4.2s. -3. Cards fill in one at a time as their services answer, rather than all appearing together at the end. -4. The per-card timings read roughly 0.6s, 0.7s, 0.8s, 0.9s, and 1.2s. No card reports 1.6s, 2.8s, or 3.4s. -5. The recommendations service still fails, because you cannot fix somebody else's 503. Its card now reads `Unavailable` with a failure message and its own timing, in the failure style with a red left edge, and the terminal running the app carries the full `HttpRequestException`. Every other panel is unaffected. -6. The yellow banner across the top of the page is gone. -7. Clicking **Load product page** again gives the same result every time. -8. Your code is ready to compare with **2. Finish/ProductDetails**. +2. The workshop guide shows 3 of 3 steps pass after the app starts. +3. On the Product page, the total page load tile reads about **1.2s** instead of 4.2s. +4. Cards fill in one at a time as their services answer, rather than all appearing together at the end. +5. The per-card timings read roughly 0.6s, 0.7s, 0.8s, 0.9s, and 1.2s. No card reports 1.6s, 2.8s, or 3.4s. +6. The recommendations service still fails, because you cannot fix somebody else's 503. Its card now reads `Unavailable` with a failure message and its own timing, in the failure style with a red left edge, and the terminal running the app carries the full `HttpRequestException`. Every other panel is unaffected. +7. The yellow banner above the page panels is gone. +8. Clicking **Load product page** again gives the same result every time. +9. Your code is ready to compare with **2. Finish/ProductDetails**. ## 4. Review the Solution diff --git a/6. Coordinating Multiple Tasks/SOLUTION.md b/6. Coordinating Multiple Tasks/SOLUTION.md index 49320cd..c2e84dd 100644 --- a/6. Coordinating Multiple Tasks/SOLUTION.md +++ b/6. Coordinating Multiple Tasks/SOLUTION.md @@ -2,14 +2,16 @@ Use this during the guided walkthrough after the challenge and group review in [README.md](README.md). -The completed project is **2. Finish/ProductDetails**. Compare your decisions with the finished Blazor sample as we rebuild the solution step by step. Only one source file you will edit differs between **1. Start** and **2. Finish**: `Components/Pages/Product.razor.cs`. The only other difference is the port in `Properties/launchSettings.json`, so both apps can run side by side, with Start on 5009 and Finish on 5010. The five services, the models, and the Razor markup are already correct, which is worth saying out loud. Nothing about this problem is fixed by changing the backend. +The completed project is **2. Finish/ProductDetails**. Compare your decisions with the finished Blazor sample as we rebuild the solution step by step. Each section below names the workshop step it makes pass, so you can follow along in the workshop guide. + +Only one source file you will edit differs between **1. Start** and **2. Finish**: `Components/Pages/Product.razor.cs`. The only other difference is the port in `Properties/launchSettings.json`, so both apps can run side by side, with Start on 5009 and Finish on 5010. Both share the same workshop plumbing: the `Steps` and `Verification` folders, and the workshop guide in `Components/Layout/WorkshopGuide.razor`. The five services, the models, and the Razor markup are already correct, which is worth saying out loud. Nothing about this problem is fixed by changing the backend. ## 1. Read the Starter Method Again The starter awaits each service on the same line that starts it: ```cs -// ToDo Refactor: these five services do not depend on each other, but +// ToDo Refactor (Step 1): these five services do not depend on each other, but // each await waits for the previous one to finish. The page costs the // sum of every latency instead of the slowest one. var inventory = await InventoryService.GetInventoryAsync(_sku, CancellationToken.None).ConfigureAwait(false); @@ -24,7 +26,7 @@ await SetPanelAsync("Pricing", "ready", $"{pricing.YourPrice:C} (list {pricing.L The second problem is at the bottom of the same method: ```cs - // ToDo Refactor: this service is down. Every call shares one try block, + // ToDo Refactor (Step 2): this service is down. Every call shares one try block, // so its failure is the whole page's failure. Move it above Reviews and // two more panels go blank. One flaky service should degrade one panel. var recommendations = await RecommendationsService.GetRecommendationsAsync(_sku, CancellationToken.None).ConfigureAwait(false); @@ -39,6 +41,7 @@ catch (HttpRequestException e) // The continuation is off Blazor's renderer, so these writes go back through it await InvokeAsync(() => { + // ToDo Refactor (Step 2): one card's failure is reported here as a failure of the whole page PageError = "A backend service did not respond. Certain panel updates have been skipped."; // Anything still waiting when the load stopped will never arrive @@ -49,7 +52,7 @@ catch (HttpRequestException e) One `try` block covers all five calls. When the recommendations service throws, control jumps straight to the `catch`, the page sets a page-level error, and the last `SetPanelAsync(...)` never runs at all, so the Recommendations card is marked `skipped` and reads `never requested`. Move that call above the reviews call and two more panels go blank. How much of the page dies has nothing to do with the failure and everything to do with where the call sits in the block. -## 2. Start Every Call Before You Await Any of Them +## 2. Start Every Call Before You Await Any of Them (Step 1) Replace the five sequential awaits with five calls that are all started before anything is awaited: @@ -81,7 +84,11 @@ Every call still passes `CancellationToken.None`, exactly as the starter did. No Notice that `panelTasks` is a `List` and not a `List>`. Each service returns a different type, so there is no single result type to collect. Each wrapper records its own panel, and the list exists only so the page has something to wait on. -## 3. Wrap Each Call So One Failure Degrades One Panel +Step 1 renders your page in the background and times the load. It has to finish in about 1.2 seconds, and no later than 1.6, and no card may report a running total such as the starter's 1.6s, 2.8s or 3.4s. It also watches Blazor's renderer while the page waits, so a blocking `Task.WaitAll(...)` that happens to finish in 1.2 seconds does not pass. That wait holds the thread the page renders on, and `SetPanelAsync(...)` needs that thread, so a blocking wait here can deadlock the page outright. + +Starting every call at once is enough for Step 1 even with the original single `try` block still in place. Try it: wrap the five started calls in one `await Task.WhenAll(...)` inside that `try`, and Step 1 passes while Step 2 fails. The recommendations failure now surfaces after 1.2 seconds instead of 4.2, and all five cards read `never requested`. + +## 3. Wrap Each Call So One Failure Degrades One Panel (Step 2) The wrapper is where the failure is contained: @@ -113,12 +120,14 @@ Catch the exception you actually expect. `HttpRequestException` is what a failin Because every failure is handled inside the wrapper, the `Task` the wrapper returns always completes successfully. That matters for the next step. +Step 2 checks exactly this contract: the Recommendations card reads `failed` with a message that contains no exception text, the other four cards read `ready`, nothing on the page mentions the 503, the page-level warning banner is gone, and the page logged the full `HttpRequestException` server-side. + One detail in the wrapper is worth stopping on. `SetPanelAsync(...)` is awaited, and it is awaited because it marshals its own write: ```cs // All five wrappers write here at once, from whichever Thread Pool thread // their own service finished on, while Product.razor renders Panels with a -// foreach. Marshalling the write keeps every mutation on the renderer. +// foreach. Marshaling the write keeps every mutation on the renderer. protected Task SetPanelAsync(string name, string status, string? detail, TimeSpan elapsed) => InvokeAsync(() => { @@ -137,7 +146,23 @@ Writing through the `List` indexer increments the list's internal version cou `InvokeAsync(...)` closes the window by putting the write on Blazor's renderer, where the render also runs. The renderer processes one work item at a time, so a write can no longer overlap a render. Note that it does not call `StateHasChanged()`. Recording the panel and repainting the page are two separate decisions here, and the next step is where the repaint belongs. -## 4. Stream the Completions With Task.WhenEach +## 4. Stream the Completions With Task.WhenEach (Step 3) + +The starter repaints exactly once after the calls start, at the very end: + +```cs +await InvokeAsync(() => +{ + TotalSeconds = stopwatch.Elapsed.TotalSeconds; + IsLoading = false; + + // ToDo Refactor (Step 3): this is the only repaint after the service calls start, + // so no card reaches the screen until the whole load is over + StateHasChanged(); +}).ConfigureAwait(false); +``` + +`SetPanelAsync(...)` records a card, but recording is not repainting. The repaint belongs in the loop that observes each completion: ```cs // Task.WhenEach streams each task as it finishes, so every panel paints @@ -154,13 +179,15 @@ await foreach (var finishedPanel in Task.WhenEach(panelTasks)) `Task.WhenEach` was added in .NET 9. It returns an `IAsyncEnumerable` that hands you each task the moment that task completes, so `await foreach` runs its body five times at 0.6s, 0.7s, 0.8s, 0.9s, and 1.2s. In practice that is finishing order rather than the order you built the list in, and the documentation deliberately stops short of guaranteeing an exact order for tasks that finish at nearly the same instant. Depend on getting each task as soon as it is done, not on the sequence. There is no list to trim and no bookkeeping to forget. -`Task.WhenEach` yields the `Task`, not the result, which is why the body awaits `finishedPanel`. Step 3 already caught the only failure the wrapper can produce, so that `await` observes completion and nothing more. +`Task.WhenEach` yields the `Task`, not the result, which is why the body awaits `finishedPanel`. The wrapper from Step 2 already caught the only failure it can produce, so that `await` observes completion and nothing more. -`InvokeAsync(StateHasChanged)` is what turns each completion into a repaint, and putting it here rather than inside `SetPanelAsync(...)` is deliberate. Every `await` in this method uses `ConfigureAwait(false)`, so the continuation may not be on Blazor's renderer, and the render has to be marshalled for the same reason the panel write was. +`InvokeAsync(StateHasChanged)` is what turns each completion into a repaint, and putting it here rather than inside `SetPanelAsync(...)` is deliberate. Every `await` in this method uses `ConfigureAwait(false)`, so the continuation may not be on Blazor's renderer, and the render has to be marshaled for the same reason the panel write was. The ordering works out because `SetPanelAsync(...)` is awaited inside the wrapper. The panel write is already on the renderer and already done before that wrapper's task completes, so by the time `Task.WhenEach` hands the task back and this loop repaints, the new panel is there to draw. One repaint per completion, and each one draws exactly the panel that just arrived. -## 5. Stop the Page From Owning a Panel's Failure +Step 3 records every time the page paints. It expects at least three of the paints with 1, 2, 3 and 4 cards answered on the way to 5, a paint where Shipping (0.6s) is ready while Reviews (1.2s) is still waiting, and each card's timing within a moment of its own service's cost. Then it presses **Load product page** again and expects the same cards, in about 1.2 seconds, with `IsLoading` back to `false`. + +## 5. Stop the Page From Owning a Panel's Failure (Finishing Step 2) Every failure now belongs to a panel, so the page-level error has nothing left to report: @@ -214,7 +241,7 @@ These two are not what **2. Finish** uses, but both are correct answers to the t await Task.WhenAll(panelTasks).ConfigureAwait(false); ``` -The difference is when the user sees anything. `Task.WhenAll` completes once, at 1.2 seconds, so all five cards appear together unless each wrapper repaints itself as it finishes. If you kept the wrappers and moved `InvokeAsync(StateHasChanged)` into `TrackPanelAsync`, `Task.WhenAll` passes every acceptance check in the challenge. +The difference is when the user sees anything. `Task.WhenAll` completes once, at 1.2 seconds, so all five cards appear together unless each wrapper repaints itself as it finishes. That is exactly where the workshop steps draw the line: the wrappers with a single `await Task.WhenAll(panelTasks)` pass Steps 1 and 2 and stop at Step 3, because nothing repaints until the end. If you kept the wrappers and moved `InvokeAsync(StateHasChanged)` into `TrackPanelAsync`, `Task.WhenAll` passes every acceptance check in the challenge and every workshop step. `Task.WhenAll` is also the only one of the three that collects failures for you. If you had not wrapped each call, `await Task.WhenAll(...)` would rethrow only one exception, and it is the first faulted task in the order you passed them in, not the first one to fail. The rest are still there: hold on to the task, and after it faults, its `Exception` property is an `AggregateException` whose `InnerExceptions` holds every failure. @@ -235,7 +262,7 @@ while (pending.Count > 0) } ``` -`pending.Remove(finished)` is the whole trick. `Task.WhenAny` hands back the task that finished, and a finished task stays finished, so leaving it in the list means the next call returns the same task again and the loop never ends. Awaiting `finished` after you remove it does the same job as step 4: it observes the completion so nothing is silently dropped if a wrapper ever does throw. `Task.WhenEach` exists so you never have to remember any of this. +`pending.Remove(finished)` is the whole trick. `Task.WhenAny` hands back the task that finished, and a finished task stays finished, so leaving it in the list means the next call returns the same task again and the loop never ends. The workshop steps report that as a page that never finishes loading. Awaiting `finished` after you remove it does the same job as the `await finishedPanel` in the `Task.WhenEach` loop: it observes the completion so nothing is silently dropped if a wrapper ever does throw. `Task.WhenEach` exists so you never have to remember any of this. ## 7. Check It in the Browser @@ -245,7 +272,7 @@ Run the finished app from **2. Finish**, which is configured for port 5010 so it dotnet run --project ProductDetails/ProductDetails.csproj ``` -Open [http://localhost:5010](http://localhost:5010) and confirm each of these: +Open [http://localhost:5010](http://localhost:5010). The app opens on the Product page, and the workshop guide beside it shows 3 of 3 steps pass as soon as the app starts. Press **Load product page** and confirm each of these: 1. Shipping lands first at 0.6s. 2. Inventory follows at 0.7s. @@ -253,7 +280,7 @@ Open [http://localhost:5010](http://localhost:5010) and confirm each of these: 4. Pricing lands at 0.9s. 5. Reviews lands last at 1.2s. 6. The total page load tile reads 1.2s. -7. There is no yellow banner across the top of the page. +7. There is no yellow banner above the page panels. Same five services, same latencies, same broken dependency. The only thing that changed is when you awaited and where you caught. diff --git a/7. Data Parallelism/1. Start/ImportPortal/Components/Layout/MainLayout.razor b/7. Data Parallelism/1. Start/ImportPortal/Components/Layout/MainLayout.razor index 1c2a641..f745fc5 100644 --- a/7. Data Parallelism/1. Start/ImportPortal/Components/Layout/MainLayout.razor +++ b/7. Data Parallelism/1. Start/ImportPortal/Components/Layout/MainLayout.razor @@ -1,5 +1,9 @@ @inherits LayoutComponentBase -
- @Body -
+
+
+ @Body +
+ + +
diff --git a/7. Data Parallelism/1. Start/ImportPortal/Components/Layout/StepBadge.razor b/7. Data Parallelism/1. Start/ImportPortal/Components/Layout/StepBadge.razor new file mode 100644 index 0000000..352439e --- /dev/null +++ b/7. Data Parallelism/1. Start/ImportPortal/Components/Layout/StepBadge.razor @@ -0,0 +1,10 @@ +@inherits StepBadgeBase + +@if (IsCompact) +{ + @Label +} +else +{ + @Icon @Label +} diff --git a/7. Data Parallelism/1. Start/ImportPortal/Components/Layout/StepBadge.razor.cs b/7. Data Parallelism/1. Start/ImportPortal/Components/Layout/StepBadge.razor.cs new file mode 100644 index 0000000..d2013ae --- /dev/null +++ b/7. Data Parallelism/1. Start/ImportPortal/Components/Layout/StepBadge.razor.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Components; + +namespace ImportPortal.Components.Layout; + +public partial class StepBadgeBase : ComponentBase +{ + [Parameter, EditorRequired] + public StepStatus Status { get; set; } + + [Parameter] + public bool IsUnlocked { get; set; } = true; + + // Only the icon, for the collapsed guide + [Parameter] + public bool IsCompact { get; set; } + + public string Icon => Status is StepStatus.Running ? "⏳" : !IsUnlocked ? "🔒" : Status switch + { + StepStatus.Passed => "✅", + StepStatus.Failed or StepStatus.TimedOut or StepStatus.Crashed => "❌", + _ => "•", + }; + + public string Label => Status is StepStatus.Running ? "Running" : !IsUnlocked ? "Locked" : Status switch + { + StepStatus.Passed => "Passed", + StepStatus.Failed => "Failed", + StepStatus.TimedOut => "Timed out", + StepStatus.Crashed => "Crashed", + StepStatus.Stopped => "Stopped", + _ => "Not run", + }; + + public string CssClass => Status is StepStatus.Running ? "badge-running" : !IsUnlocked ? "badge-locked" : Status switch + { + StepStatus.Passed => "badge-passed", + StepStatus.Failed or StepStatus.TimedOut or StepStatus.Crashed => "badge-failed", + _ => "badge-idle", + }; +} \ No newline at end of file diff --git a/7. Data Parallelism/1. Start/ImportPortal/Components/Layout/WorkshopGuide.razor b/7. Data Parallelism/1. Start/ImportPortal/Components/Layout/WorkshopGuide.razor new file mode 100644 index 0000000..86b5de3 --- /dev/null +++ b/7. Data Parallelism/1. Start/ImportPortal/Components/Layout/WorkshopGuide.razor @@ -0,0 +1,321 @@ +@inherits WorkshopGuideBase + + diff --git a/7. Data Parallelism/1. Start/ImportPortal/Components/Layout/WorkshopGuide.razor.cs b/7. Data Parallelism/1. Start/ImportPortal/Components/Layout/WorkshopGuide.razor.cs new file mode 100644 index 0000000..4141e6d --- /dev/null +++ b/7. Data Parallelism/1. Start/ImportPortal/Components/Layout/WorkshopGuide.razor.cs @@ -0,0 +1,179 @@ +using Microsoft.AspNetCore.Components; + +namespace ImportPortal.Components.Layout; + +// Workshop plumbing: the guide docked beside the app. It shows your challenge, every step's result, and the step you are working on. +public partial class WorkshopGuideBase : ComponentBase, IAsyncDisposable +{ + // Selecting this shows your challenge instead of a step + public const int ChallengeTab = 0; + + // A run that fails quickly can finish faster than anyone can see a spinner + static readonly TimeSpan _minimumActivityIndicatorTime = TimeSpan.FromMilliseconds(600); + + readonly CancellationTokenSource _disposeCancellationTokenSource = new(); + + Task _polling = Task.CompletedTask; + bool _isStartingCheck; + int? _startingRunStepNumber; + + [Inject] + public required StepVerifier Verifier { get; init; } + + [Inject] + public required ILogger Logger { get; init; } + + public int SelectedTab { get; private set; } = ChallengeTab; + + public bool IsCollapsed { get; private set; } + + public string? ErrorMessage { get; private set; } + + public int PassedStepCount => Verifier.Steps.Count(step => Verifier.GetState(step.Number).Status is StepStatus.Passed); + + public bool IsCheckingSteps => _isStartingCheck || Verifier.IsCheckingAllSteps; + + // The first step that does not pass yet, which is the one to work on next + public WorkshopStep? StoppedAtStep => Verifier.Steps.FirstOrDefault(step => Verifier.GetState(step.Number).Status is not StepStatus.Passed); + + // The last step the check reaches: the first one that did not pass, or the last step when they all passed + public WorkshopStep LastCheckedStep => StoppedAtStep ?? Verifier.Steps[^1]; + + public WorkshopStep? SelectedStep => Verifier.FindStep(SelectedTab); + + // The run lives in the verifier, not in this guide, so a step that is still running shows up again after a reload + public ActiveStepRun? SelectedRun => Verifier.ActiveRun is { } run && run.Step.Number == SelectedTab ? run : null; + + public bool IsSelectedStepRunning => _startingRunStepNumber == SelectedTab || SelectedRun is not null; + + public async ValueTask DisposeAsync() + { + // Only stops refreshing the guide. A step that is running keeps running. + await _disposeCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + await _polling.ConfigureAwait(false); + + _disposeCancellationTokenSource.Dispose(); + + GC.SuppressFinalize(this); + } + + protected override void OnInitialized() + { + _polling = PollVerifier(_disposeCancellationTokenSource.Token); + } + + protected void SelectTab(int tab) + { + SelectedTab = tab; + IsCollapsed = false; + ErrorMessage = null; + } + + protected void ToggleCollapsed() => IsCollapsed = !IsCollapsed; + + protected async Task CheckAllStepsAsync() + { + var token = _disposeCancellationTokenSource.Token; + string? errorMessage = null; + + ErrorMessage = null; + _isStartingCheck = true; + + // Show the activity indicator before the check starts + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + try + { + // Keep the indicator on screen long enough to notice, even when Step 1 fails quickly + await Task.WhenAll(Verifier.CheckAllSteps(), Task.Delay(_minimumActivityIndicatorTime, token)).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The guide was closed while the steps were being checked + return; + } + catch (Exception e) + { + Logger.LogError(e, "Checking every step failed"); + + errorMessage = "Checking the steps failed. The terminal running the app has the details."; + } + + // The continuation is off Blazor's renderer, so every component state change goes back through it + await InvokeAsync(() => + { + ErrorMessage = errorMessage; + _isStartingCheck = false; + + StateHasChanged(); + }).ConfigureAwait(false); + } + + protected async Task RunSelectedStepAsync() + { + if (SelectedStep is not { } step) + return; + + var token = _disposeCancellationTokenSource.Token; + string? errorMessage = null; + + ErrorMessage = null; + _startingRunStepNumber = step.Number; + + // Show the activity indicator before the run starts + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + try + { + // Keep the indicator on screen long enough to notice, even when the step fails quickly + await Task.WhenAll(Verifier.RunStep(step), Task.Delay(_minimumActivityIndicatorTime, token)).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The guide was closed while the step was running + return; + } + catch (Exception e) + { + Logger.LogError(e, "Running Step {StepNumber} failed", step.Number); + + errorMessage = "The step could not be run. The terminal running the app has the details."; + } + + // The continuation is off Blazor's renderer, so every component state change goes back through it + await InvokeAsync(() => + { + ErrorMessage = errorMessage; + _startingRunStepNumber = null; + + StateHasChanged(); + }).ConfigureAwait(false); + } + + protected void StopRun() => Verifier.StopRun(); + + // Re-renders while the verifier is busy, plus once more when it finishes, so the live log and results appear without a refresh + async Task PollVerifier(CancellationToken token) + { + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100)); + var wasBusy = true; + + try + { + while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false)) + { + var isBusy = Verifier.IsBusy; + + if (isBusy || wasBusy) + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + wasBusy = isBusy; + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // Expected when the guide is disposed + } + } +} \ No newline at end of file diff --git a/7. Data Parallelism/1. Start/ImportPortal/Components/Pages/Import.razor b/7. Data Parallelism/1. Start/ImportPortal/Components/Pages/Import.razor index 39b3bfa..4cbaea3 100644 --- a/7. Data Parallelism/1. Start/ImportPortal/Components/Pages/Import.razor +++ b/7. Data Parallelism/1. Start/ImportPortal/Components/Pages/Import.razor @@ -1,87 +1,168 @@ @page "/" @inherits ImportPageBase -ImportPortal - -
-
-
-

ImportPortal

-

Nightly order import

-

Validate @RowCount.ToString("N0") uploaded rows, enrich each one from the customer API, then summarize by region.

-
- -
- +ImportPortal · Nightly order import -
- @Environment.ProcessorCount - processors available -
-
+
+
+ ImportPortal + Data operations Jobs orders-nightly + prod · nightly
- @if (Report is null && !IsBusy) - { -
-

What to look for

-

Run the import and read two things: how long each stage takes, and whether every row was actually validated and enriched. A stage that finishes suspiciously fast may not have done anything.

-
- } - - @if (Report is not null) - { -
-

Import stages

-

Every row must be validated and enriched. Anything less than @RowCount.ToString("N0") means work was skipped.

- -
-
+
+
+
+

Scheduled job · orders-nightly

+

Nightly order import

+

Validate @RowCount.ToString("N0") uploaded rows, enrich each one from the customer API, then summarize by region.

+ +
+
+
Source
+
uploads/orders.csv
+
+
+
Rows
+
@RowCount.ToString("N0")
+
+
+
Schedule
+
02:00 nightly
+
+
+
+ +
+
+ @Environment.ProcessorCount + processors available +
+ + +
+
+ +
+
+
+

Import stages

+ @if (Report is null) + { +

Run the import and read two things: how long each stage takes, and whether every row was actually validated and enriched. A stage that finishes suspiciously fast may not have done anything.

+ } + else + { +

Every row must be validated and enriched. Anything less than @RowCount.ToString("N0") means work was skipped.

+ } +
+ + @(IsBusy ? "Running" : Report is null ? "Not run yet" : "Last run finished") +
+ +
+
+
+ + CPU-bound +
Validated - @Report.RowsValidated.ToString("N0") - of @RowCount.ToString("N0") in @Report.ValidateElapsed.TotalSeconds.ToString("F2")s + @(Report is null ? "—" : Report.RowsValidated.ToString("N0")) + @(Report is null ? $"of {RowCount:N0} rows" : $"of {RowCount:N0} in {Report.ValidateElapsed.TotalSeconds:F2}s")
-
+ + +
+
+ + I/O-bound +
Enriched - @Report.RowsEnriched.ToString("N0") - of @RowCount.ToString("N0") in @Report.EnrichElapsed.TotalSeconds.ToString("F2")s + @(Report is null ? "—" : Report.RowsEnriched.ToString("N0")) + @(Report is null ? $"of {RowCount:N0} rows" : $"of {RowCount:N0} in {Report.EnrichElapsed.TotalSeconds:F2}s")
-
+ + +
+
+ + Summary +
Report - @Report.ReportElapsed.TotalSeconds.ToString("F2")s + @(Report is null ? "—" : $"{Report.ReportElapsed.TotalSeconds:F2}s") grouped by region
+
-
+
+
Total - @((Report.ValidateElapsed + Report.EnrichElapsed + Report.ReportElapsed).TotalSeconds.ToString("F2"))s + @(Report is null ? "—" : $"{(Report.ValidateElapsed + Report.EnrichElapsed + Report.ReportElapsed).TotalSeconds:F2}s") end to end
-
-
-
-

Regional summary

-

Average risk is only meaningful once every row has been validated.

- -
- @foreach (var total in Report.RegionTotals) + @if (Report is not null) { -
- @total.Region - @total.Revenue.ToString("C0") - @total.Orders.ToString("N0") orders, avg risk @total.AverageRisk.ToString("N0") + }
-
- } -
+
+ +
+
+
+

Regional summary

+

Average risk is only meaningful once every row has been validated.

+
+
+ +
+ + + + + + + + + + + @if (Report is null) + { + + + + } + else + { + @foreach (var total in Report.RegionTotals) + { + + + + + + + } + } + +
RegionOrdersRevenueAvg risk
No summary yet. Run the import to group the rows by region.
@total.Region@total.Orders.ToString("N0")@total.Revenue.ToString("C0")@total.AverageRisk.ToString("N0")
+
+
+ + diff --git a/7. Data Parallelism/1. Start/ImportPortal/Program.cs b/7. Data Parallelism/1. Start/ImportPortal/Program.cs index 2737f45..e00ac36 100644 --- a/7. Data Parallelism/1. Start/ImportPortal/Program.cs +++ b/7. Data Parallelism/1. Start/ImportPortal/Program.cs @@ -15,6 +15,11 @@ public static void Main(string[] args) builder.Services.AddSingleton(); builder.Services.AddSingleton(); + // Workshop plumbing: one StepVerifier holds every step's result. It is also a hosted service, + // so it checks your ImportService against every step each time the app starts. + builder.Services.AddSingleton(); + builder.Services.AddHostedService(static serviceProvider => serviceProvider.GetRequiredService()); + var app = builder.Build(); if (!app.Environment.IsDevelopment()) diff --git a/7. Data Parallelism/1. Start/ImportPortal/Services/CustomerApiService.cs b/7. Data Parallelism/1. Start/ImportPortal/Services/CustomerApiService.cs index 9efe82e..73d4a0b 100644 --- a/7. Data Parallelism/1. Start/ImportPortal/Services/CustomerApiService.cs +++ b/7. Data Parallelism/1. Start/ImportPortal/Services/CustomerApiService.cs @@ -6,10 +6,46 @@ public sealed class CustomerApiService { static readonly string[] _tiers = ["Bronze", "Silver", "Gold", "Platinum"]; + // Workshop instrumentation: the workshop steps read these counters to see how many calls + // ran at the same time. You do not need to change this file. + int _callsInFlight; + int _peakCallsInFlight; + int _callsCompleted; + + public int CallsInFlight => Volatile.Read(ref _callsInFlight); + + public int PeakCallsInFlight => Volatile.Read(ref _peakCallsInFlight); + + public int CallsCompleted => Volatile.Read(ref _callsCompleted); + public async Task GetCustomerTierAsync(string sku, CancellationToken token) { - await Task.Delay(TimeSpan.FromMilliseconds(10), token).ConfigureAwait(false); + RecordCallStarted(); + + try + { + await Task.Delay(TimeSpan.FromMilliseconds(10), token).ConfigureAwait(false); + + Interlocked.Increment(ref _callsCompleted); + + return _tiers[Math.Abs(sku.GetHashCode(StringComparison.Ordinal)) % _tiers.Length]; + } + finally + { + Interlocked.Decrement(ref _callsInFlight); + } + } + + void RecordCallStarted() + { + var callsInFlight = Interlocked.Increment(ref _callsInFlight); + + // Raise the peak only if no other call raised it higher in the meantime + var peak = Volatile.Read(ref _peakCallsInFlight); - return _tiers[Math.Abs(sku.GetHashCode(StringComparison.Ordinal)) % _tiers.Length]; + while (callsInFlight > peak && Interlocked.CompareExchange(ref _peakCallsInFlight, callsInFlight, peak) != peak) + { + peak = Volatile.Read(ref _peakCallsInFlight); + } } } \ No newline at end of file diff --git a/7. Data Parallelism/1. Start/ImportPortal/Services/ImportService.cs b/7. Data Parallelism/1. Start/ImportPortal/Services/ImportService.cs index d574149..3880e60 100644 --- a/7. Data Parallelism/1. Start/ImportPortal/Services/ImportService.cs +++ b/7. Data Parallelism/1. Start/ImportPortal/Services/ImportService.cs @@ -11,8 +11,10 @@ public async Task RunImportAsync(int rowCount, CancellationToken t // Step 1: validate every row. This is CPU-bound work. var validateStopwatch = Stopwatch.StartNew(); - // ToDo Refactor: every row is scored on one thread while the other + // ToDo Refactor (Step 1): every row is scored on one thread while the other // cores sit idle. Nothing here depends on the row before it. + // ToDo Refactor (Step 3): this loop never looks at the token, so a cancelled + // import still scores every row before anything notices. foreach (var order in orders) { order.RiskScore = ScoreRisk(order); @@ -23,7 +25,7 @@ public async Task RunImportAsync(int rowCount, CancellationToken t // Step 2: enrich every row from the customer API. This is I/O-bound work. var enrichStopwatch = Stopwatch.StartNew(); - // ToDo Refactor: Parallel.ForEach takes an Action, not a Func. + // ToDo Refactor (Step 2): Parallel.ForEach takes an Action, not a Func. // This lambda is `async void`: ForEach starts each one and immediately // considers it finished, so this returns long before any call completes // and any exception inside it is rethrown where nothing can catch it. @@ -37,7 +39,7 @@ public async Task RunImportAsync(int rowCount, CancellationToken t // Step 3: summarize by region. var reportStopwatch = Stopwatch.StartNew(); - // ToDo Refactor: this query runs on one thread + // ToDo Refactor (Step 3): this query runs on one thread, and it ignores the cancellation token var regionTotals = orders .GroupBy(static order => order.Region) .Select(static group => new RegionTotal( @@ -50,6 +52,8 @@ public async Task RunImportAsync(int rowCount, CancellationToken t reportStopwatch.Stop(); + // ToDo Refactor (Step 2): this is the only await in the method. It yields + // the thread, but it does not wait for a single customer API call above. await Task.Yield(); return new ImportReport( diff --git a/7. Data Parallelism/1. Start/ImportPortal/Steps/Step1ScoreEveryRow.cs b/7. Data Parallelism/1. Start/ImportPortal/Steps/Step1ScoreEveryRow.cs new file mode 100644 index 0000000..4a53fc1 --- /dev/null +++ b/7. Data Parallelism/1. Start/ImportPortal/Steps/Step1ScoreEveryRow.cs @@ -0,0 +1,88 @@ +namespace ImportPortal; + +public sealed class Step1ScoreEveryRow : WorkshopStep +{ + // Roughly a second of validation on one thread, and a small fraction of that once every core helps + const int _rowCount = 1_000; + + const string _missingRowsHint = "Some rows were never scored. Every row in orders has to get its RiskScore, exactly once, before RunImportAsync moves on to the next stage."; + + const string _oneCoreHint = "Validation kept about one core busy: one thread scored every row while the other cores sat idle. " + + "Nothing in the loop body depends on another row, so hand the rows to a Parallel loop and let every processor score them at the same time."; + + public override int Number => 1; + + public override string Scenario => "Score every row on every core"; + + public override string Title => "RunImportAsync(), the validation stage"; + + public override string Story => "Every night ImportPortal scores each uploaded row for risk before it does anything else. ScoreRisk() is pure CPU work, and no row depends on another, " + + "yet the import works through the file one row at a time on one thread while every other core on the server sits idle. " + + "The answer is right. It just takes exactly as long as it would on a machine with a single processor, and the files get bigger every month."; + + public override string SeeItInTheApp => "On the Import page beside this guide, press Run import. Validated reads 4,000 of 4,000, and its time is almost all of the Total card. " + + "Compare that with the processors available tile in the header: every one of them was available, and one did all the work. Write the Total down as your baseline."; + + public override string FileToChange => "Services/ImportService.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Replace the sequential validation foreach so every processor scores rows at the same time.", + "Give the CPU stage its own ParallelOptions. Set MaxDegreeOfParallelism to a ceiling that suits CPU-bound work, and set CancellationToken to the token RunImportAsync already receives. Step 3 checks the token.", + "Leave ScoreRisk(OrderRow) exactly as it is. The goal is to run it on more threads, not to make it cheaper.", + "Do not add a shared counter, list, or dictionary that the loop body writes to. Each iteration only writes RiskScore on its own row.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "The validation loop is a plain foreach, and nothing in its body depends on the row before it.", + "Each iteration writes RiskScore on its own OrderRow and reads nothing another iteration wrote, so there is nothing to lock.", + "The Parallel method that looks most like foreach is already in this file. One of its overloads takes ParallelOptions, and for CPU-bound work Environment.ProcessorCount is the right ceiling: more threads than cores only adds context switching.", + ]; + + public override string TimeoutHint => "The import never finished. Check that the validation loop visits every row once and ends, and that nothing in RunImportAsync waits on work that cannot complete."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + + // A fresh ImportService, so this check never touches the singletons the Import page uses + var importService = new ImportService(new OrderFileService(), new CustomerApiService()); + + report.Log($"Importing {_rowCount:N0} rows through a fresh ImportService. This machine has {Environment.ProcessorCount} processors"); + + // CancellationToken.None, never context.Token: until Step 2 is fixed, the enrichment stage runs async void lambdas, + // and a cancelled token reaching one of them throws where nothing can catch it, which ends the whole app + var processorTimeBefore = GetProcessorTime(); + var importReport = await importService.RunImportAsync(_rowCount, CancellationToken.None).ConfigureAwait(false); + var processorTime = GetProcessorTime() - processorTimeBefore; + + report.Log($"Validation scored {importReport.RowsValidated:N0} rows in {importReport.ValidateElapsed.TotalSeconds:F2}s"); + + report.Expect("Every row is scored", _rowCount, importReport.RowsValidated, _missingRowsHint); + + // One thread can use at most one second of CPU time per second of validation, however hard it works. + // Almost all of the CPU time an import uses is spent scoring rows, so CPU time divided by the validation time is how many cores were busy. + // The other stages use a little CPU time too, which can push the result past the processor count, so it is capped there. + var coresBusy = importReport.ValidateElapsed > TimeSpan.Zero ? Math.Min(processorTime / importReport.ValidateElapsed, Environment.ProcessorCount) : 0; + + report.Log($"The import used {processorTime.TotalSeconds:F2}s of CPU time, so validation kept about {coresBusy:F1} cores busy"); + + if (Environment.ProcessorCount is 1) + { + report.Log("This machine reports 1 processor, so no loop can keep more than one core busy here. The next check passes on this machine without measuring anything"); + report.Expect("Validation keeps more than one core busy", "more than 1 core, on a machine with 2 or more processors", "not measured: this machine has 1 processor", true, _oneCoreHint); + return; + } + + // A single thread scores about 1.0. Every core helping scores close to the processor count, so these leave plenty of room for a busy laptop. + var requiredCoresBusy = Environment.ProcessorCount >= 4 ? 2.0 : 1.5; + + report.Expect( + "Validation keeps more than one core busy", + $"at least {requiredCoresBusy:F1} cores", + $"{coresBusy:F1} cores", + coresBusy >= requiredCoresBusy, + _oneCoreHint); + } +} \ No newline at end of file diff --git a/7. Data Parallelism/1. Start/ImportPortal/Steps/Step2EnrichEveryRow.cs b/7. Data Parallelism/1. Start/ImportPortal/Steps/Step2EnrichEveryRow.cs new file mode 100644 index 0000000..a2db5ea --- /dev/null +++ b/7. Data Parallelism/1. Start/ImportPortal/Steps/Step2EnrichEveryRow.cs @@ -0,0 +1,107 @@ +namespace ImportPortal; + +public sealed class Step2EnrichEveryRow : WorkshopStep +{ + // Small enough that even awaiting every call one at a time finishes well inside the step timeout + const int _rowCount = 400; + + const string _notAwaitedHint = "RunImportAsync returned before the customer API answered. An async lambda passed to Parallel.ForEach is async void, " + + "so the loop counts each row as done the moment its call starts. Use the Parallel overload that is built for asynchronous bodies, and await it."; + + const string _inFlightHint = "Customer API calls were still running after RunImportAsync returned, so the import reported on work that had not finished. " + + "The enrichment stage has to be awaited before the method moves on."; + + const string _oneAtATimeHint = "Only one customer API call ran at a time, so the import makes every round trip back to back. Let the calls run in parallel, up to a limit you choose."; + + const string _unboundedHint = "Every row's call was in flight at the same moment. Against a real customer API, that is how a nightly job takes down a service other teams depend on. " + + "Give the enrichment stage its own ParallelOptions, with a MaxDegreeOfParallelism well below the row count."; + + const string _forEachAsyncHint = "The enrichment stage needs the Parallel method whose body receives a CancellationToken and returns a ValueTask. It awaits every body, and it bounds how many run at once."; + + const string _asyncVoidHint = "An async lambda passed where an Action is expected compiles to async void. Nothing can await it, and an exception inside it ends the whole app. " + + "Every async body in ImportService has to return a Task or a ValueTask."; + + public override int Number => 2; + + public override string Scenario => "Enrich every row, and wait for it"; + + public override string Title => "RunImportAsync(), the enrichment stage"; + + public override string Story => "Every row is supposed to come back with the customer's tier from the internal customer API. " + + "Last week someone noticed that not one row has a tier, and that the import has reported success every night for a year. " + + "The stage that should be waiting on thousands of 10 millisecond calls finishes in a hundredth of a second. " + + "And the day the customer API answers one of those calls with an error, the exception is thrown where nothing can catch it, and it takes the whole process down."; + + public override string SeeItInTheApp => "On the Import page, press Run import. The Enriched card reads 0 of 4,000 in a hundredth of a second or less, and it is red. " + + "4,000 calls at 10 milliseconds each cannot finish in a hundredth of a second, and they did not: the import stopped waiting for them before a single one answered."; + + public override string FileToChange => "Services/ImportService.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Fix the enrichment stage so every row really is enriched, and so RunImportAsync does not return until it is.", + "Use the Parallel overload that is built for asynchronous bodies, and forward the CancellationToken that overload hands to your body.", + "Bound the enrichment stage with its own ParallelOptions, so you do not fire every call at the customer API at once. Pick a limit and be ready to explain the number you picked. The CPU stage and the I/O stage do not have to agree on the same ceiling.", + "Do not add a shared counter, list, or dictionary that parallel bodies write to. Each call only writes CustomerTier on its own row.", + "Leave CustomerApiService.cs alone. Its counters are how this step sees how many calls ran at the same time.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "The enrichment stage calls Parallel.ForEach with an async lambda, and the compiler is perfectly happy with it.", + "The stage that should be waiting on thousands of calls at 10 milliseconds each reports a hundredth of a second.", + "RunImportAsync is an async method whose only await is await Task.Yield().", + "The body Parallel.ForEach takes here is an Action, and no overload of it accepts a Task-returning body. An async lambda bound to a delegate that returns void becomes async void.", + "Parallel has an asynchronous sibling of ForEach. Its body receives a CancellationToken and returns a ValueTask. Without a MaxDegreeOfParallelism of your own, it runs one body per processor, and cores are the wrong unit for network calls.", + ]; + + public override string TimeoutHint => "The import never finished. Check that every customer API call can complete, and that the enrichment stage awaits the calls instead of blocking a thread on each one."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + + // Part 1: a fresh CustomerApiService counts how many calls are in flight, so this step can see what the import really waited for + var customerApi = new CustomerApiService(); + var importService = new ImportService(new OrderFileService(), customerApi); + + report.Log($"Importing {_rowCount:N0} rows through a fresh ImportService and CustomerApiService"); + + // CancellationToken.None, never context.Token: until this step is fixed, the enrichment stage runs async void lambdas, + // and a cancelled token reaching one of them throws where nothing can catch it, which ends the whole app + var importReport = await importService.RunImportAsync(_rowCount, CancellationToken.None).ConfigureAwait(false); + + // Read the moment RunImportAsync returns. A call still in flight is work the import reported on before it finished. + var callsInFlight = customerApi.CallsInFlight; + var callsCompleted = customerApi.CallsCompleted; + var peakCallsInFlight = customerApi.PeakCallsInFlight; + + report.Log($"RunImportAsync returned after {importReport.EnrichElapsed.TotalSeconds:F2}s of enrichment with {importReport.RowsEnriched:N0} rows enriched. " + + $"The customer API had answered {callsCompleted:N0} calls, and {callsInFlight:N0} were still in flight"); + + report.Expect("Every row has a customer tier when RunImportAsync returns", _rowCount, importReport.RowsEnriched, _notAwaitedHint); + report.Expect("No customer API call is still in flight when RunImportAsync returns", 0, callsInFlight, _inFlightHint); + + // Part 2: how many calls ran at the same time. More than one is parallel, and fewer than every row is bounded. + if (peakCallsInFlight >= _rowCount) + report.Log($"All {_rowCount:N0} calls were in flight at the same time"); + else if (peakCallsInFlight <= 1) + report.Log("Only one call was ever in flight, so the calls ran back to back"); + else if (peakCallsInFlight == Environment.ProcessorCount) + report.Log($"At most {peakCallsInFlight} calls ran at once, the same as this machine's {Environment.ProcessorCount} processors. That is the limit Parallel.ForEachAsync uses when you do not choose one. Be ready to explain why the customer API should get that number"); + else + report.Log($"At most {peakCallsInFlight} calls ran at once, and this machine has {Environment.ProcessorCount} processors. Network calls spend their time waiting, so their limit comes from what the customer API can take, not from the core count"); + + report.Expect("Several customer API calls run at the same time", "more than 1", $"{peakCallsInFlight:N0}", peakCallsInFlight > 1, _oneAtATimeHint); + report.Expect("The customer API is never sent every row at once", $"fewer than {_rowCount:N0}", $"{peakCallsInFlight:N0}", peakCallsInFlight < _rowCount, _unboundedHint); + + // Part 3: the compiled code. An async void lambda can look like it works on a good night, so this step reads ImportService's IL to find any that are left. + var callsForEachAsync = CodeInspector.GetCalledMethods(typeof(ImportService)).Contains("Parallel.ForEachAsync"); + var asyncVoidMethods = CodeInspector.FindAsyncVoidMethods(typeof(ImportService)); + + report.Log($"ImportService {(callsForEachAsync ? "calls" : "does not call")} Parallel.ForEachAsync, and has {asyncVoidMethods.Count} async void lambdas or methods"); + + report.Expect("ImportService calls Parallel.ForEachAsync", "called", callsForEachAsync ? "called" : "not called", callsForEachAsync, _forEachAsyncHint); + report.Expect("ImportService has no async void lambdas or methods left", 0, asyncVoidMethods.Count, _asyncVoidHint); + } +} \ No newline at end of file diff --git a/7. Data Parallelism/1. Start/ImportPortal/Steps/Step3SummarizeAndCancel.cs b/7. Data Parallelism/1. Start/ImportPortal/Steps/Step3SummarizeAndCancel.cs new file mode 100644 index 0000000..4d76215 --- /dev/null +++ b/7. Data Parallelism/1. Start/ImportPortal/Steps/Step3SummarizeAndCancel.cs @@ -0,0 +1,157 @@ +namespace ImportPortal; + +public sealed class Step3SummarizeAndCancel : WorkshopStep +{ + // Enough rows that scoring them takes a clearly measurable amount of CPU time + const int _rowCount = 1_000; + + // A cancelled import that stops before validation uses almost no CPU time. One that scores every row first uses about as much as a full import. + const double _maximumCancelledShare = 0.25; + + const string _summaryHint = "The regional summary no longer matches the uploaded file. Moving a query to PLINQ should not change its body at all: group by region, count, sum and average, then sort by region."; + + const string _plinqHint = "The regional summary still runs as a single-threaded LINQ query, or it ignores the token. Move the query to PLINQ, and make it honor the CancellationToken that RunImportAsync receives."; + + const string _ranToTheEndHint = "A cancelled import ran to the end and returned a report nobody wanted. Every stage has to honor the token RunImportAsync receives: " + + "set it on the ParallelOptions of each Parallel loop, hand it to the PLINQ query, and let the OperationCanceledException reach the caller."; + + const string _scoredRowsFirstHint = "The import did not notice the cancellation until it had scored every row. A foreach, or a Parallel loop whose ParallelOptions has no CancellationToken, never looks at the token. " + + "Give the validation stage's ParallelOptions the token RunImportAsync receives, and the loop checks it before it scores a single row."; + + const string _asyncVoidHint = "ImportService still has an async void lambda, and a cancelled token reaching one would throw where nothing can catch it and end the whole app, so this step did not cancel an import. " + + "Run Step 2 again and fix what it reports first."; + + public override int Number => 3; + + public override string Scenario => "Summarize with PLINQ, and stop when cancelled"; + + public override string Title => "RunImportAsync(), the summary stage and its token"; + + public override string Story => "When a bad file is uploaded, operations cancels the nightly import and expects it to stop. " + + "RunImportAsync has received a CancellationToken all along, but only the customer API call ever looked at it, so a cancelled import still scores every row before anything notices. " + + "Meanwhile the regional summary is the last stage still running on one thread, and it has to honor the same token."; + + public override string SeeItInTheApp => "The Import page has no Cancel button, so this step cancels an import for you. On the page, the Report card shows how long the regional grouping took on one thread, " + + "and Regional summary shows US-CA, US-NY, US-TX and US-WA with 1,000 orders each. None of those orders or revenue figures may change when the query moves to PLINQ."; + + public override string FileToChange => "Services/ImportService.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Turn the regional summary into a PLINQ query that honors the cancellation token.", + "Keep the query body the same, so the report still shows four regions with the same order counts and the same revenue.", + "Set CancellationToken through ParallelOptions on every Parallel loop, using the token RunImportAsync already receives, so a cancelled import stops before it scores a single row.", + "Let the OperationCanceledException reach the caller. A cancelled import has no report to return.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "The regional summary runs as a single-threaded LINQ query after every row is already in memory.", + "The method already receives a CancellationToken, and nothing except the customer API call ever uses it.", + "One extension method moves a LINQ query onto PLINQ, and another makes the query honor a token. ParallelOptions has a CancellationToken property too, and a Parallel loop checks it before it starts a single iteration.", + ]; + + public override string TimeoutHint => "The import never finished. Check that the regional summary query ends, and that a cancelled import stops instead of waiting."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + + // Part 1: a full import, and the regional summary it reports, compared with totals worked out straight from the same file + var importService = new ImportService(new OrderFileService(), new CustomerApiService()); + + report.Log($"Importing {_rowCount:N0} rows through a fresh ImportService"); + + var processorTimeBefore = GetProcessorTime(); + var importReport = await importService.RunImportAsync(_rowCount, CancellationToken.None).ConfigureAwait(false); + var fullImportProcessorTime = GetProcessorTime() - processorTimeBefore; + + report.Log($"The import used {fullImportProcessorTime.TotalMilliseconds:N0} ms of CPU time, and the summary took {importReport.ReportElapsed.TotalMilliseconds:N1} ms"); + + var expectedTotals = new OrderFileService().ReadOrders(_rowCount) + .GroupBy(static order => order.Region) + .OrderBy(static group => group.Key, StringComparer.Ordinal) + .Select(static group => new RegionTotal(group.Key, group.Count(), group.Sum(static order => order.Amount), AverageRisk: 0)) + .ToList(); + + report.Expect( + $"The summary has {expectedTotals.Count} regions with {_rowCount / expectedTotals.Count:N0} orders each", + FormatOrders(expectedTotals), + FormatOrders(importReport.RegionTotals), + FormatOrders(expectedTotals) == FormatOrders(importReport.RegionTotals), + _summaryHint); + + report.Expect( + "The revenue for every region matches the uploaded file", + FormatRevenue(expectedTotals), + FormatRevenue(importReport.RegionTotals), + FormatRevenue(expectedTotals) == FormatRevenue(importReport.RegionTotals), + _summaryHint); + + // Part 2: the summary takes about a millisecond, far too little to measure, so this reads ImportService's compiled code instead + var calledMethods = CodeInspector.GetCalledMethods(typeof(ImportService)); + string[] plinqMethods = ["AsParallel", "WithCancellation"]; + var calledPlinqMethods = plinqMethods.Where(name => calledMethods.Contains($"ParallelEnumerable.{name}")).Select(static name => $"{name}()").ToList(); + + report.Expect( + "The regional summary is a PLINQ query that honors the token", + "AsParallel(), WithCancellation()", + calledPlinqMethods.Count is 0 ? "neither" : string.Join(", ", calledPlinqMethods), + calledPlinqMethods.Count == plinqMethods.Length, + _plinqHint); + + // Part 3: cancel an import. Only safe once no async void lambda is left: a cancelled token reaching one would end the whole app. + var asyncVoidMethods = CodeInspector.FindAsyncVoidMethods(typeof(ImportService)); + + if (asyncVoidMethods.Count > 0) + { + report.Log($"ImportService has {asyncVoidMethods.Count} async void lambdas or methods, so this step will not cancel an import"); + report.Expect("A cancelled import throws OperationCanceledException", nameof(OperationCanceledException), "not run: ImportService has async void code", false, _asyncVoidHint); + report.Expect("A cancelled import stops before it scores rows", $"under {_maximumCancelledShare:P0} of a full import's CPU time", "not run: ImportService has async void code", false, _asyncVoidHint); + return; + } + + // Cancelled before it starts, the way operations cancels a job the moment a bad file is spotted + var cancelledToken = new CancellationToken(canceled: true); + string? thrownException = null; + + report.Log($"Importing {_rowCount:N0} rows with a CancellationToken that is already cancelled"); + + processorTimeBefore = GetProcessorTime(); + + try + { + await importService.RunImportAsync(_rowCount, cancelledToken).ConfigureAwait(false); + } + catch (OperationCanceledException e) when (cancelledToken.IsCancellationRequested) + { + thrownException = e.GetType().Name; + } + + var cancelledProcessorTime = GetProcessorTime() - processorTimeBefore; + var cancelledShare = cancelledProcessorTime / fullImportProcessorTime; + + report.Log($"The cancelled import {(thrownException is null ? "returned a report" : $"threw {thrownException}")} after {cancelledProcessorTime.TotalMilliseconds:N0} ms of CPU time, {cancelledShare:P0} of what the full import used"); + + // TaskCanceledException derives from OperationCanceledException, and either one tells the caller the import was cancelled + var outcome = thrownException switch + { + null => "returned a report", + nameof(OperationCanceledException) => thrownException, + _ => $"{thrownException}, an OperationCanceledException", + }; + + report.Expect("A cancelled import throws OperationCanceledException", nameof(OperationCanceledException), outcome, thrownException is not null, _ranToTheEndHint); + + report.Expect( + "A cancelled import stops before it scores rows", + $"under {_maximumCancelledShare:P0} of a full import's CPU time", + $"{cancelledShare:P0} ({cancelledProcessorTime.TotalMilliseconds:N0} ms)", + cancelledShare < _maximumCancelledShare, + _scoredRowsFirstHint); + } + + static string FormatOrders(IEnumerable totals) => string.Join(", ", totals.Select(static total => $"{total.Region} {total.Orders:N0}")); + + static string FormatRevenue(IEnumerable totals) => string.Join(", ", totals.Select(static total => $"{total.Region} {total.Revenue:C0}")); +} \ No newline at end of file diff --git a/7. Data Parallelism/1. Start/ImportPortal/Steps/StepContext.cs b/7. Data Parallelism/1. Start/ImportPortal/Steps/StepContext.cs new file mode 100644 index 0000000..f97d15d --- /dev/null +++ b/7. Data Parallelism/1. Start/ImportPortal/Steps/StepContext.cs @@ -0,0 +1,4 @@ +namespace ImportPortal; + +// Token is cancelled when the run is stopped, times out, or the app shuts down +public sealed record StepContext(StepReport Report, CancellationToken Token); \ No newline at end of file diff --git a/7. Data Parallelism/1. Start/ImportPortal/Steps/WorkshopStep.cs b/7. Data Parallelism/1. Start/ImportPortal/Steps/WorkshopStep.cs new file mode 100644 index 0000000..54f660e --- /dev/null +++ b/7. Data Parallelism/1. Start/ImportPortal/Steps/WorkshopStep.cs @@ -0,0 +1,48 @@ +using System.Diagnostics; + +namespace ImportPortal; + +// Every step is one stage of ImportPortal's nightly import. +// Its tab in the workshop guide tells you what to fix, and Run() runs your ImportService on fresh services, logs what it measured, +// and records an expected result, with a hint, for everything that should be true once the stage is fixed. +public abstract class WorkshopStep +{ + public abstract int Number { get; } + + // What this step fixes, in a few words + public abstract string Scenario { get; } + + // The members this step exercises + public abstract string Title { get; } + + // What goes wrong in production, and why nobody noticed + public abstract string Story { get; } + + // How to watch this bug happen on the Import page + public abstract string SeeItInTheApp { get; } + + // Where the bug lives + public abstract string FileToChange { get; } + + // What this step asks you to change. Some of it cannot be measured from outside, so not every task has an expected result. + public abstract IReadOnlyList Tasks { get; } + + // Shown only when you ask for them + public abstract IReadOnlyList Clues { get; } + + // What is most likely waiting forever when this step does not finish in time + public abstract string TimeoutHint { get; } + + public string SourceFile => $"Steps/{GetType().Name}.cs"; + + public abstract Task Run(StepContext context); + + // The CPU time every thread in this app has used so far. The difference between two readings, divided by the + // wall-clock time between them, is how many cores that work kept busy on average. + protected static TimeSpan GetProcessorTime() + { + using var process = Process.GetCurrentProcess(); + + return process.TotalProcessorTime; + } +} \ No newline at end of file diff --git a/7. Data Parallelism/1. Start/ImportPortal/Verification/ActiveStepRun.cs b/7. Data Parallelism/1. Start/ImportPortal/Verification/ActiveStepRun.cs new file mode 100644 index 0000000..b127117 --- /dev/null +++ b/7. Data Parallelism/1. Start/ImportPortal/Verification/ActiveStepRun.cs @@ -0,0 +1,3 @@ +namespace ImportPortal; + +public sealed record ActiveStepRun(WorkshopStep Step, StepReport Report); \ No newline at end of file diff --git a/7. Data Parallelism/1. Start/ImportPortal/Verification/CodeInspector.cs b/7. Data Parallelism/1. Start/ImportPortal/Verification/CodeInspector.cs new file mode 100644 index 0000000..e09c141 --- /dev/null +++ b/7. Data Parallelism/1. Start/ImportPortal/Verification/CodeInspector.cs @@ -0,0 +1,98 @@ +using System.Reflection; +using System.Reflection.Emit; +using System.Runtime.CompilerServices; + +namespace ImportPortal; + +// Workshop plumbing: reads the compiled IL of a type, including its lambdas, local functions and async state machines. +// The steps use it only for requirements with nothing to observe from outside, such as a PLINQ query that finishes in a millisecond. +public static class CodeInspector +{ + const BindingFlags _declaredMembers = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + + static readonly Dictionary _opCodes = typeof(OpCodes) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Select(static field => (OpCode)(field.GetValue(null) ?? throw new InvalidOperationException($"OpCodes.{field.Name} has no value"))) + .ToDictionary(static opCode => opCode.Value); + + // Every method the type calls, creates a delegate for, or constructs, named "DeclaringType.Name", for example "ParallelEnumerable.AsParallel" + public static IReadOnlySet GetCalledMethods(Type type) + { + var calledMethods = new HashSet(StringComparer.Ordinal); + + foreach (var method in GetMethodsAndConstructors(type)) + { + AddCalledMethods(method, calledMethods); + } + + return calledMethods; + } + + // Lambdas, local functions and methods that are async void. Nothing can await one, and an exception inside one ends the app. + public static IReadOnlyList FindAsyncVoidMethods(Type type) => + [ + .. GetTypeAndNestedTypes(type) + .SelectMany(static declaringType => declaringType.GetMethods(_declaredMembers)) + .Where(static method => method.ReturnType == typeof(void) && method.IsDefined(typeof(AsyncStateMachineAttribute), inherit: false)), + ]; + + static IEnumerable GetMethodsAndConstructors(Type type) => GetTypeAndNestedTypes(type) + .SelectMany(static declaringType => declaringType.GetMethods(_declaredMembers).Cast().Concat(declaringType.GetConstructors(_declaredMembers))); + + // The compiler puts closures, lambdas and async state machines in nested types, sometimes nested more than one level deep + static IEnumerable GetTypeAndNestedTypes(Type type) => + [type, .. type.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic).SelectMany(GetTypeAndNestedTypes)]; + + static void AddCalledMethods(MethodBase method, HashSet calledMethods) + { + var il = method.GetMethodBody()?.GetILAsByteArray(); + + if (il is null) + return; + + var typeArguments = method.DeclaringType is { IsGenericType: true } declaringType ? declaringType.GetGenericArguments() : null; + var methodArguments = method.IsGenericMethod ? method.GetGenericArguments() : null; + + var position = 0; + + while (position < il.Length) + { + // Two-byte opcodes start with 0xFE + var value = il[position] is 0xFE && position + 1 < il.Length + ? unchecked((short)(0xFE00 | il[++position])) + : il[position]; + + position++; + + if (!_opCodes.TryGetValue(value, out var opCode)) + return; + + if (opCode.OperandType is OperandType.InlineMethod && position + 4 <= il.Length) + { + var token = BitConverter.ToInt32(il, position); + + try + { + if (method.Module.ResolveMethod(token, typeArguments, methodArguments) is { DeclaringType: { } calledType } calledMethod) + calledMethods.Add($"{calledType.Name}.{calledMethod.Name}"); + } + catch (ArgumentException) + { + // A token this module cannot resolve is not a call the steps look for + } + } + + position += GetOperandSize(opCode, il, position); + } + } + + static int GetOperandSize(OpCode opCode, byte[] il, int position) => opCode.OperandType switch + { + OperandType.InlineNone => 0, + OperandType.ShortInlineBrTarget or OperandType.ShortInlineI or OperandType.ShortInlineVar => 1, + OperandType.InlineVar => 2, + OperandType.InlineI8 or OperandType.InlineR => 8, + OperandType.InlineSwitch when position + 4 <= il.Length => 4 + (BitConverter.ToInt32(il, position) * 4), + _ => 4, + }; +} \ No newline at end of file diff --git a/7. Data Parallelism/1. Start/ImportPortal/Verification/StepCheck.cs b/7. Data Parallelism/1. Start/ImportPortal/Verification/StepCheck.cs new file mode 100644 index 0000000..c98e617 --- /dev/null +++ b/7. Data Parallelism/1. Start/ImportPortal/Verification/StepCheck.cs @@ -0,0 +1,3 @@ +namespace ImportPortal; + +public sealed record StepCheck(string Description, string Expected, string Actual, bool Passed, string Hint); \ No newline at end of file diff --git a/7. Data Parallelism/1. Start/ImportPortal/Verification/StepLogEntry.cs b/7. Data Parallelism/1. Start/ImportPortal/Verification/StepLogEntry.cs new file mode 100644 index 0000000..9bd73d7 --- /dev/null +++ b/7. Data Parallelism/1. Start/ImportPortal/Verification/StepLogEntry.cs @@ -0,0 +1,3 @@ +namespace ImportPortal; + +public sealed record StepLogEntry(TimeSpan Elapsed, int ThreadId, bool IsThreadPoolThread, string Message); \ No newline at end of file diff --git a/7. Data Parallelism/1. Start/ImportPortal/Verification/StepReport.cs b/7. Data Parallelism/1. Start/ImportPortal/Verification/StepReport.cs new file mode 100644 index 0000000..5927ee6 --- /dev/null +++ b/7. Data Parallelism/1. Start/ImportPortal/Verification/StepReport.cs @@ -0,0 +1,82 @@ +using System.Diagnostics; + +namespace ImportPortal; + +// Collects what a step logged and every expected result it checked. +// Steps write to it from thread pool threads while the workshop guide reads it, so every member takes the lock. +public sealed class StepReport +{ + readonly Lock _lock = new(); + readonly Stopwatch _stopwatch = Stopwatch.StartNew(); + readonly List _entries = []; + readonly List _checks = []; + + bool _isComplete; + + public IReadOnlyList Entries + { + get + { + lock (_lock) + { + return [.. _entries]; + } + } + } + + public IReadOnlyList Checks + { + get + { + lock (_lock) + { + return [.. _checks]; + } + } + } + + public void Log(string message) + { + var threadId = Environment.CurrentManagedThreadId; + var isThreadPoolThread = Thread.CurrentThread.IsThreadPoolThread; + + lock (_lock) + { + if (!_isComplete) + _entries.Add(new StepLogEntry(_stopwatch.Elapsed, threadId, isThreadPoolThread, message)); + } + } + + // The hint is shown under the expected result only when it does not match + public void Expect(string description, T expected, T actual, string hint) + { + Expect(description, Format(expected), Format(actual), EqualityComparer.Default.Equals(expected, actual), hint); + } + + public void Expect(string description, string expected, string actual, bool passed, string hint) + { + lock (_lock) + { + if (!_isComplete) + _checks.Add(new StepCheck(description, expected, actual, passed, hint)); + } + } + + // Called when the run ends, including a run that timed out or was stopped while its step is still blocked somewhere. + // Anything that abandoned step writes later is ignored, so the result in the workshop guide can no longer change. + public void Complete() + { + lock (_lock) + { + _isComplete = true; + _stopwatch.Stop(); + } + } + + static string Format(T value) => value switch + { + null => "null", + int or long => $"{value:N0}", + _ => value.ToString() ?? "null", + }; +} \ No newline at end of file diff --git a/7. Data Parallelism/1. Start/ImportPortal/Verification/StepState.cs b/7. Data Parallelism/1. Start/ImportPortal/Verification/StepState.cs new file mode 100644 index 0000000..1eea41e --- /dev/null +++ b/7. Data Parallelism/1. Start/ImportPortal/Verification/StepState.cs @@ -0,0 +1,9 @@ +namespace ImportPortal; + +// ExceptionType is set for Crashed. Only the name is kept: exception messages and stack traces stay in the terminal. +// CompletedAt is when the run that produced this state ended, so the workshop guide can show that a click really ran again. +public sealed record StepState( + StepStatus Status, + StepReport? Report, + string? ExceptionType = null, + DateTimeOffset? CompletedAt = null); \ No newline at end of file diff --git a/7. Data Parallelism/1. Start/ImportPortal/Verification/StepStatus.cs b/7. Data Parallelism/1. Start/ImportPortal/Verification/StepStatus.cs new file mode 100644 index 0000000..d8def28 --- /dev/null +++ b/7. Data Parallelism/1. Start/ImportPortal/Verification/StepStatus.cs @@ -0,0 +1,12 @@ +namespace ImportPortal; + +public enum StepStatus +{ + NotRun, + Running, + Passed, + Failed, + TimedOut, + Crashed, + Stopped, +} \ No newline at end of file diff --git a/7. Data Parallelism/1. Start/ImportPortal/Verification/StepVerifier.cs b/7. Data Parallelism/1. Start/ImportPortal/Verification/StepVerifier.cs new file mode 100644 index 0000000..c940830 --- /dev/null +++ b/7. Data Parallelism/1. Start/ImportPortal/Verification/StepVerifier.cs @@ -0,0 +1,230 @@ +namespace ImportPortal; + +// Workshop plumbing: runs the steps against your services and keeps their results. +// Every step creates its own instances of the services, so a check never disturbs the singletons the Import page uses. +// Only one step runs at a time, so a step that measures CPU time or concurrent calls never shares the machine with another run. +public sealed class StepVerifier(ILogger logger, IHostApplicationLifetime applicationLifetime) : BackgroundService +{ + public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); + + readonly Lock _lock = new(); + readonly StepState[] _states = [.. Enumerable.Repeat(new StepState(StepStatus.NotRun, null), 3)]; + + // Both start at 1: the app checks every step on startup, and nothing else may run before that finishes + int _isBusy = 1; + int _isCheckingAllSteps = 1; + + ActiveStepRun? _activeRun; + TaskCompletionSource? _stopRequested; + + public IReadOnlyList Steps { get; } = + [ + new Step1ScoreEveryRow(), + new Step2EnrichEveryRow(), + new Step3SummarizeAndCancel(), + ]; + + public bool IsBusy => Volatile.Read(ref _isBusy) is 1; + + public bool IsCheckingAllSteps => Volatile.Read(ref _isCheckingAllSteps) is 1; + + public ActiveStepRun? ActiveRun + { + get + { + lock (_lock) + { + return _activeRun; + } + } + } + + public WorkshopStep? FindStep(int number) => Steps.FirstOrDefault(step => step.Number == number); + + public StepState GetState(int number) + { + lock (_lock) + { + return _states[number - 1]; + } + } + + public bool IsUnlocked(int number) => number is 1 || GetState(number - 1).Status is StepStatus.Passed; + + // Re-runs every step, the same way startup does + public async Task CheckAllSteps() + { + if (Interlocked.CompareExchange(ref _isBusy, 1, 0) is not 0) + return; + + Volatile.Write(ref _isCheckingAllSteps, 1); + + try + { + await CheckSteps(applicationLifetime.ApplicationStopping).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isCheckingAllSteps, 0); + Volatile.Write(ref _isBusy, 0); + } + } + + public async Task RunStep(WorkshopStep step) + { + if (!IsUnlocked(step.Number) || Interlocked.CompareExchange(ref _isBusy, 1, 0) is not 0) + return GetState(step.Number); + + try + { + return await Run(step, applicationLifetime.ApplicationStopping).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isBusy, 0); + } + } + + public void StopRun() + { + lock (_lock) + { + _stopRequested?.TrySetResult(); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await CheckSteps(stoppingToken).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isCheckingAllSteps, 0); + Volatile.Write(ref _isBusy, 0); + } + } + + async Task CheckSteps(CancellationToken token) + { + try + { + // Results from before the latest change to the services are not trusted + lock (_lock) + { + Array.Fill(_states, new StepState(StepStatus.NotRun, null)); + } + + logger.LogInformation("Checking your services against every step. Open the app in your browser: the workshop guide beside the Import page shows what to fix next"); + + foreach (var step in Steps) + { + var state = await Run(step, token).ConfigureAwait(false); + + if (state.Status is not StepStatus.Passed) + { + logger.LogInformation("Stopped checking at Step {StepNumber}. Open it in the app to see what to change, with a hint for every result that did not match. Later steps unlock after it passes", step.Number); + return; + } + } + + logger.LogInformation("Every step passed. Pause here for the group review"); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The app is shutting down + } + catch (Exception e) + { + logger.LogError(e, "Checking the steps failed unexpectedly"); + } + } + + async Task Run(WorkshopStep step, CancellationToken token) + { + var report = new StepReport(); + var stopRequested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var previousState = GetState(step.Number); + + lock (_lock) + { + _activeRun = new ActiveStepRun(step, report); + _stopRequested = stopRequested; + _states[step.Number - 1] = new StepState(StepStatus.Running, report); + } + + // Cancelled when the run ends for any reason, so a step that is still working can stop + using var runCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + + var state = previousState; + + try + { + state = await Execute(step, report, stopRequested.Task, runCancellationTokenSource.Token, token).ConfigureAwait(false); + } + finally + { + await runCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + report.Complete(); + + lock (_lock) + { + _activeRun = null; + _stopRequested = null; + _states[step.Number - 1] = state with { CompletedAt = DateTimeOffset.Now }; + } + } + + return state; + } + + async Task Execute(WorkshopStep step, StepReport report, Task stopRequested, CancellationToken runToken, CancellationToken token) + { + try + { + // A dedicated thread runs the step until its first await. If a deadlock blocks it forever, + // a thread pool thread is not lost for the rest of the app's life. + var stepTask = Task.Factory.StartNew(() => step.Run(new StepContext(report, runToken)), token, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); + + var finished = await Task.WhenAny(stepTask, stopRequested).WaitAsync(Timeout, token).ConfigureAwait(false); + + if (finished == stopRequested) + { + logger.LogInformation("Step {StepNumber} was stopped", step.Number); + return new StepState(StepStatus.Stopped, report); + } + + await stepTask.ConfigureAwait(false); + + var checks = report.Checks; + var passedChecks = checks.Count(static check => check.Passed); + var status = checks.Count > 0 && passedChecks == checks.Count ? StepStatus.Passed : StepStatus.Failed; + + logger.LogInformation("Step {StepNumber} {Result} ({PassedChecks}/{TotalChecks} checks)", step.Number, status is StepStatus.Passed ? "passed" : "failed", passedChecks, checks.Count); + + // The hints belong in the terminal too, next to anything the app logged while the step ran + foreach (var check in checks.Where(static check => !check.Passed)) + { + logger.LogWarning("Step {StepNumber}: {Description}. Expected {Expected}, actual {Actual}. Hint: {Hint}", step.Number, check.Description, check.Expected, check.Actual, check.Hint); + } + + return new StepState(status, report); + } + catch (TimeoutException) + { + logger.LogWarning("Step {StepNumber} did not finish within {Timeout}. {TimeoutHint}", step.Number, Timeout, step.TimeoutHint); + return new StepState(StepStatus.TimedOut, report); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + return new StepState(StepStatus.NotRun, null); + } + catch (Exception e) + { + logger.LogError(e, "Step {StepNumber} threw an unexpected exception", step.Number); + return new StepState(StepStatus.Crashed, report, e.GetType().Name); + } + } +} \ No newline at end of file diff --git a/7. Data Parallelism/1. Start/ImportPortal/wwwroot/app.css b/7. Data Parallelism/1. Start/ImportPortal/wwwroot/app.css index c5a79b2..4a46dfd 100644 --- a/7. Data Parallelism/1. Start/ImportPortal/wwwroot/app.css +++ b/7. Data Parallelism/1. Start/ImportPortal/wwwroot/app.css @@ -1,18 +1,48 @@ +/* ImportPortal: a back-office data operations console, with the workshop guide docked beside it as the on-call runbook for tonight's import */ + :root { - --canvas: #eef3f1; - --panel: #ffffff; - --panel-strong: #112a2d; - --ink: #18211f; - --muted: #62706d; - --line: #d7e0dd; - --accent: #f46a1f; - --teal: #0f766e; - --good: #0f8a52; - --bad: #cf202f; - --warn: #c59d35; - --shadow: 0 18px 55px rgb(28 45 42 / 14%); + /* The console */ + --canvas: #eceff3; + --canvas-dot: #d3d9e1; + --surface: #ffffff; + --surface-sunken: #f5f7fa; + --line: #d6dce4; + --line-strong: #b8c1cd; + --ink: #152030; + --muted: #556173; + --accent: #0b6e8f; + --accent-dark: #085a75; + --accent-soft: #e0f0f5; + --good: #147a3d; + --good-soft: #e5f3ea; + --bad: #c02a2a; + --bad-soft: #fbe9e9; + --stage-validate: #0b6e8f; + --stage-enrich: #7b57c9; + --stage-summarize: #d99100; + + /* The runbook */ + --rb-bg: #0b0d10; + --rb-panel: #13161b; + --rb-raised: #1a1e24; + --rb-line: #292f38; + --rb-ink: #e6e9ed; + --rb-bright: #f7f8fa; + --rb-muted: #a2abb6; + --rb-amber: #f5b041; + --rb-amber-soft: #241c0c; + --rb-green: #5ad68f; + --rb-green-soft: #0f2619; + --rb-red: #ff6f73; + --rb-red-soft: #2a1416; + --rb-cyan: #62cde3; + --rb-cyan-soft: #0d1f25; + + --sans: "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif; + --mono: "Cascadia Mono", "Cascadia Code", "SF Mono", ui-monospace, Menlo, Consolas, monospace; + --guide-width: 460px; color-scheme: light; - font-family: "Aptos", "Avenir Next", "Segoe UI", sans-serif; + font-family: var(--sans); background: var(--canvas); color: var(--ink); } @@ -24,160 +54,458 @@ body { margin: 0; overflow-x: clip; - background: - linear-gradient(135deg, rgb(15 118 110 / 14%), transparent 32%), - radial-gradient(circle at top right, rgb(244 106 31 / 16%), transparent 30%), - var(--canvas); + background: var(--canvas); +} + +/* FocusOnNavigate moves focus to the page heading for screen readers; the heading is not interactive, so hide the ring */ +h1:focus { + outline: none; +} + +code { + font-family: var(--mono); + font-size: 0.88em; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +/* The app and the guide share the screen */ + +.workspace { + display: flex; + min-height: 100vh; +} + +.app-pane { + flex: 1 1 auto; + min-width: 0; + container-type: inline-size; +} + +/* Buttons */ + +button.action { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 18px; + border: 1px solid var(--accent-dark); + border-radius: 6px; + background: var(--accent); + color: #fff; + font: inherit; + font-weight: 600; + cursor: pointer; +} + +button.action:hover:not(:disabled) { + background: var(--accent-dark); +} + +button.action:disabled { + border-color: #8d97a5; + background: #8d97a5; + cursor: progress; +} + +button.action.secondary { + border-color: var(--line-strong); + background: transparent; + color: inherit; +} + +button.action.small { + padding: 6px 12px; + font-size: 0.85rem; +} + +.spinner { + display: inline-block; + flex: none; + width: 14px; + height: 14px; + border: 2px solid rgb(255 255 255 / 35%); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.8s linear infinite; } -.shell { +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* The console */ + +.console { min-height: 100vh; + background-color: var(--canvas); + background-image: radial-gradient(var(--canvas-dot) 1px, transparent 1.5px); + background-size: 20px 20px; +} + +.console-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 18px; + padding: 10px 24px; + border-bottom: 1px solid var(--line); + background: var(--surface); +} + +.console-brand { + display: inline-flex; + align-items: center; + gap: 9px; + font-weight: 700; + letter-spacing: -0.01em; +} + +/* Three rows in a table, drawn with gradients so the app needs no image */ +.console-mark { + width: 22px; + height: 22px; + border-radius: 5px; + background: + linear-gradient(#fff 0 0) 5px 5px / 12px 2px no-repeat, + linear-gradient(#fff 0 0) 5px 10px / 12px 2px no-repeat, + linear-gradient(#fff 0 0) 5px 15px / 8px 2px no-repeat, + var(--accent); +} + +.console-crumbs { + flex: 1 1 auto; + color: var(--muted); + font-size: 0.88rem; +} + +.console-crumbs span { + margin: 0 4px; + color: var(--line-strong); +} + +.env-badge { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 3px 10px; + border-radius: 4px; + background: var(--ink); + color: #fff; + font-family: var(--mono); + font-size: 0.76rem; + white-space: nowrap; +} + +.env-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #4ade80; } -.page { - width: min(1120px, calc(100% - 32px)); +.console-main { + width: min(1120px, calc(100% - 48px)); margin: 0 auto; - padding: 32px 0 56px; + padding: 22px 0 56px; } -.hero-panel { - display: grid; - grid-template-columns: 1fr auto; - gap: 28px; - align-items: end; - margin-bottom: 22px; - padding: 30px; - border: 1px solid rgb(36 35 31 / 10%); +.job-header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-end; + gap: 18px 28px; + margin-bottom: 16px; + padding: 22px 24px; + border: 1px solid var(--line); + border-left: 4px solid var(--accent); border-radius: 8px; - background: linear-gradient(135deg, var(--panel-strong), #15463e 58%, #6f3b17); - box-shadow: var(--shadow); - color: #f8fffc; + background: var(--surface); + box-shadow: 0 1px 2px rgb(21 32 48 / 6%); +} + +.job-copy { + flex: 1 1 380px; + min-width: 0; } -.hero-copy { - max-width: 680px; +.job-kicker { + margin: 0 0 4px; + color: var(--accent); + font-family: var(--mono); + font-size: 0.76rem; + letter-spacing: 0.04em; } -.eyebrow { +.job-header h1 { margin: 0 0 6px; - font-size: 0.78rem; - font-weight: 600; - letter-spacing: 0.12em; - text-transform: uppercase; - color: rgb(248 255 252 / 72%); + font-size: clamp(1.45rem, 3cqi, 1.85rem); + font-weight: 650; + letter-spacing: -0.015em; } -.hero-panel h1 { - margin: 0 0 10px; - font-size: clamp(1.8rem, 3vw, 2.5rem); - line-height: 1.1; +.job-summary { + max-width: 620px; + margin: 0 0 14px; + color: var(--muted); + line-height: 1.5; } -.hero-summary { +.job-meta { + display: flex; + flex-wrap: wrap; + gap: 8px 26px; margin: 0; - color: rgb(248 255 252 / 80%); } -.hero-actions { +.job-meta div { display: grid; + gap: 1px; +} + +.job-meta dt { + color: var(--muted); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.job-meta dd { + margin: 0; + font-family: var(--mono); + font-size: 0.86rem; +} + +.job-actions { + display: flex; + flex-wrap: wrap; + align-items: stretch; gap: 10px; - justify-items: end; } -button.action { - padding: 12px 22px; - border: 0; +.status-tile { + display: grid; + align-content: center; + gap: 2px; + padding: 8px 14px; + border: 1px solid var(--line); border-radius: 6px; - background: var(--accent); - color: #fff; - font: inherit; - font-weight: 600; - cursor: pointer; + background: var(--surface-sunken); } -button.action:hover:not(:disabled) { - background: #d4560f; +.status-value { + color: var(--accent); + font-family: var(--mono); + font-size: 1.5rem; + font-weight: 600; + line-height: 1.1; + font-variant-numeric: tabular-nums; } -button.action:disabled { - background: var(--muted); - cursor: progress; +.status-label { + color: var(--muted); + font-size: 0.78rem; } -button.action.secondary { - background: rgb(248 255 252 / 14%); - border: 1px solid rgb(248 255 252 / 30%); +.run-button { + padding-inline: 20px; } -button.action.secondary:hover:not(:disabled) { - background: rgb(248 255 252 / 24%); +/* A play triangle, drawn with borders */ +.run-button:not(:disabled)::before { + content: ""; + width: 0; + height: 0; + border-block: 5px solid transparent; + border-left: 8px solid currentColor; } .panel { margin-bottom: 16px; - padding: 22px 24px; + padding: 20px 24px 22px; border: 1px solid var(--line); border-radius: 8px; - background: var(--panel); - box-shadow: 0 10px 26px rgb(28 45 42 / 8%); + background: var(--surface); + box-shadow: 0 1px 2px rgb(21 32 48 / 6%); +} + +.panel-header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-start; + gap: 8px 20px; + margin-bottom: 16px; +} + +.panel-header > div { + flex: 1 1 320px; } .panel h2 { margin: 0 0 4px; - font-size: 1.15rem; + font-size: 1.05rem; + font-weight: 650; } .panel-hint { - margin: 0 0 16px; + max-width: 720px; + margin: 0; color: var(--muted); - font-size: 0.92rem; + font-size: 0.9rem; + line-height: 1.5; +} + +.run-state { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 3px 10px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--surface-sunken); + color: var(--muted); + font-size: 0.8rem; + font-weight: 600; + white-space: nowrap; +} + +.run-state::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--line-strong); +} + +.run-state.running { + border-color: rgb(11 110 143 / 35%); + background: var(--accent-soft); + color: var(--accent-dark); +} + +.run-state.running::before { + background: var(--accent); + animation: pulse 1s ease-in-out infinite; +} + +.run-state.finished::before { + background: var(--ink); } -.result-grid { +@keyframes pulse { + 50% { + opacity: 0.25; + } +} + +/* The pipeline: three stage cards joined by connectors */ + +.pipeline { display: grid; - grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); - gap: 12px; + grid-template-columns: minmax(0, 1fr) 34px minmax(0, 1fr) 34px minmax(0, 1fr); + margin-bottom: 12px; } .result-card { display: grid; - gap: 4px; + align-content: start; + gap: 3px; padding: 14px 16px; border: 1px solid var(--line); - border-left: 4px solid var(--muted); + border-top: 3px solid var(--line-strong); border-radius: 6px; - background: #fbfdfc; + background: var(--surface); } .result-card.ok { - border-left-color: var(--good); + border-top-color: var(--good); } .result-card.bad { - border-left-color: var(--bad); + border-top-color: var(--bad); + background: linear-gradient(var(--bad-soft), var(--surface) 70%); } -.result-card.pending { - border-left-color: var(--warn); +.stage-top { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + margin-bottom: 6px; } -.result-label { - font-size: 0.78rem; +.stage-index { + padding: 1px 6px; + border-radius: 4px; + color: #fff; + font-family: var(--mono); + font-size: 0.72rem; font-weight: 600; - letter-spacing: 0.06em; - text-transform: uppercase; +} + +.stage-index.validate, +.duration-segment.validate { + background: var(--stage-validate); +} + +.stage-index.enrich, +.duration-segment.enrich { + background: var(--stage-enrich); +} + +.stage-index.summarize, +.duration-segment.summarize { + background: var(--stage-summarize); +} + +.stage-index.summarize { + color: #2a1c00; +} + +.stage-kind { color: var(--muted); + font-family: var(--mono); + font-size: 0.72rem; } -.result-value { - font-size: 1.5rem; +.result-label { + color: var(--muted); + font-size: 0.74rem; font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.result-value { + font-family: var(--mono); + font-size: 1.65rem; + font-weight: 600; + line-height: 1.2; font-variant-numeric: tabular-nums; } +.result-card.idle .result-value { + color: var(--line-strong); +} + .result-note { - font-size: 0.85rem; color: var(--muted); + font-family: var(--mono); + font-size: 0.8rem; + font-variant-numeric: tabular-nums; } .result-card.ok .result-note { @@ -188,84 +516,985 @@ button.action.secondary:hover:not(:disabled) { color: var(--bad); } -.timing { - font-variant-numeric: tabular-nums; - font-weight: 600; +.pipeline-connector { + position: relative; } -.log { - max-height: 260px; - overflow-y: auto; - margin: 0; - padding: 14px 16px; +/* The line between two stages */ +.pipeline-connector::before { + content: ""; + position: absolute; + top: 50%; + right: 7px; + left: 0; + height: 2px; + background: var(--line-strong); +} + +/* Its arrowhead */ +.pipeline-connector::after { + content: ""; + position: absolute; + top: calc(50% - 5px); + right: 0; + border-block: 6px solid transparent; + border-left: 8px solid var(--line-strong); + transform: translateY(-1px); +} + +/* While the import runs, rows flow along the connectors */ +.pipeline.running .pipeline-connector::before { + background: repeating-linear-gradient(90deg, var(--accent) 0 6px, transparent 6px 11px); + background-size: 22px 2px; + animation: flow 0.7s linear infinite; +} + +.pipeline.running .pipeline-connector::after { + border-left-color: var(--accent); +} + +@keyframes flow { + to { + background-position: 22px 0; + } +} + +.total-card { + grid-template-columns: max-content minmax(0, 1fr); + align-items: center; + gap: 10px 32px; + border-top-width: 1px; + border-left: 3px solid var(--ink); + background: var(--surface-sunken); +} + +.total-card.idle { + border-left-color: var(--line-strong); +} + +.total-copy { + display: grid; + gap: 2px; +} + +.duration { + display: grid; + gap: 6px; +} + +.duration-bar { + display: flex; + gap: 2px; + height: 12px; + overflow: hidden; + border-radius: 3px; + background: var(--line); +} + +.duration-segment { + flex-basis: 0; + min-width: 3px; +} + +.duration-caption { + color: var(--muted); + font-size: 0.8rem; +} + +/* The regional summary */ + +.data-scroll { + overflow-x: auto; border: 1px solid var(--line); border-radius: 6px; - background: #1e2227; - color: #d7dde6; - font-family: "Menlo", "Consolas", monospace; - font-size: 0.82rem; - line-height: 1.6; - white-space: pre-wrap; } -.status-tile { - display: grid; - gap: 4px; - justify-items: end; - padding: 14px 18px; - border: 1px solid rgb(248 255 252 / 22%); - border-radius: 8px; - background: rgb(248 255 252 / 10%); +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.92rem; } -.status-value { - font-size: 1.9rem; +.data-table th, +.data-table td { + padding: 9px 14px; + border-bottom: 1px solid var(--line); + text-align: left; + white-space: nowrap; +} + +.data-table thead th { + background: var(--surface-sunken); + color: var(--muted); + font-size: 0.74rem; font-weight: 700; - line-height: 1; - font-variant-numeric: tabular-nums; + letter-spacing: 0.06em; + text-transform: uppercase; } -.status-label { - font-size: 0.82rem; - color: rgb(248 255 252 / 78%); +.data-table tbody tr:last-child > * { + border-bottom: 0; } -.spinner { - display: inline-block; - width: 14px; - height: 14px; - margin-right: 8px; - border: 2px solid rgb(98 112 109 / 35%); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; - vertical-align: -2px; +.data-table tbody th { + font-family: var(--mono); + font-weight: 600; } -@keyframes spin { - to { - transform: rotate(360deg); - } +.data-table .num { + font-family: var(--mono); + font-variant-numeric: tabular-nums; + text-align: right; } -.warning-banner { - margin-bottom: 16px; - padding: 14px 18px; - border: 1px solid rgb(197 157 53 / 45%); - border-left: 4px solid var(--warn); - border-radius: 6px; - background: #fdf8e9; - color: #6b551a; +.data-table thead .num { + font-family: var(--sans); } -@media (max-width: 640px) { - .hero-panel { - grid-template-columns: 1fr; - align-items: start; +.data-table .empty-row td { + padding: 18px 14px; + color: var(--muted); + text-align: center; + white-space: normal; +} + +@container (max-width: 720px) { + .console-crumbs { + display: none; + } + + .console-brand { + flex: 1 1 auto; + } + + .console-main { + width: calc(100% - 32px); + } + + .pipeline { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto 26px auto 26px auto; + } + + .pipeline-connector::before { + top: 0; + right: auto; + bottom: 7px; + left: 50%; + width: 2px; + height: auto; + } + + .pipeline-connector::after { + top: auto; + right: auto; + bottom: 0; + left: calc(50% - 5px); + border-block: 0; + border-inline: 6px solid transparent; + border-top: 8px solid var(--line-strong); + transform: none; + } + + .pipeline.running .pipeline-connector::before { + background: repeating-linear-gradient(180deg, var(--accent) 0 6px, transparent 6px 11px); + background-size: 2px 22px; + animation-name: flow-down; + } + + .pipeline.running .pipeline-connector::after { + border-top-color: var(--accent); + } + + .total-card { + grid-template-columns: minmax(0, 1fr); + } + + .job-header, + .panel { + padding-inline: 18px; + } +} + +@keyframes flow-down { + to { + background-position: 0 22px; + } +} + +/* The workshop guide: tonight's runbook, open on the on-call laptop beside the console */ + +.guide { + position: sticky; + top: 0; + flex: 0 0 var(--guide-width); + height: 100vh; + overflow-y: auto; + border-top: 3px solid var(--rb-amber); + background: var(--rb-bg); + box-shadow: -10px 0 30px rgb(11 13 16 / 22%); + color: var(--rb-ink); + font-family: var(--sans); + font-size: 0.95rem; + scrollbar-color: #3a414b var(--rb-bg); +} + +.guide code { + padding: 1px 5px; + border-radius: 4px; + background: rgb(255 255 255 / 7%); + color: #f4d59a; + overflow-wrap: anywhere; +} + +.guide.collapsed { + flex-basis: 76px; + display: grid; + align-content: start; + justify-items: center; + gap: 14px; + padding: 14px 0; +} + +.guide-header { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 18px 22px 14px; + background: linear-gradient(rgb(245 176 65 / 7%), transparent); +} + +.guide-eyebrow { + margin: 0 0 6px; + color: var(--rb-amber); + font-family: var(--mono); + font-size: 0.72rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.guide-eyebrow::before { + content: "$ "; + content: "$ " / ""; + color: var(--rb-muted); +} + +.guide-title { + margin: 0; + color: var(--rb-bright); + font-family: var(--mono); + font-size: 1.3rem; + font-weight: 600; + letter-spacing: -0.01em; +} + +/* A terminal cursor that blinks a few times when the guide opens, then stays */ +.guide-title::after { + content: ""; + display: inline-block; + width: 0.55em; + height: 1.05em; + margin-left: 6px; + background: var(--rb-amber); + vertical-align: -0.15em; + animation: cursor 1.1s steps(1) 4; +} + +@keyframes cursor { + 50% { + opacity: 0; + } +} + +.guide-subtitle { + margin: 6px 0 0; + color: var(--rb-muted); + font-size: 0.92rem; +} + +.guide-toggle { + flex: none; + align-self: start; + display: grid; + justify-items: center; + gap: 8px; + min-width: 36px; + padding: 6px 8px; + border: 1px solid var(--rb-line); + border-radius: 6px; + background: var(--rb-panel); + color: var(--rb-amber); + font: inherit; + font-family: var(--mono); + font-size: 1.05rem; + cursor: pointer; +} + +.guide-toggle:hover { + border-color: var(--rb-amber); +} + +.guide-rail-label { + font-size: 0.72rem; + letter-spacing: 0.12em; + text-transform: uppercase; + writing-mode: vertical-rl; +} + +.guide-rail { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.guide-rail-step { + display: grid; + justify-items: center; + gap: 2px; + width: 52px; + padding: 6px 0; + border: 1px solid var(--rb-line); + border-radius: 6px; + background: var(--rb-panel); + color: var(--rb-ink); + font: inherit; + cursor: pointer; +} + +.guide-rail-step:hover { + border-color: var(--rb-amber); +} + +.guide-rail-number { + font-family: var(--mono); + font-weight: 600; +} + +.guide-progress { + display: grid; + gap: 10px; + padding: 4px 22px 16px; + border-bottom: 1px solid var(--rb-line); +} + +.progress-meter { + display: flex; + gap: 4px; +} + +.progress-segment { + flex: 1; + height: 6px; + border-radius: 1px; + background: #262c34; +} + +.progress-segment.done { + background: var(--rb-green); + box-shadow: 0 0 10px rgb(90 214 143 / 40%); +} + +.guide-progress-row { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.guide-progress-count { + margin: 0; + color: var(--rb-muted); +} + +.guide-progress-count strong { + color: var(--rb-bright); + font-family: var(--mono); + font-size: 1.05rem; +} + +.guide-tabs { + display: grid; + gap: 2px; + padding: 10px 12px; + border-bottom: 1px solid var(--rb-line); +} + +.guide-tab { + display: grid; + grid-template-columns: 30px 1fr auto; + gap: 10px; + align-items: center; + padding: 8px 10px; + border: 0; + border-left: 3px solid transparent; + border-radius: 0 6px 6px 0; + background: transparent; + color: var(--rb-ink); + font: inherit; + font-size: 0.92rem; + text-align: left; + cursor: pointer; +} + +.guide-tab:hover { + background: var(--rb-panel); +} + +.guide-tab.selected { + border-left-color: var(--rb-amber); + background: var(--rb-raised); + color: var(--rb-bright); + font-weight: 600; +} + +.guide-tab-number { + display: grid; + place-items: center; + width: 26px; + height: 26px; + border: 1px solid #3a414b; + border-radius: 4px; + color: var(--rb-muted); + font-family: var(--mono); + font-size: 0.82rem; +} + +.guide-tab.selected .guide-tab-number { + border-color: var(--rb-amber); + background: var(--rb-amber); + color: #1c1405; +} + +.guide-body { + display: grid; + gap: 14px; + padding: 16px 22px 40px; +} + +.guide-section { + padding: 16px 18px; + border: 1px solid var(--rb-line); + border-radius: 6px; + background: var(--rb-panel); +} + +.guide-section h3, +.guide-section h4 { + margin: 0 0 10px; + color: var(--rb-amber); + font-family: var(--mono); + font-size: 0.9rem; + font-weight: 600; +} + +/* Runbook headings read like Markdown */ +.guide-section h3::before { + content: "## "; + content: "## " / ""; + color: #7d6634; +} + +.guide-section h4::before { + content: "### "; + content: "### " / ""; + color: #7d6634; +} + +.guide-section p { + margin: 0 0 10px; + line-height: 1.6; +} + +.guide-section p:last-child { + margin-bottom: 0; +} + +.guide-note { + color: var(--rb-muted); + font-size: 0.88rem; +} + +.step-header { + display: grid; + gap: 12px; +} + +.step-eyebrow { + margin: 0; + color: var(--rb-amber); + font-family: var(--mono); + font-size: 0.74rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.step-title { + margin: 4px 0 6px; + color: var(--rb-bright); + font-family: var(--mono); + font-size: 1.12rem; + font-weight: 600; + line-height: 1.35; +} + +.guide .step-members { + padding: 0; + background: none; + color: var(--rb-muted); +} + +.step-actions { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.step-buttons { + display: flex; + gap: 8px; +} + +.guide button.action { + border-color: var(--rb-amber); + border-radius: 4px; + background: var(--rb-amber); + color: #1c1405; + font-family: var(--mono); + font-weight: 600; +} + +.guide button.action:hover:not(:disabled) { + border-color: #ffc561; + background: #ffc561; +} + +.guide button.action:disabled { + border-color: #343a43; + background: #2a2f37; + color: #8f98a3; +} + +.guide button.action.secondary { + border-color: #4a515b; + background: transparent; + color: var(--rb-ink); +} + +.guide button.action.secondary:hover:not(:disabled) { + border-color: var(--rb-ink); + background: rgb(255 255 255 / 6%); +} + +.guide button.action .spinner { + border-color: rgb(162 171 182 / 35%); + border-top-color: var(--rb-amber); +} + +.how-it-works, +.task-list { + margin: 0 0 12px; + padding-left: 22px; + line-height: 1.6; +} + +.how-it-works li + li, +.task-list li + li { + margin-top: 8px; +} + +.how-it-works li::marker, +.task-list li::marker { + color: var(--rb-amber); + font-family: var(--mono); + font-size: 0.85em; +} + +.how-it-works { + margin-bottom: 0; +} + +.facts { + display: grid; + grid-template-columns: max-content 1fr; + gap: 8px 12px; + margin: 0 0 14px; + font-size: 0.92rem; +} + +.facts dt { + padding-top: 2px; + color: var(--rb-muted); + font-family: var(--mono); + font-size: 0.74rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.facts dd { + margin: 0; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.see-it { + padding: 10px 14px; + border-left: 3px solid var(--rb-cyan); + border-radius: 0 6px 6px 0; + background: var(--rb-cyan-soft); + color: #d6eef4; +} + +.see-it strong { + color: var(--rb-cyan); + font-family: var(--mono); + font-size: 0.85em; +} + +.ai-note { + padding: 10px 14px; + border-left: 3px solid var(--rb-amber); + border-radius: 0 6px 6px 0; + background: var(--rb-amber-soft); + color: #f4e2bf; +} + +.ai-note strong { + color: #ffd48a; +} + +.clues { + padding: 10px 14px; + border: 1px dashed #3d444e; + border-radius: 6px; + background: var(--rb-bg); +} + +.clues summary, +.guide-log summary { + color: var(--rb-amber); + font-family: var(--mono); + font-size: 0.88rem; + cursor: pointer; +} + +.clues ul { + margin: 10px 0 0; + padding-left: 20px; + line-height: 1.6; +} + +.clues li + li { + margin-top: 6px; +} + +.clues li::marker { + content: "› "; + color: var(--rb-amber); +} + +.guide-log-count { + margin-left: 8px; + color: var(--rb-muted); + font-size: 0.8rem; +} + +/* Step results */ + +.badge { + display: inline-block; + padding: 2px 8px; + border: 1px solid #353b44; + border-radius: 4px; + background: var(--rb-raised); + color: var(--rb-muted); + font-family: var(--mono); + font-size: 0.74rem; + white-space: nowrap; +} + +.badge-compact { + padding: 0; + border: 0; + background: transparent; + font-size: 0.9rem; +} + +.badge-passed { + border-color: rgb(90 214 143 / 35%); + background: var(--rb-green-soft); + color: #8ff0b6; +} + +.badge-failed { + border-color: rgb(255 111 115 / 40%); + background: var(--rb-red-soft); + color: #ffa3a6; +} + +.badge-running { + border-color: rgb(245 176 65 / 40%); + background: var(--rb-amber-soft); + color: #ffd48a; +} + +.badge-compact.badge-passed, +.badge-compact.badge-failed, +.badge-compact.badge-running { + border: 0; + background: transparent; +} + +.banner { + display: grid; + justify-items: start; + gap: 6px; + padding: 12px 16px; + border: 1px solid var(--rb-line); + border-left: 3px solid #4a515b; + border-radius: 0 6px 6px 0; + background: var(--rb-panel); + line-height: 1.5; +} + +.banner-passed { + border-color: rgb(90 214 143 / 30%); + border-left-color: var(--rb-green); + background: var(--rb-green-soft); + color: #c9f7da; +} + +.banner-failed { + border-color: rgb(255 111 115 / 30%); + border-left-color: var(--rb-red); + background: var(--rb-red-soft); + color: #ffd6d7; +} + +.banner-running { + border-color: rgb(245 176 65 / 30%); + border-left-color: var(--rb-amber); + background: var(--rb-amber-soft); + color: #fbe6bd; +} + +.banner-locked { + color: var(--rb-muted); +} + +.banner .spinner { + border-color: rgb(162 171 182 / 35%); + border-top-color: var(--rb-amber); + vertical-align: -2px; + margin-right: 6px; +} + +.banner-note { + font-family: var(--mono); + font-size: 0.76rem; + opacity: 0.8; +} + +/* @key recreates the banner for every new result, so this animation plays again after each click */ +.banner-fresh { + animation: banner-in 0.35s ease-out; +} + +@keyframes banner-in { + from { + opacity: 0; + transform: translateY(-6px); + } + + to { + opacity: 1; + transform: none; + } +} + +.link-button { + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + font-weight: 600; + text-align: left; + text-decoration: underline; + text-underline-offset: 3px; + cursor: pointer; +} + +.check-list { + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.check { + display: grid; + grid-template-columns: 24px 1fr; + gap: 8px; + padding: 10px 12px; + border: 1px solid var(--rb-line); + border-left: 3px solid var(--rb-green); + border-radius: 0 6px 6px 0; + background: var(--rb-bg); +} + +.check-failed { + border-color: rgb(255 111 115 / 35%); + border-left-color: var(--rb-red); + background: #1a0f11; +} + +.check-body { + min-width: 0; +} + +.guide-section .check-description { + margin: 0 0 6px; + color: var(--rb-bright); + font-weight: 600; + line-height: 1.4; +} + +.check-values { + display: grid; + grid-template-columns: max-content 1fr; + gap: 3px 10px; + margin: 0; + font-size: 0.88rem; +} + +.check-values dt { + padding-top: 2px; + color: var(--rb-muted); + font-family: var(--mono); + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.check-values dd { + margin: 0; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} + +/* Hints read like a note someone left in the runbook margin */ +.guide-section .check-hint { + margin: 10px 0 0; + padding: 8px 12px 10px; + border-left: 3px solid var(--rb-amber); + border-radius: 0 6px 6px 0; + background: var(--rb-amber-soft); + color: #fbe8c4; + font-size: 0.9rem; + line-height: 1.55; +} + +.check-hint::before { + content: "# note"; + content: "# note" / ""; + display: block; + margin-bottom: 3px; + color: var(--rb-amber); + font-family: var(--mono); + font-size: 0.72rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.table-scroll { + margin-top: 10px; + overflow-x: auto; +} + +.step-log { + width: 100%; + border-collapse: collapse; + font-family: var(--mono); + font-size: 0.76rem; +} + +.step-log th, +.step-log td { + padding: 6px 8px; + border-bottom: 1px solid #22272e; + text-align: left; + vertical-align: top; +} + +.step-log th { + color: var(--rb-muted); + font-weight: 400; + text-transform: uppercase; +} + +.step-log td { + color: #d3d8de; + line-height: 1.5; +} + +.step-log td:nth-child(-n + 3) { + color: var(--rb-muted); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* Narrow screens stack the guide under the app */ + +@media (max-width: 1080px) { + :root { + --guide-width: 380px; + } + + /* Keeps the cursor on the same line as the title in the narrower guide */ + .guide-title { + font-size: 1.12rem; + } +} + +@media (max-width: 860px) { + .workspace { + flex-direction: column; + } + + .guide, + .guide.collapsed { + position: static; + flex-basis: auto; + height: auto; + box-shadow: none; + } + + .guide.collapsed { + grid-auto-flow: column; + justify-content: start; + padding: 12px 16px; + } + + .guide.collapsed .guide-rail { + grid-auto-flow: column; + } + + .guide-rail-label { + writing-mode: horizontal-tb; + } + + .guide-header, + .guide-progress { + padding-inline: 16px; } - .hero-actions, - .status-tile { - justify-items: start; + .guide-body { + padding-inline: 16px; } } diff --git a/7. Data Parallelism/2. Finish/ImportPortal/Components/Layout/MainLayout.razor b/7. Data Parallelism/2. Finish/ImportPortal/Components/Layout/MainLayout.razor index 1c2a641..f745fc5 100644 --- a/7. Data Parallelism/2. Finish/ImportPortal/Components/Layout/MainLayout.razor +++ b/7. Data Parallelism/2. Finish/ImportPortal/Components/Layout/MainLayout.razor @@ -1,5 +1,9 @@ @inherits LayoutComponentBase -
- @Body -
+
+
+ @Body +
+ + +
diff --git a/7. Data Parallelism/2. Finish/ImportPortal/Components/Layout/StepBadge.razor b/7. Data Parallelism/2. Finish/ImportPortal/Components/Layout/StepBadge.razor new file mode 100644 index 0000000..352439e --- /dev/null +++ b/7. Data Parallelism/2. Finish/ImportPortal/Components/Layout/StepBadge.razor @@ -0,0 +1,10 @@ +@inherits StepBadgeBase + +@if (IsCompact) +{ + @Label +} +else +{ + @Icon @Label +} diff --git a/7. Data Parallelism/2. Finish/ImportPortal/Components/Layout/StepBadge.razor.cs b/7. Data Parallelism/2. Finish/ImportPortal/Components/Layout/StepBadge.razor.cs new file mode 100644 index 0000000..d2013ae --- /dev/null +++ b/7. Data Parallelism/2. Finish/ImportPortal/Components/Layout/StepBadge.razor.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Components; + +namespace ImportPortal.Components.Layout; + +public partial class StepBadgeBase : ComponentBase +{ + [Parameter, EditorRequired] + public StepStatus Status { get; set; } + + [Parameter] + public bool IsUnlocked { get; set; } = true; + + // Only the icon, for the collapsed guide + [Parameter] + public bool IsCompact { get; set; } + + public string Icon => Status is StepStatus.Running ? "⏳" : !IsUnlocked ? "🔒" : Status switch + { + StepStatus.Passed => "✅", + StepStatus.Failed or StepStatus.TimedOut or StepStatus.Crashed => "❌", + _ => "•", + }; + + public string Label => Status is StepStatus.Running ? "Running" : !IsUnlocked ? "Locked" : Status switch + { + StepStatus.Passed => "Passed", + StepStatus.Failed => "Failed", + StepStatus.TimedOut => "Timed out", + StepStatus.Crashed => "Crashed", + StepStatus.Stopped => "Stopped", + _ => "Not run", + }; + + public string CssClass => Status is StepStatus.Running ? "badge-running" : !IsUnlocked ? "badge-locked" : Status switch + { + StepStatus.Passed => "badge-passed", + StepStatus.Failed or StepStatus.TimedOut or StepStatus.Crashed => "badge-failed", + _ => "badge-idle", + }; +} \ No newline at end of file diff --git a/7. Data Parallelism/2. Finish/ImportPortal/Components/Layout/WorkshopGuide.razor b/7. Data Parallelism/2. Finish/ImportPortal/Components/Layout/WorkshopGuide.razor new file mode 100644 index 0000000..86b5de3 --- /dev/null +++ b/7. Data Parallelism/2. Finish/ImportPortal/Components/Layout/WorkshopGuide.razor @@ -0,0 +1,321 @@ +@inherits WorkshopGuideBase + + diff --git a/7. Data Parallelism/2. Finish/ImportPortal/Components/Layout/WorkshopGuide.razor.cs b/7. Data Parallelism/2. Finish/ImportPortal/Components/Layout/WorkshopGuide.razor.cs new file mode 100644 index 0000000..4141e6d --- /dev/null +++ b/7. Data Parallelism/2. Finish/ImportPortal/Components/Layout/WorkshopGuide.razor.cs @@ -0,0 +1,179 @@ +using Microsoft.AspNetCore.Components; + +namespace ImportPortal.Components.Layout; + +// Workshop plumbing: the guide docked beside the app. It shows your challenge, every step's result, and the step you are working on. +public partial class WorkshopGuideBase : ComponentBase, IAsyncDisposable +{ + // Selecting this shows your challenge instead of a step + public const int ChallengeTab = 0; + + // A run that fails quickly can finish faster than anyone can see a spinner + static readonly TimeSpan _minimumActivityIndicatorTime = TimeSpan.FromMilliseconds(600); + + readonly CancellationTokenSource _disposeCancellationTokenSource = new(); + + Task _polling = Task.CompletedTask; + bool _isStartingCheck; + int? _startingRunStepNumber; + + [Inject] + public required StepVerifier Verifier { get; init; } + + [Inject] + public required ILogger Logger { get; init; } + + public int SelectedTab { get; private set; } = ChallengeTab; + + public bool IsCollapsed { get; private set; } + + public string? ErrorMessage { get; private set; } + + public int PassedStepCount => Verifier.Steps.Count(step => Verifier.GetState(step.Number).Status is StepStatus.Passed); + + public bool IsCheckingSteps => _isStartingCheck || Verifier.IsCheckingAllSteps; + + // The first step that does not pass yet, which is the one to work on next + public WorkshopStep? StoppedAtStep => Verifier.Steps.FirstOrDefault(step => Verifier.GetState(step.Number).Status is not StepStatus.Passed); + + // The last step the check reaches: the first one that did not pass, or the last step when they all passed + public WorkshopStep LastCheckedStep => StoppedAtStep ?? Verifier.Steps[^1]; + + public WorkshopStep? SelectedStep => Verifier.FindStep(SelectedTab); + + // The run lives in the verifier, not in this guide, so a step that is still running shows up again after a reload + public ActiveStepRun? SelectedRun => Verifier.ActiveRun is { } run && run.Step.Number == SelectedTab ? run : null; + + public bool IsSelectedStepRunning => _startingRunStepNumber == SelectedTab || SelectedRun is not null; + + public async ValueTask DisposeAsync() + { + // Only stops refreshing the guide. A step that is running keeps running. + await _disposeCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + await _polling.ConfigureAwait(false); + + _disposeCancellationTokenSource.Dispose(); + + GC.SuppressFinalize(this); + } + + protected override void OnInitialized() + { + _polling = PollVerifier(_disposeCancellationTokenSource.Token); + } + + protected void SelectTab(int tab) + { + SelectedTab = tab; + IsCollapsed = false; + ErrorMessage = null; + } + + protected void ToggleCollapsed() => IsCollapsed = !IsCollapsed; + + protected async Task CheckAllStepsAsync() + { + var token = _disposeCancellationTokenSource.Token; + string? errorMessage = null; + + ErrorMessage = null; + _isStartingCheck = true; + + // Show the activity indicator before the check starts + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + try + { + // Keep the indicator on screen long enough to notice, even when Step 1 fails quickly + await Task.WhenAll(Verifier.CheckAllSteps(), Task.Delay(_minimumActivityIndicatorTime, token)).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The guide was closed while the steps were being checked + return; + } + catch (Exception e) + { + Logger.LogError(e, "Checking every step failed"); + + errorMessage = "Checking the steps failed. The terminal running the app has the details."; + } + + // The continuation is off Blazor's renderer, so every component state change goes back through it + await InvokeAsync(() => + { + ErrorMessage = errorMessage; + _isStartingCheck = false; + + StateHasChanged(); + }).ConfigureAwait(false); + } + + protected async Task RunSelectedStepAsync() + { + if (SelectedStep is not { } step) + return; + + var token = _disposeCancellationTokenSource.Token; + string? errorMessage = null; + + ErrorMessage = null; + _startingRunStepNumber = step.Number; + + // Show the activity indicator before the run starts + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + try + { + // Keep the indicator on screen long enough to notice, even when the step fails quickly + await Task.WhenAll(Verifier.RunStep(step), Task.Delay(_minimumActivityIndicatorTime, token)).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The guide was closed while the step was running + return; + } + catch (Exception e) + { + Logger.LogError(e, "Running Step {StepNumber} failed", step.Number); + + errorMessage = "The step could not be run. The terminal running the app has the details."; + } + + // The continuation is off Blazor's renderer, so every component state change goes back through it + await InvokeAsync(() => + { + ErrorMessage = errorMessage; + _startingRunStepNumber = null; + + StateHasChanged(); + }).ConfigureAwait(false); + } + + protected void StopRun() => Verifier.StopRun(); + + // Re-renders while the verifier is busy, plus once more when it finishes, so the live log and results appear without a refresh + async Task PollVerifier(CancellationToken token) + { + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100)); + var wasBusy = true; + + try + { + while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false)) + { + var isBusy = Verifier.IsBusy; + + if (isBusy || wasBusy) + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + wasBusy = isBusy; + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // Expected when the guide is disposed + } + } +} \ No newline at end of file diff --git a/7. Data Parallelism/2. Finish/ImportPortal/Components/Pages/Import.razor b/7. Data Parallelism/2. Finish/ImportPortal/Components/Pages/Import.razor index 39b3bfa..4cbaea3 100644 --- a/7. Data Parallelism/2. Finish/ImportPortal/Components/Pages/Import.razor +++ b/7. Data Parallelism/2. Finish/ImportPortal/Components/Pages/Import.razor @@ -1,87 +1,168 @@ @page "/" @inherits ImportPageBase -ImportPortal - -
-
-
-

ImportPortal

-

Nightly order import

-

Validate @RowCount.ToString("N0") uploaded rows, enrich each one from the customer API, then summarize by region.

-
- -
- +ImportPortal · Nightly order import -
- @Environment.ProcessorCount - processors available -
-
+
+
+ ImportPortal + Data operations Jobs orders-nightly + prod · nightly
- @if (Report is null && !IsBusy) - { -
-

What to look for

-

Run the import and read two things: how long each stage takes, and whether every row was actually validated and enriched. A stage that finishes suspiciously fast may not have done anything.

-
- } - - @if (Report is not null) - { -
-

Import stages

-

Every row must be validated and enriched. Anything less than @RowCount.ToString("N0") means work was skipped.

- -
-
+
+
+
+

Scheduled job · orders-nightly

+

Nightly order import

+

Validate @RowCount.ToString("N0") uploaded rows, enrich each one from the customer API, then summarize by region.

+ +
+
+
Source
+
uploads/orders.csv
+
+
+
Rows
+
@RowCount.ToString("N0")
+
+
+
Schedule
+
02:00 nightly
+
+
+
+ +
+
+ @Environment.ProcessorCount + processors available +
+ + +
+
+ +
+
+
+

Import stages

+ @if (Report is null) + { +

Run the import and read two things: how long each stage takes, and whether every row was actually validated and enriched. A stage that finishes suspiciously fast may not have done anything.

+ } + else + { +

Every row must be validated and enriched. Anything less than @RowCount.ToString("N0") means work was skipped.

+ } +
+ + @(IsBusy ? "Running" : Report is null ? "Not run yet" : "Last run finished") +
+ +
+
+
+ + CPU-bound +
Validated - @Report.RowsValidated.ToString("N0") - of @RowCount.ToString("N0") in @Report.ValidateElapsed.TotalSeconds.ToString("F2")s + @(Report is null ? "—" : Report.RowsValidated.ToString("N0")) + @(Report is null ? $"of {RowCount:N0} rows" : $"of {RowCount:N0} in {Report.ValidateElapsed.TotalSeconds:F2}s")
-
+ + +
+
+ + I/O-bound +
Enriched - @Report.RowsEnriched.ToString("N0") - of @RowCount.ToString("N0") in @Report.EnrichElapsed.TotalSeconds.ToString("F2")s + @(Report is null ? "—" : Report.RowsEnriched.ToString("N0")) + @(Report is null ? $"of {RowCount:N0} rows" : $"of {RowCount:N0} in {Report.EnrichElapsed.TotalSeconds:F2}s")
-
+ + +
+
+ + Summary +
Report - @Report.ReportElapsed.TotalSeconds.ToString("F2")s + @(Report is null ? "—" : $"{Report.ReportElapsed.TotalSeconds:F2}s") grouped by region
+
-
+
+
Total - @((Report.ValidateElapsed + Report.EnrichElapsed + Report.ReportElapsed).TotalSeconds.ToString("F2"))s + @(Report is null ? "—" : $"{(Report.ValidateElapsed + Report.EnrichElapsed + Report.ReportElapsed).TotalSeconds:F2}s") end to end
-
-
-
-

Regional summary

-

Average risk is only meaningful once every row has been validated.

- -
- @foreach (var total in Report.RegionTotals) + @if (Report is not null) { -
- @total.Region - @total.Revenue.ToString("C0") - @total.Orders.ToString("N0") orders, avg risk @total.AverageRisk.ToString("N0") + }
-
- } -
+
+ +
+
+
+

Regional summary

+

Average risk is only meaningful once every row has been validated.

+
+
+ +
+ + + + + + + + + + + @if (Report is null) + { + + + + } + else + { + @foreach (var total in Report.RegionTotals) + { + + + + + + + } + } + +
RegionOrdersRevenueAvg risk
No summary yet. Run the import to group the rows by region.
@total.Region@total.Orders.ToString("N0")@total.Revenue.ToString("C0")@total.AverageRisk.ToString("N0")
+
+
+ + diff --git a/7. Data Parallelism/2. Finish/ImportPortal/Program.cs b/7. Data Parallelism/2. Finish/ImportPortal/Program.cs index 2737f45..e00ac36 100644 --- a/7. Data Parallelism/2. Finish/ImportPortal/Program.cs +++ b/7. Data Parallelism/2. Finish/ImportPortal/Program.cs @@ -15,6 +15,11 @@ public static void Main(string[] args) builder.Services.AddSingleton(); builder.Services.AddSingleton(); + // Workshop plumbing: one StepVerifier holds every step's result. It is also a hosted service, + // so it checks your ImportService against every step each time the app starts. + builder.Services.AddSingleton(); + builder.Services.AddHostedService(static serviceProvider => serviceProvider.GetRequiredService()); + var app = builder.Build(); if (!app.Environment.IsDevelopment()) diff --git a/7. Data Parallelism/2. Finish/ImportPortal/Services/CustomerApiService.cs b/7. Data Parallelism/2. Finish/ImportPortal/Services/CustomerApiService.cs index 9efe82e..73d4a0b 100644 --- a/7. Data Parallelism/2. Finish/ImportPortal/Services/CustomerApiService.cs +++ b/7. Data Parallelism/2. Finish/ImportPortal/Services/CustomerApiService.cs @@ -6,10 +6,46 @@ public sealed class CustomerApiService { static readonly string[] _tiers = ["Bronze", "Silver", "Gold", "Platinum"]; + // Workshop instrumentation: the workshop steps read these counters to see how many calls + // ran at the same time. You do not need to change this file. + int _callsInFlight; + int _peakCallsInFlight; + int _callsCompleted; + + public int CallsInFlight => Volatile.Read(ref _callsInFlight); + + public int PeakCallsInFlight => Volatile.Read(ref _peakCallsInFlight); + + public int CallsCompleted => Volatile.Read(ref _callsCompleted); + public async Task GetCustomerTierAsync(string sku, CancellationToken token) { - await Task.Delay(TimeSpan.FromMilliseconds(10), token).ConfigureAwait(false); + RecordCallStarted(); + + try + { + await Task.Delay(TimeSpan.FromMilliseconds(10), token).ConfigureAwait(false); + + Interlocked.Increment(ref _callsCompleted); + + return _tiers[Math.Abs(sku.GetHashCode(StringComparison.Ordinal)) % _tiers.Length]; + } + finally + { + Interlocked.Decrement(ref _callsInFlight); + } + } + + void RecordCallStarted() + { + var callsInFlight = Interlocked.Increment(ref _callsInFlight); + + // Raise the peak only if no other call raised it higher in the meantime + var peak = Volatile.Read(ref _peakCallsInFlight); - return _tiers[Math.Abs(sku.GetHashCode(StringComparison.Ordinal)) % _tiers.Length]; + while (callsInFlight > peak && Interlocked.CompareExchange(ref _peakCallsInFlight, callsInFlight, peak) != peak) + { + peak = Volatile.Read(ref _peakCallsInFlight); + } } } \ No newline at end of file diff --git a/7. Data Parallelism/2. Finish/ImportPortal/Steps/Step1ScoreEveryRow.cs b/7. Data Parallelism/2. Finish/ImportPortal/Steps/Step1ScoreEveryRow.cs new file mode 100644 index 0000000..4a53fc1 --- /dev/null +++ b/7. Data Parallelism/2. Finish/ImportPortal/Steps/Step1ScoreEveryRow.cs @@ -0,0 +1,88 @@ +namespace ImportPortal; + +public sealed class Step1ScoreEveryRow : WorkshopStep +{ + // Roughly a second of validation on one thread, and a small fraction of that once every core helps + const int _rowCount = 1_000; + + const string _missingRowsHint = "Some rows were never scored. Every row in orders has to get its RiskScore, exactly once, before RunImportAsync moves on to the next stage."; + + const string _oneCoreHint = "Validation kept about one core busy: one thread scored every row while the other cores sat idle. " + + "Nothing in the loop body depends on another row, so hand the rows to a Parallel loop and let every processor score them at the same time."; + + public override int Number => 1; + + public override string Scenario => "Score every row on every core"; + + public override string Title => "RunImportAsync(), the validation stage"; + + public override string Story => "Every night ImportPortal scores each uploaded row for risk before it does anything else. ScoreRisk() is pure CPU work, and no row depends on another, " + + "yet the import works through the file one row at a time on one thread while every other core on the server sits idle. " + + "The answer is right. It just takes exactly as long as it would on a machine with a single processor, and the files get bigger every month."; + + public override string SeeItInTheApp => "On the Import page beside this guide, press Run import. Validated reads 4,000 of 4,000, and its time is almost all of the Total card. " + + "Compare that with the processors available tile in the header: every one of them was available, and one did all the work. Write the Total down as your baseline."; + + public override string FileToChange => "Services/ImportService.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Replace the sequential validation foreach so every processor scores rows at the same time.", + "Give the CPU stage its own ParallelOptions. Set MaxDegreeOfParallelism to a ceiling that suits CPU-bound work, and set CancellationToken to the token RunImportAsync already receives. Step 3 checks the token.", + "Leave ScoreRisk(OrderRow) exactly as it is. The goal is to run it on more threads, not to make it cheaper.", + "Do not add a shared counter, list, or dictionary that the loop body writes to. Each iteration only writes RiskScore on its own row.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "The validation loop is a plain foreach, and nothing in its body depends on the row before it.", + "Each iteration writes RiskScore on its own OrderRow and reads nothing another iteration wrote, so there is nothing to lock.", + "The Parallel method that looks most like foreach is already in this file. One of its overloads takes ParallelOptions, and for CPU-bound work Environment.ProcessorCount is the right ceiling: more threads than cores only adds context switching.", + ]; + + public override string TimeoutHint => "The import never finished. Check that the validation loop visits every row once and ends, and that nothing in RunImportAsync waits on work that cannot complete."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + + // A fresh ImportService, so this check never touches the singletons the Import page uses + var importService = new ImportService(new OrderFileService(), new CustomerApiService()); + + report.Log($"Importing {_rowCount:N0} rows through a fresh ImportService. This machine has {Environment.ProcessorCount} processors"); + + // CancellationToken.None, never context.Token: until Step 2 is fixed, the enrichment stage runs async void lambdas, + // and a cancelled token reaching one of them throws where nothing can catch it, which ends the whole app + var processorTimeBefore = GetProcessorTime(); + var importReport = await importService.RunImportAsync(_rowCount, CancellationToken.None).ConfigureAwait(false); + var processorTime = GetProcessorTime() - processorTimeBefore; + + report.Log($"Validation scored {importReport.RowsValidated:N0} rows in {importReport.ValidateElapsed.TotalSeconds:F2}s"); + + report.Expect("Every row is scored", _rowCount, importReport.RowsValidated, _missingRowsHint); + + // One thread can use at most one second of CPU time per second of validation, however hard it works. + // Almost all of the CPU time an import uses is spent scoring rows, so CPU time divided by the validation time is how many cores were busy. + // The other stages use a little CPU time too, which can push the result past the processor count, so it is capped there. + var coresBusy = importReport.ValidateElapsed > TimeSpan.Zero ? Math.Min(processorTime / importReport.ValidateElapsed, Environment.ProcessorCount) : 0; + + report.Log($"The import used {processorTime.TotalSeconds:F2}s of CPU time, so validation kept about {coresBusy:F1} cores busy"); + + if (Environment.ProcessorCount is 1) + { + report.Log("This machine reports 1 processor, so no loop can keep more than one core busy here. The next check passes on this machine without measuring anything"); + report.Expect("Validation keeps more than one core busy", "more than 1 core, on a machine with 2 or more processors", "not measured: this machine has 1 processor", true, _oneCoreHint); + return; + } + + // A single thread scores about 1.0. Every core helping scores close to the processor count, so these leave plenty of room for a busy laptop. + var requiredCoresBusy = Environment.ProcessorCount >= 4 ? 2.0 : 1.5; + + report.Expect( + "Validation keeps more than one core busy", + $"at least {requiredCoresBusy:F1} cores", + $"{coresBusy:F1} cores", + coresBusy >= requiredCoresBusy, + _oneCoreHint); + } +} \ No newline at end of file diff --git a/7. Data Parallelism/2. Finish/ImportPortal/Steps/Step2EnrichEveryRow.cs b/7. Data Parallelism/2. Finish/ImportPortal/Steps/Step2EnrichEveryRow.cs new file mode 100644 index 0000000..a2db5ea --- /dev/null +++ b/7. Data Parallelism/2. Finish/ImportPortal/Steps/Step2EnrichEveryRow.cs @@ -0,0 +1,107 @@ +namespace ImportPortal; + +public sealed class Step2EnrichEveryRow : WorkshopStep +{ + // Small enough that even awaiting every call one at a time finishes well inside the step timeout + const int _rowCount = 400; + + const string _notAwaitedHint = "RunImportAsync returned before the customer API answered. An async lambda passed to Parallel.ForEach is async void, " + + "so the loop counts each row as done the moment its call starts. Use the Parallel overload that is built for asynchronous bodies, and await it."; + + const string _inFlightHint = "Customer API calls were still running after RunImportAsync returned, so the import reported on work that had not finished. " + + "The enrichment stage has to be awaited before the method moves on."; + + const string _oneAtATimeHint = "Only one customer API call ran at a time, so the import makes every round trip back to back. Let the calls run in parallel, up to a limit you choose."; + + const string _unboundedHint = "Every row's call was in flight at the same moment. Against a real customer API, that is how a nightly job takes down a service other teams depend on. " + + "Give the enrichment stage its own ParallelOptions, with a MaxDegreeOfParallelism well below the row count."; + + const string _forEachAsyncHint = "The enrichment stage needs the Parallel method whose body receives a CancellationToken and returns a ValueTask. It awaits every body, and it bounds how many run at once."; + + const string _asyncVoidHint = "An async lambda passed where an Action is expected compiles to async void. Nothing can await it, and an exception inside it ends the whole app. " + + "Every async body in ImportService has to return a Task or a ValueTask."; + + public override int Number => 2; + + public override string Scenario => "Enrich every row, and wait for it"; + + public override string Title => "RunImportAsync(), the enrichment stage"; + + public override string Story => "Every row is supposed to come back with the customer's tier from the internal customer API. " + + "Last week someone noticed that not one row has a tier, and that the import has reported success every night for a year. " + + "The stage that should be waiting on thousands of 10 millisecond calls finishes in a hundredth of a second. " + + "And the day the customer API answers one of those calls with an error, the exception is thrown where nothing can catch it, and it takes the whole process down."; + + public override string SeeItInTheApp => "On the Import page, press Run import. The Enriched card reads 0 of 4,000 in a hundredth of a second or less, and it is red. " + + "4,000 calls at 10 milliseconds each cannot finish in a hundredth of a second, and they did not: the import stopped waiting for them before a single one answered."; + + public override string FileToChange => "Services/ImportService.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Fix the enrichment stage so every row really is enriched, and so RunImportAsync does not return until it is.", + "Use the Parallel overload that is built for asynchronous bodies, and forward the CancellationToken that overload hands to your body.", + "Bound the enrichment stage with its own ParallelOptions, so you do not fire every call at the customer API at once. Pick a limit and be ready to explain the number you picked. The CPU stage and the I/O stage do not have to agree on the same ceiling.", + "Do not add a shared counter, list, or dictionary that parallel bodies write to. Each call only writes CustomerTier on its own row.", + "Leave CustomerApiService.cs alone. Its counters are how this step sees how many calls ran at the same time.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "The enrichment stage calls Parallel.ForEach with an async lambda, and the compiler is perfectly happy with it.", + "The stage that should be waiting on thousands of calls at 10 milliseconds each reports a hundredth of a second.", + "RunImportAsync is an async method whose only await is await Task.Yield().", + "The body Parallel.ForEach takes here is an Action, and no overload of it accepts a Task-returning body. An async lambda bound to a delegate that returns void becomes async void.", + "Parallel has an asynchronous sibling of ForEach. Its body receives a CancellationToken and returns a ValueTask. Without a MaxDegreeOfParallelism of your own, it runs one body per processor, and cores are the wrong unit for network calls.", + ]; + + public override string TimeoutHint => "The import never finished. Check that every customer API call can complete, and that the enrichment stage awaits the calls instead of blocking a thread on each one."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + + // Part 1: a fresh CustomerApiService counts how many calls are in flight, so this step can see what the import really waited for + var customerApi = new CustomerApiService(); + var importService = new ImportService(new OrderFileService(), customerApi); + + report.Log($"Importing {_rowCount:N0} rows through a fresh ImportService and CustomerApiService"); + + // CancellationToken.None, never context.Token: until this step is fixed, the enrichment stage runs async void lambdas, + // and a cancelled token reaching one of them throws where nothing can catch it, which ends the whole app + var importReport = await importService.RunImportAsync(_rowCount, CancellationToken.None).ConfigureAwait(false); + + // Read the moment RunImportAsync returns. A call still in flight is work the import reported on before it finished. + var callsInFlight = customerApi.CallsInFlight; + var callsCompleted = customerApi.CallsCompleted; + var peakCallsInFlight = customerApi.PeakCallsInFlight; + + report.Log($"RunImportAsync returned after {importReport.EnrichElapsed.TotalSeconds:F2}s of enrichment with {importReport.RowsEnriched:N0} rows enriched. " + + $"The customer API had answered {callsCompleted:N0} calls, and {callsInFlight:N0} were still in flight"); + + report.Expect("Every row has a customer tier when RunImportAsync returns", _rowCount, importReport.RowsEnriched, _notAwaitedHint); + report.Expect("No customer API call is still in flight when RunImportAsync returns", 0, callsInFlight, _inFlightHint); + + // Part 2: how many calls ran at the same time. More than one is parallel, and fewer than every row is bounded. + if (peakCallsInFlight >= _rowCount) + report.Log($"All {_rowCount:N0} calls were in flight at the same time"); + else if (peakCallsInFlight <= 1) + report.Log("Only one call was ever in flight, so the calls ran back to back"); + else if (peakCallsInFlight == Environment.ProcessorCount) + report.Log($"At most {peakCallsInFlight} calls ran at once, the same as this machine's {Environment.ProcessorCount} processors. That is the limit Parallel.ForEachAsync uses when you do not choose one. Be ready to explain why the customer API should get that number"); + else + report.Log($"At most {peakCallsInFlight} calls ran at once, and this machine has {Environment.ProcessorCount} processors. Network calls spend their time waiting, so their limit comes from what the customer API can take, not from the core count"); + + report.Expect("Several customer API calls run at the same time", "more than 1", $"{peakCallsInFlight:N0}", peakCallsInFlight > 1, _oneAtATimeHint); + report.Expect("The customer API is never sent every row at once", $"fewer than {_rowCount:N0}", $"{peakCallsInFlight:N0}", peakCallsInFlight < _rowCount, _unboundedHint); + + // Part 3: the compiled code. An async void lambda can look like it works on a good night, so this step reads ImportService's IL to find any that are left. + var callsForEachAsync = CodeInspector.GetCalledMethods(typeof(ImportService)).Contains("Parallel.ForEachAsync"); + var asyncVoidMethods = CodeInspector.FindAsyncVoidMethods(typeof(ImportService)); + + report.Log($"ImportService {(callsForEachAsync ? "calls" : "does not call")} Parallel.ForEachAsync, and has {asyncVoidMethods.Count} async void lambdas or methods"); + + report.Expect("ImportService calls Parallel.ForEachAsync", "called", callsForEachAsync ? "called" : "not called", callsForEachAsync, _forEachAsyncHint); + report.Expect("ImportService has no async void lambdas or methods left", 0, asyncVoidMethods.Count, _asyncVoidHint); + } +} \ No newline at end of file diff --git a/7. Data Parallelism/2. Finish/ImportPortal/Steps/Step3SummarizeAndCancel.cs b/7. Data Parallelism/2. Finish/ImportPortal/Steps/Step3SummarizeAndCancel.cs new file mode 100644 index 0000000..4d76215 --- /dev/null +++ b/7. Data Parallelism/2. Finish/ImportPortal/Steps/Step3SummarizeAndCancel.cs @@ -0,0 +1,157 @@ +namespace ImportPortal; + +public sealed class Step3SummarizeAndCancel : WorkshopStep +{ + // Enough rows that scoring them takes a clearly measurable amount of CPU time + const int _rowCount = 1_000; + + // A cancelled import that stops before validation uses almost no CPU time. One that scores every row first uses about as much as a full import. + const double _maximumCancelledShare = 0.25; + + const string _summaryHint = "The regional summary no longer matches the uploaded file. Moving a query to PLINQ should not change its body at all: group by region, count, sum and average, then sort by region."; + + const string _plinqHint = "The regional summary still runs as a single-threaded LINQ query, or it ignores the token. Move the query to PLINQ, and make it honor the CancellationToken that RunImportAsync receives."; + + const string _ranToTheEndHint = "A cancelled import ran to the end and returned a report nobody wanted. Every stage has to honor the token RunImportAsync receives: " + + "set it on the ParallelOptions of each Parallel loop, hand it to the PLINQ query, and let the OperationCanceledException reach the caller."; + + const string _scoredRowsFirstHint = "The import did not notice the cancellation until it had scored every row. A foreach, or a Parallel loop whose ParallelOptions has no CancellationToken, never looks at the token. " + + "Give the validation stage's ParallelOptions the token RunImportAsync receives, and the loop checks it before it scores a single row."; + + const string _asyncVoidHint = "ImportService still has an async void lambda, and a cancelled token reaching one would throw where nothing can catch it and end the whole app, so this step did not cancel an import. " + + "Run Step 2 again and fix what it reports first."; + + public override int Number => 3; + + public override string Scenario => "Summarize with PLINQ, and stop when cancelled"; + + public override string Title => "RunImportAsync(), the summary stage and its token"; + + public override string Story => "When a bad file is uploaded, operations cancels the nightly import and expects it to stop. " + + "RunImportAsync has received a CancellationToken all along, but only the customer API call ever looked at it, so a cancelled import still scores every row before anything notices. " + + "Meanwhile the regional summary is the last stage still running on one thread, and it has to honor the same token."; + + public override string SeeItInTheApp => "The Import page has no Cancel button, so this step cancels an import for you. On the page, the Report card shows how long the regional grouping took on one thread, " + + "and Regional summary shows US-CA, US-NY, US-TX and US-WA with 1,000 orders each. None of those orders or revenue figures may change when the query moves to PLINQ."; + + public override string FileToChange => "Services/ImportService.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Turn the regional summary into a PLINQ query that honors the cancellation token.", + "Keep the query body the same, so the report still shows four regions with the same order counts and the same revenue.", + "Set CancellationToken through ParallelOptions on every Parallel loop, using the token RunImportAsync already receives, so a cancelled import stops before it scores a single row.", + "Let the OperationCanceledException reach the caller. A cancelled import has no report to return.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "The regional summary runs as a single-threaded LINQ query after every row is already in memory.", + "The method already receives a CancellationToken, and nothing except the customer API call ever uses it.", + "One extension method moves a LINQ query onto PLINQ, and another makes the query honor a token. ParallelOptions has a CancellationToken property too, and a Parallel loop checks it before it starts a single iteration.", + ]; + + public override string TimeoutHint => "The import never finished. Check that the regional summary query ends, and that a cancelled import stops instead of waiting."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + + // Part 1: a full import, and the regional summary it reports, compared with totals worked out straight from the same file + var importService = new ImportService(new OrderFileService(), new CustomerApiService()); + + report.Log($"Importing {_rowCount:N0} rows through a fresh ImportService"); + + var processorTimeBefore = GetProcessorTime(); + var importReport = await importService.RunImportAsync(_rowCount, CancellationToken.None).ConfigureAwait(false); + var fullImportProcessorTime = GetProcessorTime() - processorTimeBefore; + + report.Log($"The import used {fullImportProcessorTime.TotalMilliseconds:N0} ms of CPU time, and the summary took {importReport.ReportElapsed.TotalMilliseconds:N1} ms"); + + var expectedTotals = new OrderFileService().ReadOrders(_rowCount) + .GroupBy(static order => order.Region) + .OrderBy(static group => group.Key, StringComparer.Ordinal) + .Select(static group => new RegionTotal(group.Key, group.Count(), group.Sum(static order => order.Amount), AverageRisk: 0)) + .ToList(); + + report.Expect( + $"The summary has {expectedTotals.Count} regions with {_rowCount / expectedTotals.Count:N0} orders each", + FormatOrders(expectedTotals), + FormatOrders(importReport.RegionTotals), + FormatOrders(expectedTotals) == FormatOrders(importReport.RegionTotals), + _summaryHint); + + report.Expect( + "The revenue for every region matches the uploaded file", + FormatRevenue(expectedTotals), + FormatRevenue(importReport.RegionTotals), + FormatRevenue(expectedTotals) == FormatRevenue(importReport.RegionTotals), + _summaryHint); + + // Part 2: the summary takes about a millisecond, far too little to measure, so this reads ImportService's compiled code instead + var calledMethods = CodeInspector.GetCalledMethods(typeof(ImportService)); + string[] plinqMethods = ["AsParallel", "WithCancellation"]; + var calledPlinqMethods = plinqMethods.Where(name => calledMethods.Contains($"ParallelEnumerable.{name}")).Select(static name => $"{name}()").ToList(); + + report.Expect( + "The regional summary is a PLINQ query that honors the token", + "AsParallel(), WithCancellation()", + calledPlinqMethods.Count is 0 ? "neither" : string.Join(", ", calledPlinqMethods), + calledPlinqMethods.Count == plinqMethods.Length, + _plinqHint); + + // Part 3: cancel an import. Only safe once no async void lambda is left: a cancelled token reaching one would end the whole app. + var asyncVoidMethods = CodeInspector.FindAsyncVoidMethods(typeof(ImportService)); + + if (asyncVoidMethods.Count > 0) + { + report.Log($"ImportService has {asyncVoidMethods.Count} async void lambdas or methods, so this step will not cancel an import"); + report.Expect("A cancelled import throws OperationCanceledException", nameof(OperationCanceledException), "not run: ImportService has async void code", false, _asyncVoidHint); + report.Expect("A cancelled import stops before it scores rows", $"under {_maximumCancelledShare:P0} of a full import's CPU time", "not run: ImportService has async void code", false, _asyncVoidHint); + return; + } + + // Cancelled before it starts, the way operations cancels a job the moment a bad file is spotted + var cancelledToken = new CancellationToken(canceled: true); + string? thrownException = null; + + report.Log($"Importing {_rowCount:N0} rows with a CancellationToken that is already cancelled"); + + processorTimeBefore = GetProcessorTime(); + + try + { + await importService.RunImportAsync(_rowCount, cancelledToken).ConfigureAwait(false); + } + catch (OperationCanceledException e) when (cancelledToken.IsCancellationRequested) + { + thrownException = e.GetType().Name; + } + + var cancelledProcessorTime = GetProcessorTime() - processorTimeBefore; + var cancelledShare = cancelledProcessorTime / fullImportProcessorTime; + + report.Log($"The cancelled import {(thrownException is null ? "returned a report" : $"threw {thrownException}")} after {cancelledProcessorTime.TotalMilliseconds:N0} ms of CPU time, {cancelledShare:P0} of what the full import used"); + + // TaskCanceledException derives from OperationCanceledException, and either one tells the caller the import was cancelled + var outcome = thrownException switch + { + null => "returned a report", + nameof(OperationCanceledException) => thrownException, + _ => $"{thrownException}, an OperationCanceledException", + }; + + report.Expect("A cancelled import throws OperationCanceledException", nameof(OperationCanceledException), outcome, thrownException is not null, _ranToTheEndHint); + + report.Expect( + "A cancelled import stops before it scores rows", + $"under {_maximumCancelledShare:P0} of a full import's CPU time", + $"{cancelledShare:P0} ({cancelledProcessorTime.TotalMilliseconds:N0} ms)", + cancelledShare < _maximumCancelledShare, + _scoredRowsFirstHint); + } + + static string FormatOrders(IEnumerable totals) => string.Join(", ", totals.Select(static total => $"{total.Region} {total.Orders:N0}")); + + static string FormatRevenue(IEnumerable totals) => string.Join(", ", totals.Select(static total => $"{total.Region} {total.Revenue:C0}")); +} \ No newline at end of file diff --git a/7. Data Parallelism/2. Finish/ImportPortal/Steps/StepContext.cs b/7. Data Parallelism/2. Finish/ImportPortal/Steps/StepContext.cs new file mode 100644 index 0000000..f97d15d --- /dev/null +++ b/7. Data Parallelism/2. Finish/ImportPortal/Steps/StepContext.cs @@ -0,0 +1,4 @@ +namespace ImportPortal; + +// Token is cancelled when the run is stopped, times out, or the app shuts down +public sealed record StepContext(StepReport Report, CancellationToken Token); \ No newline at end of file diff --git a/7. Data Parallelism/2. Finish/ImportPortal/Steps/WorkshopStep.cs b/7. Data Parallelism/2. Finish/ImportPortal/Steps/WorkshopStep.cs new file mode 100644 index 0000000..54f660e --- /dev/null +++ b/7. Data Parallelism/2. Finish/ImportPortal/Steps/WorkshopStep.cs @@ -0,0 +1,48 @@ +using System.Diagnostics; + +namespace ImportPortal; + +// Every step is one stage of ImportPortal's nightly import. +// Its tab in the workshop guide tells you what to fix, and Run() runs your ImportService on fresh services, logs what it measured, +// and records an expected result, with a hint, for everything that should be true once the stage is fixed. +public abstract class WorkshopStep +{ + public abstract int Number { get; } + + // What this step fixes, in a few words + public abstract string Scenario { get; } + + // The members this step exercises + public abstract string Title { get; } + + // What goes wrong in production, and why nobody noticed + public abstract string Story { get; } + + // How to watch this bug happen on the Import page + public abstract string SeeItInTheApp { get; } + + // Where the bug lives + public abstract string FileToChange { get; } + + // What this step asks you to change. Some of it cannot be measured from outside, so not every task has an expected result. + public abstract IReadOnlyList Tasks { get; } + + // Shown only when you ask for them + public abstract IReadOnlyList Clues { get; } + + // What is most likely waiting forever when this step does not finish in time + public abstract string TimeoutHint { get; } + + public string SourceFile => $"Steps/{GetType().Name}.cs"; + + public abstract Task Run(StepContext context); + + // The CPU time every thread in this app has used so far. The difference between two readings, divided by the + // wall-clock time between them, is how many cores that work kept busy on average. + protected static TimeSpan GetProcessorTime() + { + using var process = Process.GetCurrentProcess(); + + return process.TotalProcessorTime; + } +} \ No newline at end of file diff --git a/7. Data Parallelism/2. Finish/ImportPortal/Verification/ActiveStepRun.cs b/7. Data Parallelism/2. Finish/ImportPortal/Verification/ActiveStepRun.cs new file mode 100644 index 0000000..b127117 --- /dev/null +++ b/7. Data Parallelism/2. Finish/ImportPortal/Verification/ActiveStepRun.cs @@ -0,0 +1,3 @@ +namespace ImportPortal; + +public sealed record ActiveStepRun(WorkshopStep Step, StepReport Report); \ No newline at end of file diff --git a/7. Data Parallelism/2. Finish/ImportPortal/Verification/CodeInspector.cs b/7. Data Parallelism/2. Finish/ImportPortal/Verification/CodeInspector.cs new file mode 100644 index 0000000..e09c141 --- /dev/null +++ b/7. Data Parallelism/2. Finish/ImportPortal/Verification/CodeInspector.cs @@ -0,0 +1,98 @@ +using System.Reflection; +using System.Reflection.Emit; +using System.Runtime.CompilerServices; + +namespace ImportPortal; + +// Workshop plumbing: reads the compiled IL of a type, including its lambdas, local functions and async state machines. +// The steps use it only for requirements with nothing to observe from outside, such as a PLINQ query that finishes in a millisecond. +public static class CodeInspector +{ + const BindingFlags _declaredMembers = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + + static readonly Dictionary _opCodes = typeof(OpCodes) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Select(static field => (OpCode)(field.GetValue(null) ?? throw new InvalidOperationException($"OpCodes.{field.Name} has no value"))) + .ToDictionary(static opCode => opCode.Value); + + // Every method the type calls, creates a delegate for, or constructs, named "DeclaringType.Name", for example "ParallelEnumerable.AsParallel" + public static IReadOnlySet GetCalledMethods(Type type) + { + var calledMethods = new HashSet(StringComparer.Ordinal); + + foreach (var method in GetMethodsAndConstructors(type)) + { + AddCalledMethods(method, calledMethods); + } + + return calledMethods; + } + + // Lambdas, local functions and methods that are async void. Nothing can await one, and an exception inside one ends the app. + public static IReadOnlyList FindAsyncVoidMethods(Type type) => + [ + .. GetTypeAndNestedTypes(type) + .SelectMany(static declaringType => declaringType.GetMethods(_declaredMembers)) + .Where(static method => method.ReturnType == typeof(void) && method.IsDefined(typeof(AsyncStateMachineAttribute), inherit: false)), + ]; + + static IEnumerable GetMethodsAndConstructors(Type type) => GetTypeAndNestedTypes(type) + .SelectMany(static declaringType => declaringType.GetMethods(_declaredMembers).Cast().Concat(declaringType.GetConstructors(_declaredMembers))); + + // The compiler puts closures, lambdas and async state machines in nested types, sometimes nested more than one level deep + static IEnumerable GetTypeAndNestedTypes(Type type) => + [type, .. type.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic).SelectMany(GetTypeAndNestedTypes)]; + + static void AddCalledMethods(MethodBase method, HashSet calledMethods) + { + var il = method.GetMethodBody()?.GetILAsByteArray(); + + if (il is null) + return; + + var typeArguments = method.DeclaringType is { IsGenericType: true } declaringType ? declaringType.GetGenericArguments() : null; + var methodArguments = method.IsGenericMethod ? method.GetGenericArguments() : null; + + var position = 0; + + while (position < il.Length) + { + // Two-byte opcodes start with 0xFE + var value = il[position] is 0xFE && position + 1 < il.Length + ? unchecked((short)(0xFE00 | il[++position])) + : il[position]; + + position++; + + if (!_opCodes.TryGetValue(value, out var opCode)) + return; + + if (opCode.OperandType is OperandType.InlineMethod && position + 4 <= il.Length) + { + var token = BitConverter.ToInt32(il, position); + + try + { + if (method.Module.ResolveMethod(token, typeArguments, methodArguments) is { DeclaringType: { } calledType } calledMethod) + calledMethods.Add($"{calledType.Name}.{calledMethod.Name}"); + } + catch (ArgumentException) + { + // A token this module cannot resolve is not a call the steps look for + } + } + + position += GetOperandSize(opCode, il, position); + } + } + + static int GetOperandSize(OpCode opCode, byte[] il, int position) => opCode.OperandType switch + { + OperandType.InlineNone => 0, + OperandType.ShortInlineBrTarget or OperandType.ShortInlineI or OperandType.ShortInlineVar => 1, + OperandType.InlineVar => 2, + OperandType.InlineI8 or OperandType.InlineR => 8, + OperandType.InlineSwitch when position + 4 <= il.Length => 4 + (BitConverter.ToInt32(il, position) * 4), + _ => 4, + }; +} \ No newline at end of file diff --git a/7. Data Parallelism/2. Finish/ImportPortal/Verification/StepCheck.cs b/7. Data Parallelism/2. Finish/ImportPortal/Verification/StepCheck.cs new file mode 100644 index 0000000..c98e617 --- /dev/null +++ b/7. Data Parallelism/2. Finish/ImportPortal/Verification/StepCheck.cs @@ -0,0 +1,3 @@ +namespace ImportPortal; + +public sealed record StepCheck(string Description, string Expected, string Actual, bool Passed, string Hint); \ No newline at end of file diff --git a/7. Data Parallelism/2. Finish/ImportPortal/Verification/StepLogEntry.cs b/7. Data Parallelism/2. Finish/ImportPortal/Verification/StepLogEntry.cs new file mode 100644 index 0000000..9bd73d7 --- /dev/null +++ b/7. Data Parallelism/2. Finish/ImportPortal/Verification/StepLogEntry.cs @@ -0,0 +1,3 @@ +namespace ImportPortal; + +public sealed record StepLogEntry(TimeSpan Elapsed, int ThreadId, bool IsThreadPoolThread, string Message); \ No newline at end of file diff --git a/7. Data Parallelism/2. Finish/ImportPortal/Verification/StepReport.cs b/7. Data Parallelism/2. Finish/ImportPortal/Verification/StepReport.cs new file mode 100644 index 0000000..5927ee6 --- /dev/null +++ b/7. Data Parallelism/2. Finish/ImportPortal/Verification/StepReport.cs @@ -0,0 +1,82 @@ +using System.Diagnostics; + +namespace ImportPortal; + +// Collects what a step logged and every expected result it checked. +// Steps write to it from thread pool threads while the workshop guide reads it, so every member takes the lock. +public sealed class StepReport +{ + readonly Lock _lock = new(); + readonly Stopwatch _stopwatch = Stopwatch.StartNew(); + readonly List _entries = []; + readonly List _checks = []; + + bool _isComplete; + + public IReadOnlyList Entries + { + get + { + lock (_lock) + { + return [.. _entries]; + } + } + } + + public IReadOnlyList Checks + { + get + { + lock (_lock) + { + return [.. _checks]; + } + } + } + + public void Log(string message) + { + var threadId = Environment.CurrentManagedThreadId; + var isThreadPoolThread = Thread.CurrentThread.IsThreadPoolThread; + + lock (_lock) + { + if (!_isComplete) + _entries.Add(new StepLogEntry(_stopwatch.Elapsed, threadId, isThreadPoolThread, message)); + } + } + + // The hint is shown under the expected result only when it does not match + public void Expect(string description, T expected, T actual, string hint) + { + Expect(description, Format(expected), Format(actual), EqualityComparer.Default.Equals(expected, actual), hint); + } + + public void Expect(string description, string expected, string actual, bool passed, string hint) + { + lock (_lock) + { + if (!_isComplete) + _checks.Add(new StepCheck(description, expected, actual, passed, hint)); + } + } + + // Called when the run ends, including a run that timed out or was stopped while its step is still blocked somewhere. + // Anything that abandoned step writes later is ignored, so the result in the workshop guide can no longer change. + public void Complete() + { + lock (_lock) + { + _isComplete = true; + _stopwatch.Stop(); + } + } + + static string Format(T value) => value switch + { + null => "null", + int or long => $"{value:N0}", + _ => value.ToString() ?? "null", + }; +} \ No newline at end of file diff --git a/7. Data Parallelism/2. Finish/ImportPortal/Verification/StepState.cs b/7. Data Parallelism/2. Finish/ImportPortal/Verification/StepState.cs new file mode 100644 index 0000000..1eea41e --- /dev/null +++ b/7. Data Parallelism/2. Finish/ImportPortal/Verification/StepState.cs @@ -0,0 +1,9 @@ +namespace ImportPortal; + +// ExceptionType is set for Crashed. Only the name is kept: exception messages and stack traces stay in the terminal. +// CompletedAt is when the run that produced this state ended, so the workshop guide can show that a click really ran again. +public sealed record StepState( + StepStatus Status, + StepReport? Report, + string? ExceptionType = null, + DateTimeOffset? CompletedAt = null); \ No newline at end of file diff --git a/7. Data Parallelism/2. Finish/ImportPortal/Verification/StepStatus.cs b/7. Data Parallelism/2. Finish/ImportPortal/Verification/StepStatus.cs new file mode 100644 index 0000000..d8def28 --- /dev/null +++ b/7. Data Parallelism/2. Finish/ImportPortal/Verification/StepStatus.cs @@ -0,0 +1,12 @@ +namespace ImportPortal; + +public enum StepStatus +{ + NotRun, + Running, + Passed, + Failed, + TimedOut, + Crashed, + Stopped, +} \ No newline at end of file diff --git a/7. Data Parallelism/2. Finish/ImportPortal/Verification/StepVerifier.cs b/7. Data Parallelism/2. Finish/ImportPortal/Verification/StepVerifier.cs new file mode 100644 index 0000000..c940830 --- /dev/null +++ b/7. Data Parallelism/2. Finish/ImportPortal/Verification/StepVerifier.cs @@ -0,0 +1,230 @@ +namespace ImportPortal; + +// Workshop plumbing: runs the steps against your services and keeps their results. +// Every step creates its own instances of the services, so a check never disturbs the singletons the Import page uses. +// Only one step runs at a time, so a step that measures CPU time or concurrent calls never shares the machine with another run. +public sealed class StepVerifier(ILogger logger, IHostApplicationLifetime applicationLifetime) : BackgroundService +{ + public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); + + readonly Lock _lock = new(); + readonly StepState[] _states = [.. Enumerable.Repeat(new StepState(StepStatus.NotRun, null), 3)]; + + // Both start at 1: the app checks every step on startup, and nothing else may run before that finishes + int _isBusy = 1; + int _isCheckingAllSteps = 1; + + ActiveStepRun? _activeRun; + TaskCompletionSource? _stopRequested; + + public IReadOnlyList Steps { get; } = + [ + new Step1ScoreEveryRow(), + new Step2EnrichEveryRow(), + new Step3SummarizeAndCancel(), + ]; + + public bool IsBusy => Volatile.Read(ref _isBusy) is 1; + + public bool IsCheckingAllSteps => Volatile.Read(ref _isCheckingAllSteps) is 1; + + public ActiveStepRun? ActiveRun + { + get + { + lock (_lock) + { + return _activeRun; + } + } + } + + public WorkshopStep? FindStep(int number) => Steps.FirstOrDefault(step => step.Number == number); + + public StepState GetState(int number) + { + lock (_lock) + { + return _states[number - 1]; + } + } + + public bool IsUnlocked(int number) => number is 1 || GetState(number - 1).Status is StepStatus.Passed; + + // Re-runs every step, the same way startup does + public async Task CheckAllSteps() + { + if (Interlocked.CompareExchange(ref _isBusy, 1, 0) is not 0) + return; + + Volatile.Write(ref _isCheckingAllSteps, 1); + + try + { + await CheckSteps(applicationLifetime.ApplicationStopping).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isCheckingAllSteps, 0); + Volatile.Write(ref _isBusy, 0); + } + } + + public async Task RunStep(WorkshopStep step) + { + if (!IsUnlocked(step.Number) || Interlocked.CompareExchange(ref _isBusy, 1, 0) is not 0) + return GetState(step.Number); + + try + { + return await Run(step, applicationLifetime.ApplicationStopping).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isBusy, 0); + } + } + + public void StopRun() + { + lock (_lock) + { + _stopRequested?.TrySetResult(); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await CheckSteps(stoppingToken).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isCheckingAllSteps, 0); + Volatile.Write(ref _isBusy, 0); + } + } + + async Task CheckSteps(CancellationToken token) + { + try + { + // Results from before the latest change to the services are not trusted + lock (_lock) + { + Array.Fill(_states, new StepState(StepStatus.NotRun, null)); + } + + logger.LogInformation("Checking your services against every step. Open the app in your browser: the workshop guide beside the Import page shows what to fix next"); + + foreach (var step in Steps) + { + var state = await Run(step, token).ConfigureAwait(false); + + if (state.Status is not StepStatus.Passed) + { + logger.LogInformation("Stopped checking at Step {StepNumber}. Open it in the app to see what to change, with a hint for every result that did not match. Later steps unlock after it passes", step.Number); + return; + } + } + + logger.LogInformation("Every step passed. Pause here for the group review"); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The app is shutting down + } + catch (Exception e) + { + logger.LogError(e, "Checking the steps failed unexpectedly"); + } + } + + async Task Run(WorkshopStep step, CancellationToken token) + { + var report = new StepReport(); + var stopRequested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var previousState = GetState(step.Number); + + lock (_lock) + { + _activeRun = new ActiveStepRun(step, report); + _stopRequested = stopRequested; + _states[step.Number - 1] = new StepState(StepStatus.Running, report); + } + + // Cancelled when the run ends for any reason, so a step that is still working can stop + using var runCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + + var state = previousState; + + try + { + state = await Execute(step, report, stopRequested.Task, runCancellationTokenSource.Token, token).ConfigureAwait(false); + } + finally + { + await runCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + report.Complete(); + + lock (_lock) + { + _activeRun = null; + _stopRequested = null; + _states[step.Number - 1] = state with { CompletedAt = DateTimeOffset.Now }; + } + } + + return state; + } + + async Task Execute(WorkshopStep step, StepReport report, Task stopRequested, CancellationToken runToken, CancellationToken token) + { + try + { + // A dedicated thread runs the step until its first await. If a deadlock blocks it forever, + // a thread pool thread is not lost for the rest of the app's life. + var stepTask = Task.Factory.StartNew(() => step.Run(new StepContext(report, runToken)), token, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); + + var finished = await Task.WhenAny(stepTask, stopRequested).WaitAsync(Timeout, token).ConfigureAwait(false); + + if (finished == stopRequested) + { + logger.LogInformation("Step {StepNumber} was stopped", step.Number); + return new StepState(StepStatus.Stopped, report); + } + + await stepTask.ConfigureAwait(false); + + var checks = report.Checks; + var passedChecks = checks.Count(static check => check.Passed); + var status = checks.Count > 0 && passedChecks == checks.Count ? StepStatus.Passed : StepStatus.Failed; + + logger.LogInformation("Step {StepNumber} {Result} ({PassedChecks}/{TotalChecks} checks)", step.Number, status is StepStatus.Passed ? "passed" : "failed", passedChecks, checks.Count); + + // The hints belong in the terminal too, next to anything the app logged while the step ran + foreach (var check in checks.Where(static check => !check.Passed)) + { + logger.LogWarning("Step {StepNumber}: {Description}. Expected {Expected}, actual {Actual}. Hint: {Hint}", step.Number, check.Description, check.Expected, check.Actual, check.Hint); + } + + return new StepState(status, report); + } + catch (TimeoutException) + { + logger.LogWarning("Step {StepNumber} did not finish within {Timeout}. {TimeoutHint}", step.Number, Timeout, step.TimeoutHint); + return new StepState(StepStatus.TimedOut, report); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + return new StepState(StepStatus.NotRun, null); + } + catch (Exception e) + { + logger.LogError(e, "Step {StepNumber} threw an unexpected exception", step.Number); + return new StepState(StepStatus.Crashed, report, e.GetType().Name); + } + } +} \ No newline at end of file diff --git a/7. Data Parallelism/2. Finish/ImportPortal/wwwroot/app.css b/7. Data Parallelism/2. Finish/ImportPortal/wwwroot/app.css index c5a79b2..4a46dfd 100644 --- a/7. Data Parallelism/2. Finish/ImportPortal/wwwroot/app.css +++ b/7. Data Parallelism/2. Finish/ImportPortal/wwwroot/app.css @@ -1,18 +1,48 @@ +/* ImportPortal: a back-office data operations console, with the workshop guide docked beside it as the on-call runbook for tonight's import */ + :root { - --canvas: #eef3f1; - --panel: #ffffff; - --panel-strong: #112a2d; - --ink: #18211f; - --muted: #62706d; - --line: #d7e0dd; - --accent: #f46a1f; - --teal: #0f766e; - --good: #0f8a52; - --bad: #cf202f; - --warn: #c59d35; - --shadow: 0 18px 55px rgb(28 45 42 / 14%); + /* The console */ + --canvas: #eceff3; + --canvas-dot: #d3d9e1; + --surface: #ffffff; + --surface-sunken: #f5f7fa; + --line: #d6dce4; + --line-strong: #b8c1cd; + --ink: #152030; + --muted: #556173; + --accent: #0b6e8f; + --accent-dark: #085a75; + --accent-soft: #e0f0f5; + --good: #147a3d; + --good-soft: #e5f3ea; + --bad: #c02a2a; + --bad-soft: #fbe9e9; + --stage-validate: #0b6e8f; + --stage-enrich: #7b57c9; + --stage-summarize: #d99100; + + /* The runbook */ + --rb-bg: #0b0d10; + --rb-panel: #13161b; + --rb-raised: #1a1e24; + --rb-line: #292f38; + --rb-ink: #e6e9ed; + --rb-bright: #f7f8fa; + --rb-muted: #a2abb6; + --rb-amber: #f5b041; + --rb-amber-soft: #241c0c; + --rb-green: #5ad68f; + --rb-green-soft: #0f2619; + --rb-red: #ff6f73; + --rb-red-soft: #2a1416; + --rb-cyan: #62cde3; + --rb-cyan-soft: #0d1f25; + + --sans: "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif; + --mono: "Cascadia Mono", "Cascadia Code", "SF Mono", ui-monospace, Menlo, Consolas, monospace; + --guide-width: 460px; color-scheme: light; - font-family: "Aptos", "Avenir Next", "Segoe UI", sans-serif; + font-family: var(--sans); background: var(--canvas); color: var(--ink); } @@ -24,160 +54,458 @@ body { margin: 0; overflow-x: clip; - background: - linear-gradient(135deg, rgb(15 118 110 / 14%), transparent 32%), - radial-gradient(circle at top right, rgb(244 106 31 / 16%), transparent 30%), - var(--canvas); + background: var(--canvas); +} + +/* FocusOnNavigate moves focus to the page heading for screen readers; the heading is not interactive, so hide the ring */ +h1:focus { + outline: none; +} + +code { + font-family: var(--mono); + font-size: 0.88em; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +/* The app and the guide share the screen */ + +.workspace { + display: flex; + min-height: 100vh; +} + +.app-pane { + flex: 1 1 auto; + min-width: 0; + container-type: inline-size; +} + +/* Buttons */ + +button.action { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 18px; + border: 1px solid var(--accent-dark); + border-radius: 6px; + background: var(--accent); + color: #fff; + font: inherit; + font-weight: 600; + cursor: pointer; +} + +button.action:hover:not(:disabled) { + background: var(--accent-dark); +} + +button.action:disabled { + border-color: #8d97a5; + background: #8d97a5; + cursor: progress; +} + +button.action.secondary { + border-color: var(--line-strong); + background: transparent; + color: inherit; +} + +button.action.small { + padding: 6px 12px; + font-size: 0.85rem; +} + +.spinner { + display: inline-block; + flex: none; + width: 14px; + height: 14px; + border: 2px solid rgb(255 255 255 / 35%); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.8s linear infinite; } -.shell { +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* The console */ + +.console { min-height: 100vh; + background-color: var(--canvas); + background-image: radial-gradient(var(--canvas-dot) 1px, transparent 1.5px); + background-size: 20px 20px; +} + +.console-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 18px; + padding: 10px 24px; + border-bottom: 1px solid var(--line); + background: var(--surface); +} + +.console-brand { + display: inline-flex; + align-items: center; + gap: 9px; + font-weight: 700; + letter-spacing: -0.01em; +} + +/* Three rows in a table, drawn with gradients so the app needs no image */ +.console-mark { + width: 22px; + height: 22px; + border-radius: 5px; + background: + linear-gradient(#fff 0 0) 5px 5px / 12px 2px no-repeat, + linear-gradient(#fff 0 0) 5px 10px / 12px 2px no-repeat, + linear-gradient(#fff 0 0) 5px 15px / 8px 2px no-repeat, + var(--accent); +} + +.console-crumbs { + flex: 1 1 auto; + color: var(--muted); + font-size: 0.88rem; +} + +.console-crumbs span { + margin: 0 4px; + color: var(--line-strong); +} + +.env-badge { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 3px 10px; + border-radius: 4px; + background: var(--ink); + color: #fff; + font-family: var(--mono); + font-size: 0.76rem; + white-space: nowrap; +} + +.env-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #4ade80; } -.page { - width: min(1120px, calc(100% - 32px)); +.console-main { + width: min(1120px, calc(100% - 48px)); margin: 0 auto; - padding: 32px 0 56px; + padding: 22px 0 56px; } -.hero-panel { - display: grid; - grid-template-columns: 1fr auto; - gap: 28px; - align-items: end; - margin-bottom: 22px; - padding: 30px; - border: 1px solid rgb(36 35 31 / 10%); +.job-header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-end; + gap: 18px 28px; + margin-bottom: 16px; + padding: 22px 24px; + border: 1px solid var(--line); + border-left: 4px solid var(--accent); border-radius: 8px; - background: linear-gradient(135deg, var(--panel-strong), #15463e 58%, #6f3b17); - box-shadow: var(--shadow); - color: #f8fffc; + background: var(--surface); + box-shadow: 0 1px 2px rgb(21 32 48 / 6%); +} + +.job-copy { + flex: 1 1 380px; + min-width: 0; } -.hero-copy { - max-width: 680px; +.job-kicker { + margin: 0 0 4px; + color: var(--accent); + font-family: var(--mono); + font-size: 0.76rem; + letter-spacing: 0.04em; } -.eyebrow { +.job-header h1 { margin: 0 0 6px; - font-size: 0.78rem; - font-weight: 600; - letter-spacing: 0.12em; - text-transform: uppercase; - color: rgb(248 255 252 / 72%); + font-size: clamp(1.45rem, 3cqi, 1.85rem); + font-weight: 650; + letter-spacing: -0.015em; } -.hero-panel h1 { - margin: 0 0 10px; - font-size: clamp(1.8rem, 3vw, 2.5rem); - line-height: 1.1; +.job-summary { + max-width: 620px; + margin: 0 0 14px; + color: var(--muted); + line-height: 1.5; } -.hero-summary { +.job-meta { + display: flex; + flex-wrap: wrap; + gap: 8px 26px; margin: 0; - color: rgb(248 255 252 / 80%); } -.hero-actions { +.job-meta div { display: grid; + gap: 1px; +} + +.job-meta dt { + color: var(--muted); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.job-meta dd { + margin: 0; + font-family: var(--mono); + font-size: 0.86rem; +} + +.job-actions { + display: flex; + flex-wrap: wrap; + align-items: stretch; gap: 10px; - justify-items: end; } -button.action { - padding: 12px 22px; - border: 0; +.status-tile { + display: grid; + align-content: center; + gap: 2px; + padding: 8px 14px; + border: 1px solid var(--line); border-radius: 6px; - background: var(--accent); - color: #fff; - font: inherit; - font-weight: 600; - cursor: pointer; + background: var(--surface-sunken); } -button.action:hover:not(:disabled) { - background: #d4560f; +.status-value { + color: var(--accent); + font-family: var(--mono); + font-size: 1.5rem; + font-weight: 600; + line-height: 1.1; + font-variant-numeric: tabular-nums; } -button.action:disabled { - background: var(--muted); - cursor: progress; +.status-label { + color: var(--muted); + font-size: 0.78rem; } -button.action.secondary { - background: rgb(248 255 252 / 14%); - border: 1px solid rgb(248 255 252 / 30%); +.run-button { + padding-inline: 20px; } -button.action.secondary:hover:not(:disabled) { - background: rgb(248 255 252 / 24%); +/* A play triangle, drawn with borders */ +.run-button:not(:disabled)::before { + content: ""; + width: 0; + height: 0; + border-block: 5px solid transparent; + border-left: 8px solid currentColor; } .panel { margin-bottom: 16px; - padding: 22px 24px; + padding: 20px 24px 22px; border: 1px solid var(--line); border-radius: 8px; - background: var(--panel); - box-shadow: 0 10px 26px rgb(28 45 42 / 8%); + background: var(--surface); + box-shadow: 0 1px 2px rgb(21 32 48 / 6%); +} + +.panel-header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-start; + gap: 8px 20px; + margin-bottom: 16px; +} + +.panel-header > div { + flex: 1 1 320px; } .panel h2 { margin: 0 0 4px; - font-size: 1.15rem; + font-size: 1.05rem; + font-weight: 650; } .panel-hint { - margin: 0 0 16px; + max-width: 720px; + margin: 0; color: var(--muted); - font-size: 0.92rem; + font-size: 0.9rem; + line-height: 1.5; +} + +.run-state { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 3px 10px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--surface-sunken); + color: var(--muted); + font-size: 0.8rem; + font-weight: 600; + white-space: nowrap; +} + +.run-state::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--line-strong); +} + +.run-state.running { + border-color: rgb(11 110 143 / 35%); + background: var(--accent-soft); + color: var(--accent-dark); +} + +.run-state.running::before { + background: var(--accent); + animation: pulse 1s ease-in-out infinite; +} + +.run-state.finished::before { + background: var(--ink); } -.result-grid { +@keyframes pulse { + 50% { + opacity: 0.25; + } +} + +/* The pipeline: three stage cards joined by connectors */ + +.pipeline { display: grid; - grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); - gap: 12px; + grid-template-columns: minmax(0, 1fr) 34px minmax(0, 1fr) 34px minmax(0, 1fr); + margin-bottom: 12px; } .result-card { display: grid; - gap: 4px; + align-content: start; + gap: 3px; padding: 14px 16px; border: 1px solid var(--line); - border-left: 4px solid var(--muted); + border-top: 3px solid var(--line-strong); border-radius: 6px; - background: #fbfdfc; + background: var(--surface); } .result-card.ok { - border-left-color: var(--good); + border-top-color: var(--good); } .result-card.bad { - border-left-color: var(--bad); + border-top-color: var(--bad); + background: linear-gradient(var(--bad-soft), var(--surface) 70%); } -.result-card.pending { - border-left-color: var(--warn); +.stage-top { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + margin-bottom: 6px; } -.result-label { - font-size: 0.78rem; +.stage-index { + padding: 1px 6px; + border-radius: 4px; + color: #fff; + font-family: var(--mono); + font-size: 0.72rem; font-weight: 600; - letter-spacing: 0.06em; - text-transform: uppercase; +} + +.stage-index.validate, +.duration-segment.validate { + background: var(--stage-validate); +} + +.stage-index.enrich, +.duration-segment.enrich { + background: var(--stage-enrich); +} + +.stage-index.summarize, +.duration-segment.summarize { + background: var(--stage-summarize); +} + +.stage-index.summarize { + color: #2a1c00; +} + +.stage-kind { color: var(--muted); + font-family: var(--mono); + font-size: 0.72rem; } -.result-value { - font-size: 1.5rem; +.result-label { + color: var(--muted); + font-size: 0.74rem; font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.result-value { + font-family: var(--mono); + font-size: 1.65rem; + font-weight: 600; + line-height: 1.2; font-variant-numeric: tabular-nums; } +.result-card.idle .result-value { + color: var(--line-strong); +} + .result-note { - font-size: 0.85rem; color: var(--muted); + font-family: var(--mono); + font-size: 0.8rem; + font-variant-numeric: tabular-nums; } .result-card.ok .result-note { @@ -188,84 +516,985 @@ button.action.secondary:hover:not(:disabled) { color: var(--bad); } -.timing { - font-variant-numeric: tabular-nums; - font-weight: 600; +.pipeline-connector { + position: relative; } -.log { - max-height: 260px; - overflow-y: auto; - margin: 0; - padding: 14px 16px; +/* The line between two stages */ +.pipeline-connector::before { + content: ""; + position: absolute; + top: 50%; + right: 7px; + left: 0; + height: 2px; + background: var(--line-strong); +} + +/* Its arrowhead */ +.pipeline-connector::after { + content: ""; + position: absolute; + top: calc(50% - 5px); + right: 0; + border-block: 6px solid transparent; + border-left: 8px solid var(--line-strong); + transform: translateY(-1px); +} + +/* While the import runs, rows flow along the connectors */ +.pipeline.running .pipeline-connector::before { + background: repeating-linear-gradient(90deg, var(--accent) 0 6px, transparent 6px 11px); + background-size: 22px 2px; + animation: flow 0.7s linear infinite; +} + +.pipeline.running .pipeline-connector::after { + border-left-color: var(--accent); +} + +@keyframes flow { + to { + background-position: 22px 0; + } +} + +.total-card { + grid-template-columns: max-content minmax(0, 1fr); + align-items: center; + gap: 10px 32px; + border-top-width: 1px; + border-left: 3px solid var(--ink); + background: var(--surface-sunken); +} + +.total-card.idle { + border-left-color: var(--line-strong); +} + +.total-copy { + display: grid; + gap: 2px; +} + +.duration { + display: grid; + gap: 6px; +} + +.duration-bar { + display: flex; + gap: 2px; + height: 12px; + overflow: hidden; + border-radius: 3px; + background: var(--line); +} + +.duration-segment { + flex-basis: 0; + min-width: 3px; +} + +.duration-caption { + color: var(--muted); + font-size: 0.8rem; +} + +/* The regional summary */ + +.data-scroll { + overflow-x: auto; border: 1px solid var(--line); border-radius: 6px; - background: #1e2227; - color: #d7dde6; - font-family: "Menlo", "Consolas", monospace; - font-size: 0.82rem; - line-height: 1.6; - white-space: pre-wrap; } -.status-tile { - display: grid; - gap: 4px; - justify-items: end; - padding: 14px 18px; - border: 1px solid rgb(248 255 252 / 22%); - border-radius: 8px; - background: rgb(248 255 252 / 10%); +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.92rem; } -.status-value { - font-size: 1.9rem; +.data-table th, +.data-table td { + padding: 9px 14px; + border-bottom: 1px solid var(--line); + text-align: left; + white-space: nowrap; +} + +.data-table thead th { + background: var(--surface-sunken); + color: var(--muted); + font-size: 0.74rem; font-weight: 700; - line-height: 1; - font-variant-numeric: tabular-nums; + letter-spacing: 0.06em; + text-transform: uppercase; } -.status-label { - font-size: 0.82rem; - color: rgb(248 255 252 / 78%); +.data-table tbody tr:last-child > * { + border-bottom: 0; } -.spinner { - display: inline-block; - width: 14px; - height: 14px; - margin-right: 8px; - border: 2px solid rgb(98 112 109 / 35%); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; - vertical-align: -2px; +.data-table tbody th { + font-family: var(--mono); + font-weight: 600; } -@keyframes spin { - to { - transform: rotate(360deg); - } +.data-table .num { + font-family: var(--mono); + font-variant-numeric: tabular-nums; + text-align: right; } -.warning-banner { - margin-bottom: 16px; - padding: 14px 18px; - border: 1px solid rgb(197 157 53 / 45%); - border-left: 4px solid var(--warn); - border-radius: 6px; - background: #fdf8e9; - color: #6b551a; +.data-table thead .num { + font-family: var(--sans); } -@media (max-width: 640px) { - .hero-panel { - grid-template-columns: 1fr; - align-items: start; +.data-table .empty-row td { + padding: 18px 14px; + color: var(--muted); + text-align: center; + white-space: normal; +} + +@container (max-width: 720px) { + .console-crumbs { + display: none; + } + + .console-brand { + flex: 1 1 auto; + } + + .console-main { + width: calc(100% - 32px); + } + + .pipeline { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto 26px auto 26px auto; + } + + .pipeline-connector::before { + top: 0; + right: auto; + bottom: 7px; + left: 50%; + width: 2px; + height: auto; + } + + .pipeline-connector::after { + top: auto; + right: auto; + bottom: 0; + left: calc(50% - 5px); + border-block: 0; + border-inline: 6px solid transparent; + border-top: 8px solid var(--line-strong); + transform: none; + } + + .pipeline.running .pipeline-connector::before { + background: repeating-linear-gradient(180deg, var(--accent) 0 6px, transparent 6px 11px); + background-size: 2px 22px; + animation-name: flow-down; + } + + .pipeline.running .pipeline-connector::after { + border-top-color: var(--accent); + } + + .total-card { + grid-template-columns: minmax(0, 1fr); + } + + .job-header, + .panel { + padding-inline: 18px; + } +} + +@keyframes flow-down { + to { + background-position: 0 22px; + } +} + +/* The workshop guide: tonight's runbook, open on the on-call laptop beside the console */ + +.guide { + position: sticky; + top: 0; + flex: 0 0 var(--guide-width); + height: 100vh; + overflow-y: auto; + border-top: 3px solid var(--rb-amber); + background: var(--rb-bg); + box-shadow: -10px 0 30px rgb(11 13 16 / 22%); + color: var(--rb-ink); + font-family: var(--sans); + font-size: 0.95rem; + scrollbar-color: #3a414b var(--rb-bg); +} + +.guide code { + padding: 1px 5px; + border-radius: 4px; + background: rgb(255 255 255 / 7%); + color: #f4d59a; + overflow-wrap: anywhere; +} + +.guide.collapsed { + flex-basis: 76px; + display: grid; + align-content: start; + justify-items: center; + gap: 14px; + padding: 14px 0; +} + +.guide-header { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 18px 22px 14px; + background: linear-gradient(rgb(245 176 65 / 7%), transparent); +} + +.guide-eyebrow { + margin: 0 0 6px; + color: var(--rb-amber); + font-family: var(--mono); + font-size: 0.72rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.guide-eyebrow::before { + content: "$ "; + content: "$ " / ""; + color: var(--rb-muted); +} + +.guide-title { + margin: 0; + color: var(--rb-bright); + font-family: var(--mono); + font-size: 1.3rem; + font-weight: 600; + letter-spacing: -0.01em; +} + +/* A terminal cursor that blinks a few times when the guide opens, then stays */ +.guide-title::after { + content: ""; + display: inline-block; + width: 0.55em; + height: 1.05em; + margin-left: 6px; + background: var(--rb-amber); + vertical-align: -0.15em; + animation: cursor 1.1s steps(1) 4; +} + +@keyframes cursor { + 50% { + opacity: 0; + } +} + +.guide-subtitle { + margin: 6px 0 0; + color: var(--rb-muted); + font-size: 0.92rem; +} + +.guide-toggle { + flex: none; + align-self: start; + display: grid; + justify-items: center; + gap: 8px; + min-width: 36px; + padding: 6px 8px; + border: 1px solid var(--rb-line); + border-radius: 6px; + background: var(--rb-panel); + color: var(--rb-amber); + font: inherit; + font-family: var(--mono); + font-size: 1.05rem; + cursor: pointer; +} + +.guide-toggle:hover { + border-color: var(--rb-amber); +} + +.guide-rail-label { + font-size: 0.72rem; + letter-spacing: 0.12em; + text-transform: uppercase; + writing-mode: vertical-rl; +} + +.guide-rail { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.guide-rail-step { + display: grid; + justify-items: center; + gap: 2px; + width: 52px; + padding: 6px 0; + border: 1px solid var(--rb-line); + border-radius: 6px; + background: var(--rb-panel); + color: var(--rb-ink); + font: inherit; + cursor: pointer; +} + +.guide-rail-step:hover { + border-color: var(--rb-amber); +} + +.guide-rail-number { + font-family: var(--mono); + font-weight: 600; +} + +.guide-progress { + display: grid; + gap: 10px; + padding: 4px 22px 16px; + border-bottom: 1px solid var(--rb-line); +} + +.progress-meter { + display: flex; + gap: 4px; +} + +.progress-segment { + flex: 1; + height: 6px; + border-radius: 1px; + background: #262c34; +} + +.progress-segment.done { + background: var(--rb-green); + box-shadow: 0 0 10px rgb(90 214 143 / 40%); +} + +.guide-progress-row { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.guide-progress-count { + margin: 0; + color: var(--rb-muted); +} + +.guide-progress-count strong { + color: var(--rb-bright); + font-family: var(--mono); + font-size: 1.05rem; +} + +.guide-tabs { + display: grid; + gap: 2px; + padding: 10px 12px; + border-bottom: 1px solid var(--rb-line); +} + +.guide-tab { + display: grid; + grid-template-columns: 30px 1fr auto; + gap: 10px; + align-items: center; + padding: 8px 10px; + border: 0; + border-left: 3px solid transparent; + border-radius: 0 6px 6px 0; + background: transparent; + color: var(--rb-ink); + font: inherit; + font-size: 0.92rem; + text-align: left; + cursor: pointer; +} + +.guide-tab:hover { + background: var(--rb-panel); +} + +.guide-tab.selected { + border-left-color: var(--rb-amber); + background: var(--rb-raised); + color: var(--rb-bright); + font-weight: 600; +} + +.guide-tab-number { + display: grid; + place-items: center; + width: 26px; + height: 26px; + border: 1px solid #3a414b; + border-radius: 4px; + color: var(--rb-muted); + font-family: var(--mono); + font-size: 0.82rem; +} + +.guide-tab.selected .guide-tab-number { + border-color: var(--rb-amber); + background: var(--rb-amber); + color: #1c1405; +} + +.guide-body { + display: grid; + gap: 14px; + padding: 16px 22px 40px; +} + +.guide-section { + padding: 16px 18px; + border: 1px solid var(--rb-line); + border-radius: 6px; + background: var(--rb-panel); +} + +.guide-section h3, +.guide-section h4 { + margin: 0 0 10px; + color: var(--rb-amber); + font-family: var(--mono); + font-size: 0.9rem; + font-weight: 600; +} + +/* Runbook headings read like Markdown */ +.guide-section h3::before { + content: "## "; + content: "## " / ""; + color: #7d6634; +} + +.guide-section h4::before { + content: "### "; + content: "### " / ""; + color: #7d6634; +} + +.guide-section p { + margin: 0 0 10px; + line-height: 1.6; +} + +.guide-section p:last-child { + margin-bottom: 0; +} + +.guide-note { + color: var(--rb-muted); + font-size: 0.88rem; +} + +.step-header { + display: grid; + gap: 12px; +} + +.step-eyebrow { + margin: 0; + color: var(--rb-amber); + font-family: var(--mono); + font-size: 0.74rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.step-title { + margin: 4px 0 6px; + color: var(--rb-bright); + font-family: var(--mono); + font-size: 1.12rem; + font-weight: 600; + line-height: 1.35; +} + +.guide .step-members { + padding: 0; + background: none; + color: var(--rb-muted); +} + +.step-actions { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.step-buttons { + display: flex; + gap: 8px; +} + +.guide button.action { + border-color: var(--rb-amber); + border-radius: 4px; + background: var(--rb-amber); + color: #1c1405; + font-family: var(--mono); + font-weight: 600; +} + +.guide button.action:hover:not(:disabled) { + border-color: #ffc561; + background: #ffc561; +} + +.guide button.action:disabled { + border-color: #343a43; + background: #2a2f37; + color: #8f98a3; +} + +.guide button.action.secondary { + border-color: #4a515b; + background: transparent; + color: var(--rb-ink); +} + +.guide button.action.secondary:hover:not(:disabled) { + border-color: var(--rb-ink); + background: rgb(255 255 255 / 6%); +} + +.guide button.action .spinner { + border-color: rgb(162 171 182 / 35%); + border-top-color: var(--rb-amber); +} + +.how-it-works, +.task-list { + margin: 0 0 12px; + padding-left: 22px; + line-height: 1.6; +} + +.how-it-works li + li, +.task-list li + li { + margin-top: 8px; +} + +.how-it-works li::marker, +.task-list li::marker { + color: var(--rb-amber); + font-family: var(--mono); + font-size: 0.85em; +} + +.how-it-works { + margin-bottom: 0; +} + +.facts { + display: grid; + grid-template-columns: max-content 1fr; + gap: 8px 12px; + margin: 0 0 14px; + font-size: 0.92rem; +} + +.facts dt { + padding-top: 2px; + color: var(--rb-muted); + font-family: var(--mono); + font-size: 0.74rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.facts dd { + margin: 0; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.see-it { + padding: 10px 14px; + border-left: 3px solid var(--rb-cyan); + border-radius: 0 6px 6px 0; + background: var(--rb-cyan-soft); + color: #d6eef4; +} + +.see-it strong { + color: var(--rb-cyan); + font-family: var(--mono); + font-size: 0.85em; +} + +.ai-note { + padding: 10px 14px; + border-left: 3px solid var(--rb-amber); + border-radius: 0 6px 6px 0; + background: var(--rb-amber-soft); + color: #f4e2bf; +} + +.ai-note strong { + color: #ffd48a; +} + +.clues { + padding: 10px 14px; + border: 1px dashed #3d444e; + border-radius: 6px; + background: var(--rb-bg); +} + +.clues summary, +.guide-log summary { + color: var(--rb-amber); + font-family: var(--mono); + font-size: 0.88rem; + cursor: pointer; +} + +.clues ul { + margin: 10px 0 0; + padding-left: 20px; + line-height: 1.6; +} + +.clues li + li { + margin-top: 6px; +} + +.clues li::marker { + content: "› "; + color: var(--rb-amber); +} + +.guide-log-count { + margin-left: 8px; + color: var(--rb-muted); + font-size: 0.8rem; +} + +/* Step results */ + +.badge { + display: inline-block; + padding: 2px 8px; + border: 1px solid #353b44; + border-radius: 4px; + background: var(--rb-raised); + color: var(--rb-muted); + font-family: var(--mono); + font-size: 0.74rem; + white-space: nowrap; +} + +.badge-compact { + padding: 0; + border: 0; + background: transparent; + font-size: 0.9rem; +} + +.badge-passed { + border-color: rgb(90 214 143 / 35%); + background: var(--rb-green-soft); + color: #8ff0b6; +} + +.badge-failed { + border-color: rgb(255 111 115 / 40%); + background: var(--rb-red-soft); + color: #ffa3a6; +} + +.badge-running { + border-color: rgb(245 176 65 / 40%); + background: var(--rb-amber-soft); + color: #ffd48a; +} + +.badge-compact.badge-passed, +.badge-compact.badge-failed, +.badge-compact.badge-running { + border: 0; + background: transparent; +} + +.banner { + display: grid; + justify-items: start; + gap: 6px; + padding: 12px 16px; + border: 1px solid var(--rb-line); + border-left: 3px solid #4a515b; + border-radius: 0 6px 6px 0; + background: var(--rb-panel); + line-height: 1.5; +} + +.banner-passed { + border-color: rgb(90 214 143 / 30%); + border-left-color: var(--rb-green); + background: var(--rb-green-soft); + color: #c9f7da; +} + +.banner-failed { + border-color: rgb(255 111 115 / 30%); + border-left-color: var(--rb-red); + background: var(--rb-red-soft); + color: #ffd6d7; +} + +.banner-running { + border-color: rgb(245 176 65 / 30%); + border-left-color: var(--rb-amber); + background: var(--rb-amber-soft); + color: #fbe6bd; +} + +.banner-locked { + color: var(--rb-muted); +} + +.banner .spinner { + border-color: rgb(162 171 182 / 35%); + border-top-color: var(--rb-amber); + vertical-align: -2px; + margin-right: 6px; +} + +.banner-note { + font-family: var(--mono); + font-size: 0.76rem; + opacity: 0.8; +} + +/* @key recreates the banner for every new result, so this animation plays again after each click */ +.banner-fresh { + animation: banner-in 0.35s ease-out; +} + +@keyframes banner-in { + from { + opacity: 0; + transform: translateY(-6px); + } + + to { + opacity: 1; + transform: none; + } +} + +.link-button { + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + font-weight: 600; + text-align: left; + text-decoration: underline; + text-underline-offset: 3px; + cursor: pointer; +} + +.check-list { + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.check { + display: grid; + grid-template-columns: 24px 1fr; + gap: 8px; + padding: 10px 12px; + border: 1px solid var(--rb-line); + border-left: 3px solid var(--rb-green); + border-radius: 0 6px 6px 0; + background: var(--rb-bg); +} + +.check-failed { + border-color: rgb(255 111 115 / 35%); + border-left-color: var(--rb-red); + background: #1a0f11; +} + +.check-body { + min-width: 0; +} + +.guide-section .check-description { + margin: 0 0 6px; + color: var(--rb-bright); + font-weight: 600; + line-height: 1.4; +} + +.check-values { + display: grid; + grid-template-columns: max-content 1fr; + gap: 3px 10px; + margin: 0; + font-size: 0.88rem; +} + +.check-values dt { + padding-top: 2px; + color: var(--rb-muted); + font-family: var(--mono); + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.check-values dd { + margin: 0; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} + +/* Hints read like a note someone left in the runbook margin */ +.guide-section .check-hint { + margin: 10px 0 0; + padding: 8px 12px 10px; + border-left: 3px solid var(--rb-amber); + border-radius: 0 6px 6px 0; + background: var(--rb-amber-soft); + color: #fbe8c4; + font-size: 0.9rem; + line-height: 1.55; +} + +.check-hint::before { + content: "# note"; + content: "# note" / ""; + display: block; + margin-bottom: 3px; + color: var(--rb-amber); + font-family: var(--mono); + font-size: 0.72rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.table-scroll { + margin-top: 10px; + overflow-x: auto; +} + +.step-log { + width: 100%; + border-collapse: collapse; + font-family: var(--mono); + font-size: 0.76rem; +} + +.step-log th, +.step-log td { + padding: 6px 8px; + border-bottom: 1px solid #22272e; + text-align: left; + vertical-align: top; +} + +.step-log th { + color: var(--rb-muted); + font-weight: 400; + text-transform: uppercase; +} + +.step-log td { + color: #d3d8de; + line-height: 1.5; +} + +.step-log td:nth-child(-n + 3) { + color: var(--rb-muted); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* Narrow screens stack the guide under the app */ + +@media (max-width: 1080px) { + :root { + --guide-width: 380px; + } + + /* Keeps the cursor on the same line as the title in the narrower guide */ + .guide-title { + font-size: 1.12rem; + } +} + +@media (max-width: 860px) { + .workspace { + flex-direction: column; + } + + .guide, + .guide.collapsed { + position: static; + flex-basis: auto; + height: auto; + box-shadow: none; + } + + .guide.collapsed { + grid-auto-flow: column; + justify-content: start; + padding: 12px 16px; + } + + .guide.collapsed .guide-rail { + grid-auto-flow: column; + } + + .guide-rail-label { + writing-mode: horizontal-tb; + } + + .guide-header, + .guide-progress { + padding-inline: 16px; } - .hero-actions, - .status-tile { - justify-items: start; + .guide-body { + padding-inline: 16px; } } diff --git a/7. Data Parallelism/DataParallelism.pptx b/7. Data Parallelism/DataParallelism.pptx index 477ee1f..050e8fe 100644 Binary files a/7. Data Parallelism/DataParallelism.pptx and b/7. Data Parallelism/DataParallelism.pptx differ diff --git a/7. Data Parallelism/README.md b/7. Data Parallelism/README.md index 3a4f8f2..9478264 100644 --- a/7. Data Parallelism/README.md +++ b/7. Data Parallelism/README.md @@ -13,15 +13,19 @@ The **1. Start** folder contains the intentionally imperfect code you will edit. The starter app runs at [http://localhost:5011](http://localhost:5011). The finished app runs at [http://localhost:5012](http://localhost:5012), so you can run both at the same time and compare them later. +The app opens on the **Import page**, with the workshop guide docked beside it. Every step in the guide is one stage of the nightly import, and every time the app starts it checks your code against them. Right now the guide says it stopped at Step 1, and the terminal running the app shows the same result, with a hint. + ## 2. Inspect the Starting Code -1. Open **ImportPortal/Services/ImportService.cs** and find the three `// ToDo Refactor` comments. All three stages of the import live in `RunImportAsync(int, CancellationToken)`. +1. Open **ImportPortal/Services/ImportService.cs** and find each `// ToDo Refactor (Step N)` comment. The step number tells you which step checks that line. All three stages of the import live in `RunImportAsync(int, CancellationToken)`. 2. Read `ScoreRisk(OrderRow)` at the bottom of the same file. It is deliberately expensive, it is pure CPU work, and it never reads a value another row wrote. -3. Open **ImportPortal/Services/CustomerApiService.cs**. `GetCustomerTierAsync(string, CancellationToken)` waits 10 milliseconds per call. That is I/O, not CPU. +3. Open **ImportPortal/Services/CustomerApiService.cs**. `GetCustomerTierAsync(string, CancellationToken)` waits 10 milliseconds per call. That is I/O, not CPU. The service also counts how many calls are in flight, which is how the workshop steps see what the import really waited for. You do not need to change it. 4. Open **ImportPortal/Models/OrderRow.cs** and note the two properties that are filled in later: `RiskScore` by the validation stage, and `CustomerTier` by the enrichment stage. 5. Open **ImportPortal/Components/Pages/Import.razor** and **Import.razor.cs** to see where the report on screen comes from. You do not need to change either file. +6. Open the **ImportPortal/Steps** folder. Each `Step*.cs` file runs your `ImportService` on fresh services, with comments that explain what each expected result checks and why. These files are your guide. +7. Skim **ImportPortal/Verification** and **ImportPortal/Components/Layout/WorkshopGuide.razor**. They are the workshop plumbing that runs the steps and draws the guide beside the app. You do not need to change them. -Now run the app and click **Run import**. The tile in the header tells you how many processors are available. Keep that number in mind while you read the four cards under **Import stages**: +Now switch back to the browser and click **Run import** on the **Import page** beside the guide. The tile in the header tells you how many processors are available. Keep that number in mind while you read the four cards under **Import stages**: 1. **Validated** reads 4,000 of 4,000 in about 2.12 seconds. That answer is correct, and one thread produced all of it. 2. **Enriched** reads 0 of 4,000 in about 0.01 seconds, and the card is red. Not a single customer tier came back. @@ -38,39 +42,49 @@ Pay attention to these clues: 2. The enrichment stage calls `Parallel.ForEach` with an `async` lambda, and the compiler is perfectly happy with it. 3. The body `Parallel.ForEach` takes here is an `Action`, and no overload of it accepts a `Task`-returning body. Ask yourself what an `async` lambda turns into when it is bound to a delegate that returns `void`. 4. The stage that never touches the network reports the longest time, and the stage that should be waiting on 4,000 calls at 10 milliseconds each reports 0.01 seconds. -5. `RunImportAsync` is an `async` method whose only `await` is `await Task.CompletedTask.ConfigureAwait(false)`. +5. `RunImportAsync` is an `async` method whose only `await` is `await Task.Yield()`. 6. The method already receives a `CancellationToken`, and nothing except the customer API call ever uses it. 7. The regional summary runs as a single-threaded LINQ query after every row is already in memory. +The app walks you through the challenge one step at a time: + +1. Step 1 scores every row on every core, Step 2 enriches every row and waits for it, and Step 3 summarizes with PLINQ and stops when the import is cancelled. +2. A step unlocks only after the step before it passes. Each step in the guide tells the story behind the bug, how to see it on the Import page, which file to change, and a task list for that step. If you get stuck, it has clues. +3. Each step runs your `ImportService` on fresh services and measures what it really did: how many cores validation kept busy, how many customer API calls were still in flight when the import returned, and how much CPU time a cancelled import burned. It shows a checklist of every expected result next to what actually happened. Every result that does not match comes with a hint, and the same hint is printed in the terminal running the app. +4. Every time the app starts, it checks your code against every step, so the workshop guide always reflects the code you have now. +5. Stop and run the app again after each change. If your IDE applied the change with Hot Reload, click **Run every step** in the workshop guide instead. + ## 3. Challenge: Fix the Nightly Import Recommended time: 25 minutes. > **Note:** Please avoid letting AI Agents solve the challenges for you. You're smart. You got this. Use them to understand the existing code, clarify parallel programming concepts, interpret errors, and ask questions that help you decide what to change. The goal is to practice the reasoning yourself. -Refactor **Services/ImportService.cs** so all three stages use the data parallelism primitive that fits them. Do not change **CustomerApiService.cs**, **OrderFileService.cs**, **OrderRow.cs**, or the page. +Refactor **Services/ImportService.cs** so all three stages use the data parallelism primitive that fits them. Do not change **CustomerApiService.cs**, **OrderFileService.cs**, **OrderRow.cs**, or the Import page. -Requirements: +Requirements, in the order the steps check them: -1. Set `MaxDegreeOfParallelism` and `CancellationToken` through `ParallelOptions`, using the token that `RunImportAsync` already receives. The CPU stage and the I/O stage do not have to agree on the same ceiling. -2. Replace the sequential validation `foreach` so every processor scores rows at the same time. -3. Leave `ScoreRisk(OrderRow)` exactly as it is. The goal is to run it on more threads, not to make it cheaper. -4. Fix the enrichment stage so all 4,000 rows really are enriched, and so `RunImportAsync` does not return until they are. -5. Use the `Parallel` overload that is built for asynchronous bodies, and forward the `CancellationToken` that overload hands to your body. -6. Bound the enrichment stage so you do not fire 4,000 calls at the customer API at once. Pick a limit and be ready to explain the number you picked. -7. Turn the regional summary into a PLINQ query that honors the cancellation token. -8. Do not add a shared counter, list, or dictionary that parallel bodies write to. If you find yourself wanting one, stop and work out why you do not need it here. +1. Replace the sequential validation `foreach` so every processor scores rows at the same time. +2. Leave `ScoreRisk(OrderRow)` exactly as it is. The goal is to run it on more threads, not to make it cheaper. +3. Fix the enrichment stage so all 4,000 rows really are enriched, and so `RunImportAsync` does not return until they are. +4. Use the `Parallel` overload that is built for asynchronous bodies, and forward the `CancellationToken` that overload hands to your body. +5. Bound the enrichment stage so you do not fire 4,000 calls at the customer API at once. Pick a limit and be ready to explain the number you picked. +6. Turn the regional summary into a PLINQ query that honors the cancellation token. +7. Set `MaxDegreeOfParallelism` and `CancellationToken` through `ParallelOptions`, using the token that `RunImportAsync` already receives, so a cancelled import stops before it scores a single row. The CPU stage and the I/O stage do not have to agree on the same ceiling. +8. Let the `OperationCanceledException` reach the caller. A cancelled import has no report to return. +9. Do not add a shared counter, list, or dictionary that parallel bodies write to. If you find yourself wanting one, stop and work out why you do not need it here. Acceptance checks: 1. **ImportPortal.slnx** builds. -2. At [http://localhost:5011](http://localhost:5011), **Validated** still reads 4,000 of 4,000, and its time is a fraction of the validation time you wrote down before you changed anything. -3. **Enriched** reads 4,000 of 4,000, and its card is green instead of red. -4. The **Enriched** time is now measured in seconds rather than hundredths of a second, because the app is finally making 4,000 real API calls. Divide 4,000 by the limit you chose and multiply by the 10 millisecond call time to predict roughly what the card should say, then check it against your prediction. -5. On a machine with several processors, the **Total** card is at or below the total you recorded from the starter, even though the app is now doing thousands of calls it used to skip. -6. **Regional summary** still shows US-CA, US-NY, US-TX, and US-WA with 1,000 orders each, and the revenue for each region is unchanged. -7. Clicking **Run import** several times in a row gives the same counts every time, and no card turns red. -8. Your code is ready to compare with **2. Finish/ImportPortal**. +2. The workshop guide shows 3 of 3 steps pass after the app starts. +3. On the Import page at [http://localhost:5011](http://localhost:5011), **Validated** still reads 4,000 of 4,000, and its time is a fraction of the validation time you wrote down before you changed anything. +4. **Enriched** reads 4,000 of 4,000, and its card is green instead of red. +5. The **Enriched** time is now measured in seconds rather than hundredths of a second, because the app is finally making 4,000 real API calls. Divide 4,000 by the limit you chose and multiply by the 10 millisecond call time to predict roughly what the card should say, then check it against your prediction. +6. On a machine with several processors, the **Total** card is at or below the total you recorded from the starter, even though the app is now doing thousands of calls it used to skip. +7. **Regional summary** still shows US-CA, US-NY, US-TX, and US-WA with 1,000 orders each, and the revenue for each region is unchanged. +8. Clicking **Run import** several times in a row gives the same counts every time, and no card turns red. +9. Your code is ready to compare with **2. Finish/ImportPortal**. ## 4. Review the Solution diff --git a/7. Data Parallelism/SOLUTION.md b/7. Data Parallelism/SOLUTION.md index 0cd423d..0d96a58 100644 --- a/7. Data Parallelism/SOLUTION.md +++ b/7. Data Parallelism/SOLUTION.md @@ -2,7 +2,9 @@ Use this during the guided walkthrough after the challenge and group review in [README.md](README.md). -The completed project is **2. Finish/ImportPortal**, which runs at [http://localhost:5012](http://localhost:5012). Compare your decisions with the finished Blazor sample as we rebuild the import one stage at a time. +The completed project is **2. Finish/ImportPortal**, which runs at [http://localhost:5012](http://localhost:5012). Compare your decisions with the finished Blazor sample as we rebuild the import one stage at a time. Each section below names the workshop step it makes pass, so you can follow along in the workshop guide beside the Import page. + +**1. Start** and **2. Finish** share the same workshop plumbing: the `Steps` and `Verification` folders, the workshop guide in `Components/Layout/WorkshopGuide.razor`, and the call counters in `CustomerApiService`. The only differences are `Services/ImportService.cs` and the port in `Properties/launchSettings.json`. ## 1. Start From the Numbers @@ -19,9 +21,9 @@ The finished app, same machine, same 4,000 rows: Read those two lists together. The finished app makes 4,000 API calls that the starter never made, and it still finishes the whole import sooner. Correctness and speed turned out to be the same fix. -## 2. Create ParallelOptions for the CPU Stage +## 2. Create ParallelOptions for the CPU Stage (Steps 1 and 3) -Every `Parallel` method has an overload that takes a `ParallelOptions`, so build one at the top of the method for the CPU-bound stage. The enrichment stage gets its own in step 5, because it needs a completely different ceiling: +Every `Parallel` method has an overload that takes a `ParallelOptions`, so build one at the top of the method for the CPU-bound stage. The enrichment stage gets its own in section 5, because it needs a completely different ceiling: ```cs var parallelOptions = new ParallelOptions @@ -33,9 +35,24 @@ var parallelOptions = new ParallelOptions `CancellationToken` is the token `RunImportAsync` was handed. With it set, the loop checks between iterations and throws `OperationCanceledException` out of the `Parallel` call when cancellation is requested, instead of running to the end of a job nobody is waiting for anymore. +This is the line Step 3 depends on. It starts an import with a token that is already cancelled and measures the CPU time the import used before it threw. With the token on these options, `Parallel.ForEach` throws before it scores a single row. Without it, the import scores every row first and only then notices the cancellation at the enrichment stage, having burned about as much CPU time as a full import. + `MaxDegreeOfParallelism` caps how many iterations run at once. For `Parallel.ForEach` it defaults to `-1`, which lets the runtime decide and leaves the thread pool as the only real limit. `Environment.ProcessorCount` is the right ceiling for CPU-bound work: more threads than cores does not create more cores, it only adds context switching to the same amount of work. -## 3. Validate Every Row On Every Core +## 3. Validate Every Row On Every Core (Step 1) + +The starter scores rows one at a time: + +```cs +// ToDo Refactor (Step 1): every row is scored on one thread while the other +// cores sit idle. Nothing here depends on the row before it. +// ToDo Refactor (Step 3): this loop never looks at the token, so a cancelled +// import still scores every row before anything notices. +foreach (var order in orders) +{ + order.RiskScore = ScoreRisk(order); +} +``` The validation loop becomes one line: @@ -54,16 +71,18 @@ The lambda is `static`, which means it captures nothing from the enclosing scope `Parallel.ForEach` returns a `ParallelLoopResult`. Nothing here calls `Break()` or `Stop()`, so `IsCompleted` would always be `true` and the sample ignores it. In a loop that can end early, check it. -One honest caveat about this sample. `Parallel.ForEach` blocks, and here it blocks Blazor's circuit, because `RunImportAsync` is reached from the page's `@onclick` handler. The component cannot process another event until the CPU stage finishes. That is acceptable for a demo whose only other UI is a spinner. In an app where the circuit has real work to do, hand the blocking loop to the thread pool with `await Task.Run(() => Parallel.ForEach(orders, parallelOptions, ...), token)` so the circuit stays responsive while the cores stay busy. +One honest caveat about this sample. `Parallel.ForEach` blocks, and here it blocks Blazor's circuit, because `RunImportAsync` is reached from the page's `@onclick` handler. Nothing on that circuit, including the workshop guide beside the page, can process another event until the CPU stage finishes. That is acceptable for a demo whose only other UI is a spinner and a guide that can wait a fifth of a second. In an app where the circuit has real work to do, hand the blocking loop to the thread pool with `await Task.Run(() => Parallel.ForEach(orders, parallelOptions, ...), token)` so the circuit stays responsive while the cores stay busy. On the reference machine this stage went from 2.12 seconds to 0.18 seconds. -## 4. Understand Why Enrichment Enriched Nothing +Step 1 does not trust a stopwatch alone, because a faster laptop would make the starter look fixed. It compares the CPU time the import used with how long validation took. One thread can use at most one second of CPU time per second, however hard it works, so the starter keeps about 1.0 cores busy. The finished loop keeps close to every processor busy, and the step asks for at least 2 cores, or 1.5 on a machine with 2 or 3 processors. + +## 4. Understand Why Enrichment Enriched Nothing (Step 2) Before fixing the second stage, look at exactly what the starter did: ```cs -// ToDo Refactor: Parallel.ForEach takes an Action, not a Func. +// ToDo Refactor (Step 2): Parallel.ForEach takes an Action, not a Func. // This lambda is `async void`: ForEach starts each one and immediately // considers it finished, so this returns long before any call completes // and any exception inside it is rethrown where nothing can catch it. @@ -79,7 +98,9 @@ At runtime, `Parallel.ForEach` calls the lambda, the lambda runs until its first The other half of the damage does not show up in this sample, because nothing here throws. An exception inside an `async void` body never reaches the caller, and it is not swallowed either. The state machine rethrows it on the captured `SynchronizationContext`, or on the thread pool when there is none, where nothing is waiting to catch it. On the thread pool that is an unhandled exception, and an unhandled exception takes the process down. So you get neither the data nor a catchable error: the `try` block you wrote around the loop never runs, and what you are left with is a crash whose stack trace points at a lambda with no line of your calling code on it. Point a real customer API at this loop, let it return one 500, and that is your night. -## 5. Enrich With Parallel.ForEachAsync +The workshop steps had to design around exactly this. A cancelled `CancellationToken` makes `Task.Delay` throw inside `GetCustomerTierAsync`, so handing the starter a token that gets cancelled would end the whole app, verifier and all. That is why Steps 1 and 2 always pass `CancellationToken.None`, and why Step 3 reads `ImportService`'s compiled code to confirm no `async void` lambda is left before it cancels an import. + +## 5. Enrich With Parallel.ForEachAsync (Step 2) ```cs // Step 2: enrich every row from the customer API. I/O-bound, so use the @@ -110,9 +131,27 @@ Three things changed, and each one matters. On the reference machine this stage went from 0 rows in 0.01 seconds to 4,000 rows in 1.44 seconds. Slower on the clock, infinitely better on the only measure that counts. -One quiet side effect: the starter needed `await Task.CompletedTask.ConfigureAwait(false);` near the end of the method to justify the `async` keyword. The finished file does not have that line, because the method now awaits something real. +Step 2 watches the calls rather than the clock. A fresh `CustomerApiService` counts how many calls are in flight, so the step can check that every row has a tier when `RunImportAsync` returns, that no call is still running at that moment, and that more than one call ran at a time without every row being sent at once. The finished app peaks at exactly 32. If you left `MaxDegreeOfParallelism` off, the step still passes, and its log points out that your peak matches the processor count, which is the default you did not choose. -## 6. Summarize With PLINQ +One quiet side effect: the starter needed an `await` near the end of the method to justify the `async` keyword: + +```cs +// ToDo Refactor (Step 2): this is the only await in the method. It yields +// the thread, but it does not wait for a single customer API call above. +await Task.Yield(); +``` + +The finished file does not have that line, because the method now awaits something real. + +## 6. Summarize With PLINQ (Step 3) + +The starter groups the rows on one thread, and never looks at the token: + +```cs +// ToDo Refactor (Step 3): this query runs on one thread, and it ignores the cancellation token +var regionTotals = orders + .GroupBy(static order => order.Region) +``` ```cs // Step 3: summarize by region. PLINQ is the declarative sibling of @@ -143,6 +182,8 @@ Be honest about this stage. Four groups over 4,000 rows is close to the line whe The final `OrderBy` gives a deterministic order in the report even though PLINQ does not preserve source order by default. When you need the original ordering rather than a sort, that is what `AsOrdered()` is for, and it is not free. +A one millisecond query leaves nothing to time, so Step 3 checks this stage two ways. It compares the regional summary with totals worked out straight from the uploaded file, and it reads `ImportService`'s compiled code for calls to `AsParallel()` and `WithCancellation()`. + ## 7. Report What Actually Happened ```cs @@ -163,7 +204,7 @@ Nothing about this line changed between Start and Finish, and that is the point. `ScoreRisk(OrderRow)` also stays exactly as it was. The lesson here is to spread expensive work across the cores you already paid for, not to make the work cheaper. Real validation passes hash, parse, and run rules, and they do not get faster because you wanted them to. -The blocking `Parallel.ForEach` in step 3 also stays on the circuit's thread, for the reason given there: this page has nothing else to do while the import runs. That is a deliberate choice for a teaching sample, not a pattern to copy into a page that has other work. +The blocking `Parallel.ForEach` in section 3 also stays on the circuit's thread, for the reason given there: this page has nothing else to do while the import runs. That is a deliberate choice for a teaching sample, not a pattern to copy into a page that has other work. ## 9. Compare Against Finish @@ -175,4 +216,4 @@ And with the code you started from: [1. Start/ImportPortal/Services/ImportService.cs](1.%20Start/ImportPortal/Services/ImportService.cs) -Run both apps side by side, [http://localhost:5011](http://localhost:5011) and [http://localhost:5012](http://localhost:5012), and click **Run import** in each. Focus on the reasons behind each change, not only on whether your code is textually identical to mine. +Run both apps side by side, [http://localhost:5011](http://localhost:5011) and [http://localhost:5012](http://localhost:5012). The finished app's workshop guide shows 3 of 3 steps pass once the checks that run after the app starts have finished, while the starter's stops at Step 1. Then click **Run import** on the Import page in each. Focus on the reasons behind each change, not only on whether your code is textually identical to mine. diff --git a/8. Concurrent Collections/1. Start/StockWatch/Components/Layout/MainLayout.razor b/8. Concurrent Collections/1. Start/StockWatch/Components/Layout/MainLayout.razor index 1c2a641..92b039a 100644 --- a/8. Concurrent Collections/1. Start/StockWatch/Components/Layout/MainLayout.razor +++ b/8. Concurrent Collections/1. Start/StockWatch/Components/Layout/MainLayout.razor @@ -1,5 +1,21 @@ @inherits LayoutComponentBase -
- @Body -
+
+
+ @* The dashboard and the workshop guide share one circuit. An exception the page does not handle would end that circuit, and the guide would stop responding with it. *@ + + + @Body + + + + + + +
+ + +
diff --git a/8. Concurrent Collections/1. Start/StockWatch/Components/Layout/StepBadge.razor b/8. Concurrent Collections/1. Start/StockWatch/Components/Layout/StepBadge.razor new file mode 100644 index 0000000..352439e --- /dev/null +++ b/8. Concurrent Collections/1. Start/StockWatch/Components/Layout/StepBadge.razor @@ -0,0 +1,10 @@ +@inherits StepBadgeBase + +@if (IsCompact) +{ + @Label +} +else +{ + @Icon @Label +} diff --git a/8. Concurrent Collections/1. Start/StockWatch/Components/Layout/StepBadge.razor.cs b/8. Concurrent Collections/1. Start/StockWatch/Components/Layout/StepBadge.razor.cs new file mode 100644 index 0000000..ede3096 --- /dev/null +++ b/8. Concurrent Collections/1. Start/StockWatch/Components/Layout/StepBadge.razor.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Components; + +namespace StockWatch.Components.Layout; + +public partial class StepBadgeBase : ComponentBase +{ + [Parameter, EditorRequired] + public StepStatus Status { get; set; } + + [Parameter] + public bool IsUnlocked { get; set; } = true; + + // Only the icon, for the collapsed guide + [Parameter] + public bool IsCompact { get; set; } + + public string Icon => Status is StepStatus.Running ? "⏳" : !IsUnlocked ? "🔒" : Status switch + { + StepStatus.Passed => "✅", + StepStatus.Failed or StepStatus.TimedOut or StepStatus.Crashed => "❌", + _ => "•", + }; + + public string Label => Status is StepStatus.Running ? "Running" : !IsUnlocked ? "Locked" : Status switch + { + StepStatus.Passed => "Passed", + StepStatus.Failed => "Failed", + StepStatus.TimedOut => "Timed out", + StepStatus.Crashed => "Crashed", + StepStatus.Stopped => "Stopped", + _ => "Not run", + }; + + public string CssClass => Status is StepStatus.Running ? "badge-running" : !IsUnlocked ? "badge-locked" : Status switch + { + StepStatus.Passed => "badge-passed", + StepStatus.Failed or StepStatus.TimedOut or StepStatus.Crashed => "badge-failed", + _ => "badge-idle", + }; +} \ No newline at end of file diff --git a/8. Concurrent Collections/1. Start/StockWatch/Components/Layout/WorkshopGuide.razor b/8. Concurrent Collections/1. Start/StockWatch/Components/Layout/WorkshopGuide.razor new file mode 100644 index 0000000..b3b410c --- /dev/null +++ b/8. Concurrent Collections/1. Start/StockWatch/Components/Layout/WorkshopGuide.razor @@ -0,0 +1,326 @@ +@inherits WorkshopGuideBase + + diff --git a/8. Concurrent Collections/1. Start/StockWatch/Components/Layout/WorkshopGuide.razor.cs b/8. Concurrent Collections/1. Start/StockWatch/Components/Layout/WorkshopGuide.razor.cs new file mode 100644 index 0000000..c201ad4 --- /dev/null +++ b/8. Concurrent Collections/1. Start/StockWatch/Components/Layout/WorkshopGuide.razor.cs @@ -0,0 +1,179 @@ +using Microsoft.AspNetCore.Components; + +namespace StockWatch.Components.Layout; + +// Workshop plumbing: the guide docked beside the app. It shows your challenge, every step's result, and the step you are working on. +public partial class WorkshopGuideBase : ComponentBase, IAsyncDisposable +{ + // Selecting this shows your challenge instead of a step + public const int ChallengeTab = 0; + + // A run that fails quickly can finish faster than anyone can see a spinner + static readonly TimeSpan _minimumActivityIndicatorTime = TimeSpan.FromMilliseconds(600); + + readonly CancellationTokenSource _disposeCancellationTokenSource = new(); + + Task _polling = Task.CompletedTask; + bool _isStartingCheck; + int? _startingRunStepNumber; + + [Inject] + public required StepVerifier Verifier { get; init; } + + [Inject] + public required ILogger Logger { get; init; } + + public int SelectedTab { get; private set; } = ChallengeTab; + + public bool IsCollapsed { get; private set; } + + public string? ErrorMessage { get; private set; } + + public int PassedStepCount => Verifier.Steps.Count(step => Verifier.GetState(step.Number).Status is StepStatus.Passed); + + public bool IsCheckingSteps => _isStartingCheck || Verifier.IsCheckingAllSteps; + + // The first step that does not pass yet, which is the one to work on next + public WorkshopStep? StoppedAtStep => Verifier.Steps.FirstOrDefault(step => Verifier.GetState(step.Number).Status is not StepStatus.Passed); + + // The last step the check reaches: the first one that did not pass, or the last step when they all passed + public WorkshopStep LastCheckedStep => StoppedAtStep ?? Verifier.Steps[^1]; + + public WorkshopStep? SelectedStep => Verifier.FindStep(SelectedTab); + + // The run lives in the verifier, not in this guide, so a step that is still running shows up again after a reload + public ActiveStepRun? SelectedRun => Verifier.ActiveRun is { } run && run.Step.Number == SelectedTab ? run : null; + + public bool IsSelectedStepRunning => _startingRunStepNumber == SelectedTab || SelectedRun is not null; + + public async ValueTask DisposeAsync() + { + // Only stops refreshing the guide. A step that is running keeps running. + await _disposeCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + await _polling.ConfigureAwait(false); + + _disposeCancellationTokenSource.Dispose(); + + GC.SuppressFinalize(this); + } + + protected override void OnInitialized() + { + _polling = PollVerifier(_disposeCancellationTokenSource.Token); + } + + protected void SelectTab(int tab) + { + SelectedTab = tab; + IsCollapsed = false; + ErrorMessage = null; + } + + protected void ToggleCollapsed() => IsCollapsed = !IsCollapsed; + + protected async Task CheckAllStepsAsync() + { + var token = _disposeCancellationTokenSource.Token; + string? errorMessage = null; + + ErrorMessage = null; + _isStartingCheck = true; + + // Show the activity indicator before the check starts + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + try + { + // Keep the indicator on screen long enough to notice, even when Step 1 fails quickly + await Task.WhenAll(Verifier.CheckAllSteps(), Task.Delay(_minimumActivityIndicatorTime, token)).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The guide was closed while the steps were being checked + return; + } + catch (Exception e) + { + Logger.LogError(e, "Checking every step failed"); + + errorMessage = "Checking the steps failed. The terminal running the app has the details."; + } + + // The continuation is off Blazor's renderer, so every component state change goes back through it + await InvokeAsync(() => + { + ErrorMessage = errorMessage; + _isStartingCheck = false; + + StateHasChanged(); + }).ConfigureAwait(false); + } + + protected async Task RunSelectedStepAsync() + { + if (SelectedStep is not { } step) + return; + + var token = _disposeCancellationTokenSource.Token; + string? errorMessage = null; + + ErrorMessage = null; + _startingRunStepNumber = step.Number; + + // Show the activity indicator before the run starts + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + try + { + // Keep the indicator on screen long enough to notice, even when the step fails quickly + await Task.WhenAll(Verifier.RunStep(step), Task.Delay(_minimumActivityIndicatorTime, token)).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The guide was closed while the step was running + return; + } + catch (Exception e) + { + Logger.LogError(e, "Running Step {StepNumber} failed", step.Number); + + errorMessage = "The step could not be run. The terminal running the app has the details."; + } + + // The continuation is off Blazor's renderer, so every component state change goes back through it + await InvokeAsync(() => + { + ErrorMessage = errorMessage; + _startingRunStepNumber = null; + + StateHasChanged(); + }).ConfigureAwait(false); + } + + protected void StopRun() => Verifier.StopRun(); + + // Re-renders while the verifier is busy, plus once more when it finishes, so the live log and results appear without a refresh + async Task PollVerifier(CancellationToken token) + { + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100)); + var wasBusy = true; + + try + { + while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false)) + { + var isBusy = Verifier.IsBusy; + + if (isBusy || wasBusy) + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + wasBusy = isBusy; + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // Expected when the guide is disposed + } + } +} \ No newline at end of file diff --git a/8. Concurrent Collections/1. Start/StockWatch/Components/Pages/Dashboard.razor b/8. Concurrent Collections/1. Start/StockWatch/Components/Pages/Dashboard.razor index 3d78637..0105271 100644 --- a/8. Concurrent Collections/1. Start/StockWatch/Components/Pages/Dashboard.razor +++ b/8. Concurrent Collections/1. Start/StockWatch/Components/Pages/Dashboard.razor @@ -3,22 +3,62 @@ StockWatch -
-
-
-

StockWatch

+
+
+ StockWatch + + + + Live feed · 2s refresh +
+ +
+
+

US equities · 60 symbols · parallel feed

Market dashboard

-

Every symbol refreshes in parallel on Thread Pool threads.

+

Every symbol refreshes in parallel on Thread Pool threads.

@RefreshCount quotes applied
-
+
+ + @{ + var symbols = Symbols; + } + +
- @foreach (var symbol in Symbols) + @foreach (var symbol in symbols) {
@@ -38,4 +78,4 @@
}
- + diff --git a/8. Concurrent Collections/1. Start/StockWatch/Components/Pages/Dashboard.razor.cs b/8. Concurrent Collections/1. Start/StockWatch/Components/Pages/Dashboard.razor.cs index 5d1e0e7..691b4d1 100644 --- a/8. Concurrent Collections/1. Start/StockWatch/Components/Pages/Dashboard.razor.cs +++ b/8. Concurrent Collections/1. Start/StockWatch/Components/Pages/Dashboard.razor.cs @@ -4,7 +4,7 @@ namespace StockWatch.Components.Pages; public partial class DashboardPageBase : ComponentBase, IAsyncDisposable { - // ToDo Refactor: Dictionary is not thread safe. Every symbol is fetched in + // ToDo Refactor (Step 2): Dictionary is not thread safe. Every symbol is fetched in // parallel, so more than one write to this can be in flight at once. readonly Dictionary _latestQuotes = []; @@ -16,10 +16,14 @@ public partial class DashboardPageBase : ComponentBase, IAsyncDisposable [Inject] public required MarketDataService MarketDataService { get; init; } + // ToDo Refactor (Step 3): Blazor's renderer reads this on a different thread than the + // one that last wrote _refreshCount, and a plain read can hand it a stale value. public int RefreshCount => _refreshCount; public IReadOnlyList Symbols => GetSymbols(); + // ToDo Refactor (Step 4): this stops the timer while StartRefreshTimer() may still be + // running. Anything you add to guard the timer has to be cleaned up here too. public async ValueTask DisposeAsync() { await _disposeCancellationTokenSource.CancelAsync().ConfigureAwait(false); @@ -38,7 +42,7 @@ protected override async Task OnInitializedAsync() await StartRefreshTimer().ConfigureAwait(false); } - // ToDo Refactor: two callers can both read _refreshTimer as null and both + // ToDo Refactor (Step 4): two callers can both read _refreshTimer as null and both // create a timer. Nothing guards this field. async ValueTask StartRefreshTimer() { @@ -55,6 +59,8 @@ async ValueTask StartRefreshTimer() _refreshTimer.Change(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2)); } + // ToDo Refactor (Step 4): this reads and writes _refreshTimer too, and nothing stops it + // running at the same time as StartRefreshTimer(), which also calls it. async ValueTask StopRefreshTimer() { if (_refreshTimer is not null) @@ -77,7 +83,7 @@ await Parallel.ForEachAsync( { var quote = await MarketDataService.GetStockQuote(symbol, cancellationToken).ConfigureAwait(false); - // ToDo Refactor: this is a read, then a write, on a collection + // ToDo Refactor (Step 2): this is a read, then a write, on a collection // that many threads are touching. It can corrupt the Dictionary // or throw, and one update can overwrite a newer one. if (!_latestQuotes.TryAdd(symbol, quote)) @@ -85,7 +91,7 @@ await Parallel.ForEachAsync( _latestQuotes[symbol] = quote; } - // ToDo Refactor: `++` is a read, an add, and a write. Increments get lost. + // ToDo Refactor (Step 3): `++` is a read, an add, and a write. Increments get lost. _refreshCount++; }).ConfigureAwait(false); @@ -100,7 +106,7 @@ await Parallel.ForEachAsync( IReadOnlyList GetSymbols() { - // ToDo Refactor: List is not thread safe, and Parallel.ForEach can call + // ToDo Refactor (Step 1): List is not thread safe, and Parallel.ForEach can call // Add from more than one worker at once. Items go missing or this throws. List symbols = []; diff --git a/8. Concurrent Collections/1. Start/StockWatch/Program.cs b/8. Concurrent Collections/1. Start/StockWatch/Program.cs index 986eac7..028d95f 100644 --- a/8. Concurrent Collections/1. Start/StockWatch/Program.cs +++ b/8. Concurrent Collections/1. Start/StockWatch/Program.cs @@ -13,6 +13,11 @@ public static void Main(string[] args) builder.Services.AddSingleton(); + // Workshop plumbing: one StepVerifier holds every step's result. It is also a hosted service, + // so it checks your Dashboard code against every step each time the app starts. + builder.Services.AddSingleton(); + builder.Services.AddHostedService(static serviceProvider => serviceProvider.GetRequiredService()); + var app = builder.Build(); if (!app.Environment.IsDevelopment()) diff --git a/8. Concurrent Collections/1. Start/StockWatch/Steps/Step1ShowEveryCard.cs b/8. Concurrent Collections/1. Start/StockWatch/Steps/Step1ShowEveryCard.cs new file mode 100644 index 0000000..3583c8e --- /dev/null +++ b/8. Concurrent Collections/1. Start/StockWatch/Steps/Step1ShowEveryCard.cs @@ -0,0 +1,122 @@ +namespace StockWatch; + +public sealed class Step1ShowEveryCard : WorkshopStep +{ + // One call can get lucky. Two hundred calls in a row, each adding 60 cards from several workers, almost never do. + const int _callCount = 200; + + const string _missingCardsHint = "Cards went missing, and nothing warned you. List.Add writes into an internal array and sometimes grows it, so two Parallel.ForEach workers adding at once can write the same slot, or write into the old array while another worker replaces it. " + + "Collect the cards in a collection that many threads can add to at once."; + + const string _throwsHint = "A torn List leaves an empty slot in its backing array, or throws while it grows. OrderBy then dereferences the empty slot, which is the NullReferenceException behind the Feed fault panel in the browser. " + + "Collect the cards in a collection that many threads can add to at once."; + + const string _orderHint = "Parallel.ForEach adds the cards in whatever order its workers finish. Keep the OrderBy on the last line of GetSymbols(), so the grid stays alphabetical."; + + public override int Number => 1; + + public override string Scenario => "Show every card"; + + public override string Title => "Symbols and GetSymbols()"; + + public override string Story => "Traders keep StockWatch open all day, and they trust the grid: a symbol that is not on it is a symbol they are not watching. " + + "The Symbols property builds a card for every one of the 60 symbols from inside Parallel.ForEach, on every render, and List was never built for more than one writer. " + + "Cards silently go missing, a different number on every reload, and every so often the render throws and the quote board stops updating altogether."; + + public override string SeeItInTheApp => "On the Dashboard page, count the cards. There should be 60, and you will usually count somewhere in the forties or fifties. " + + "Reload a few times and the number changes. Every so often a Feed fault panel replaces the whole dashboard until you reload, and the terminal running the app shows a NullReferenceException from OrderBy."; + + public override string FileToChange => "Components/Pages/Dashboard.razor.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Add using System.Collections.Concurrent; to the top of Dashboard.razor.cs.", + "Replace the List in GetSymbols() with a thread-safe collection that many Parallel.ForEach workers can add to at once.", + "Keep the OrderBy on the last line of GetSymbols(), so the cards stay in alphabetical order.", + "Keep the Parallel.ForEach, and leave MarketDataService, the models, and Dashboard.razor alone. The parallel loop is not the bug.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "List is not thread safe. Add writes into an internal array and sometimes replaces that array with a bigger one.", + "A missing card and a NullReferenceException from OrderBy are the same bug: a slot in the list's array that no worker ever filled.", + "Nothing here needs the cards in the order they were added, because OrderBy sorts them anyway.", + "System.Collections.Concurrent has a collection built for exactly this job: collect results from many threads now, and sort them later.", + ]; + + public override string TimeoutHint => "Symbols never returned. Check that nothing in GetSymbols() waits on a lock or a semaphore that is never released."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + if (!DashboardHarness.HasMembers(report, "StopRefreshTimer")) + return; + + // Part 1: a fresh dashboard, the same page a new browser tab gets, with a price for every symbol + await using var harness = await DashboardHarness.Render().ConfigureAwait(false); + + if (!await harness.LoadAndStopTimer(report, token).ConfigureAwait(false)) + return; + + // Part 2: every render reads Symbols, so read it the way two hundred renders in a row would + var expectedSymbols = harness.Dashboard.MarketDataService.Symbols.Order().ToList(); + report.Log($"Reading Symbols {_callCount} times. Each read runs Parallel.ForEach over {expectedSymbols.Count} symbols, with up to one worker per core ({Environment.ProcessorCount} on this machine)"); + + var completeCalls = 0; + var orderedCalls = 0; + var returnedCalls = 0; + var fewestCards = int.MaxValue; + var exceptionTypes = new SortedSet(StringComparer.Ordinal); + + for (var call = 0; call < _callCount; call++) + { + token.ThrowIfCancellationRequested(); + + IReadOnlyList cards; + + try + { + cards = harness.Dashboard.Symbols; + } + catch (Exception e) + { + // Only the exception type goes to the page + exceptionTypes.Add(e.GetType().Name); + continue; + } + + returnedCalls++; + fewestCards = Math.Min(fewestCards, cards.Count); + + var symbols = cards.Select(static card => card.Symbol).ToList(); + + if (symbols.Order().SequenceEqual(expectedSymbols)) + completeCalls++; + + if (symbols.SequenceEqual(symbols.Order())) + orderedCalls++; + } + + var threwCalls = _callCount - returnedCalls; + + report.Log(returnedCalls is 0 + ? $"Every read of Symbols threw: {string.Join(", ", exceptionTypes)}" + : $"{completeCalls} of {_callCount} reads returned all {expectedSymbols.Count} cards. The fewest cards any read returned was {fewestCards}"); + + if (threwCalls > 0) + report.Log($"{threwCalls} reads threw: {string.Join(", ", exceptionTypes)}"); + + report.Expect($"{_callCount} reads of Symbols in a row each return a card for all {DashboardHarness.SymbolCount} symbols", $"{_callCount} of {_callCount}", $"{completeCalls} of {_callCount}", completeCalls is _callCount, _missingCardsHint); + + report.Expect($"None of the {_callCount} reads throws", 0, threwCalls, _throwsHint); + + report.Expect( + "Every read lists its cards in alphabetical order", + "every read", + returnedCalls is 0 ? "no read returned" : $"{orderedCalls} of {returnedCalls} reads that returned", + returnedCalls > 0 && orderedCalls == returnedCalls, + returnedCalls is 0 ? _throwsHint : _orderHint); + } +} \ No newline at end of file diff --git a/8. Concurrent Collections/1. Start/StockWatch/Steps/Step2KeepEveryQuote.cs b/8. Concurrent Collections/1. Start/StockWatch/Steps/Step2KeepEveryQuote.cs new file mode 100644 index 0000000..4fa48bb --- /dev/null +++ b/8. Concurrent Collections/1. Start/StockWatch/Steps/Step2KeepEveryQuote.cs @@ -0,0 +1,120 @@ +namespace StockWatch; + +public sealed class Step2KeepEveryQuote : WorkshopStep +{ + const string _newerSymbol = "AAPL"; + const string _olderSymbol = "MSFT"; + + const string _collectionHint = "Dictionary supports one writer at a time, and RefreshQuotes() writes all 60 quotes from Parallel.ForEachAsync workers at once. " + + "A lost insert is a card stuck on --, and a corrupted dictionary can throw or lose every quote. Replace it with a keyed collection from System.Collections.Concurrent that is built for many concurrent writers."; + + const string _newerQuoteHint = "The refresh replaced a newer quote with an older one. TryAdd followed by an indexer assignment, or an update that always stores the incoming quote, lets a slow refresh overwrite a quote that already landed. " + + "Add or update each quote in a single call that keeps whichever quote has the newer Timestamp."; + + const string _olderQuoteHint = "The refresh has to update a quote that is already there, not only add the missing ones. Compare the two Timestamps, and store the newer quote."; + + const string _everyQuoteHint = "Every symbol needs a quote after a refresh, or its card shows -- instead of a price. Check that every quote RefreshQuotes() fetches is added or updated."; + + public override int Number => 2; + + public override string Scenario => "Keep every quote, and keep the newest"; + + public override string Title => "_latestQuotes and RefreshQuotes()"; + + public override string Story => "Every 2 seconds, RefreshQuotes() fetches all 60 quotes at once through Parallel.ForEachAsync and writes each one into _latestQuotes the moment it arrives. " + + "Dictionary supports one writer at a time, so a concurrent insert can lose a quote, corrupt the dictionary, or throw. " + + "And when a slow tick overlaps the next one, TryAdd followed by an indexer assignment lets an older quote overwrite a newer one that already landed. A trader sees a stale price, with nothing to say it is stale."; + + public override string SeeItInTheApp => "On the Dashboard page, look for cards that show -- instead of a price. GetSymbols() builds a card for every symbol either way, so a card showing -- is a quote that never made it into _latestQuotes. " + + "It is rarer than a missing card, so reload a few times. A stale price looks exactly like a real one, which is why this step checks for it directly."; + + public override string FileToChange => "Components/Pages/Dashboard.razor.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Replace _latestQuotes with a collection from System.Collections.Concurrent that is built for many concurrent writers.", + "Replace the TryAdd and the indexer assignment with a single call that adds the quote, or updates the existing one, keeping whichever quote has the newer Timestamp.", + "Keep the delegate you pass to that call cheap and free of side effects. It runs outside the collection's internal lock, and it can run more than once.", + "Keep the InvokeAsync(StateHasChanged) call and the CancellationToken plumbing that are already in RefreshQuotes().", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "A thread-safe TryAdd followed by a thread-safe indexer assignment is still two operations. Another thread can write between them.", + "Quotes arrive in whatever order the feed answers. When two refreshes overlap, the last write is not always the newest quote.", + "Keyed lookup from many concurrent writers is exactly what ConcurrentDictionary is for.", + "AddOrUpdate takes the key, the value to add when the key is missing, and a delegate that picks the value to store when it is already there. Compare the two Timestamps, return the winner, and do nothing else.", + ]; + + public override string TimeoutHint => "RefreshQuotes() never returned. Check that nothing in it waits on a lock or a semaphore, and that it still passes its CancellationToken to Parallel.ForEachAsync."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + if (!DashboardHarness.HasMembers(report, "_latestQuotes", "RefreshQuotes", "StopRefreshTimer")) + return; + + // Part 1: the collection itself. A lost insert is rare enough that one refresh can get lucky, so the type is checked directly. + var quotesType = DashboardHarness.FindFieldType("_latestQuotes"); + report.Log($"_latestQuotes is a {FormatType(quotesType)} from {quotesType?.Namespace}"); + + report.Expect( + "_latestQuotes is a collection from System.Collections.Concurrent", + "System.Collections.Concurrent", + $"{FormatType(quotesType)} from {quotesType?.Namespace}", + quotesType?.Namespace is "System.Collections.Concurrent", + _collectionHint); + + // Part 2: a fresh dashboard, the same page a new browser tab gets + await using var harness = await DashboardHarness.Render().ConfigureAwait(false); + + if (!await harness.LoadAndStopTimer(report, token).ConfigureAwait(false)) + return; + + report.Expect("After loading, every symbol has a quote", $"{DashboardHarness.SymbolCount} of {DashboardHarness.SymbolCount}", $"{CountQuotes(harness)} of {DashboardHarness.SymbolCount}", _everyQuoteHint); + + if (harness.GetField("_latestQuotes") is not IDictionary latestQuotes) + { + report.Expect("_latestQuotes can be read by symbol", "a dictionary of StockQuoteModel by symbol", FormatType(quotesType), false, "The dashboard looks up each card's quote by its symbol, so _latestQuotes has to stay a keyed collection of StockQuoteModel by symbol string."); + return; + } + + // Part 3: two refreshes overlap. A quote timestamped an hour from now has already landed for one symbol, and a quote from an hour ago for another. + var newerQuote = await harness.Dashboard.MarketDataService.GetStockQuote(_newerSymbol, token).ConfigureAwait(false) with { Timestamp = DateTimeOffset.UtcNow.AddHours(1) }; + var olderQuote = await harness.Dashboard.MarketDataService.GetStockQuote(_olderSymbol, token).ConfigureAwait(false) with { Timestamp = DateTimeOffset.UtcNow.AddHours(-1) }; + + latestQuotes[_newerSymbol] = newerQuote; + latestQuotes[_olderSymbol] = olderQuote; + + report.Log($"Stored a quote for {_newerSymbol} timestamped an hour from now and a quote for {_olderSymbol} from an hour ago, then called RefreshQuotes(), the way an overlapping timer tick would"); + + var refreshOutcome = await DashboardHarness.Describe(harness.RefreshQuotes(token), TimeSpan.FromSeconds(5), token).ConfigureAwait(false); + report.Log($"RefreshQuotes() {refreshOutcome}"); + + if (refreshOutcome is not DashboardHarness.Finished) + { + report.Expect("RefreshQuotes() finishes", DashboardHarness.Finished, refreshOutcome, false, "RefreshQuotes() has to finish every refresh, and only an OperationCanceledException from its own token is expected. Check what the code that stores each quote can throw or wait on."); + return; + } + + var keptNewerQuote = latestQuotes.TryGetValue(_newerSymbol, out var storedNewerQuote) && storedNewerQuote == newerQuote; + var replacedOlderQuote = latestQuotes.TryGetValue(_olderSymbol, out var storedOlderQuote) && storedOlderQuote.Timestamp > olderQuote.Timestamp; + + report.Expect($"A newer quote for {_newerSymbol}, timestamped an hour from now, survives the refresh", "kept", keptNewerQuote ? "kept" : "replaced by an older quote", keptNewerQuote, _newerQuoteHint); + report.Expect($"An older quote for {_olderSymbol}, from an hour ago, is replaced by the refresh", "replaced", replacedOlderQuote ? "replaced" : "still the old quote", replacedOlderQuote, _olderQuoteHint); + report.Expect("After the refresh, every symbol still has a quote", $"{DashboardHarness.SymbolCount} of {DashboardHarness.SymbolCount}", $"{CountQuotes(harness)} of {DashboardHarness.SymbolCount}", _everyQuoteHint); + } + + // Counts the cards showing a price, the same way the grid decides between a price and -- + static int CountQuotes(DashboardHarness harness) => harness.Dashboard.Symbols.Count(static card => card.Quote is not null); + + static string FormatType(Type? type) => type switch + { + null => "missing field", + { IsGenericType: true } => $"{type.Name[..type.Name.IndexOf('`', StringComparison.Ordinal)]}<{string.Join(", ", type.GetGenericArguments().Select(FormatType))}>", + _ when type == typeof(string) => "string", + _ => type.Name, + }; +} \ No newline at end of file diff --git a/8. Concurrent Collections/1. Start/StockWatch/Steps/Step3CountEveryQuote.cs b/8. Concurrent Collections/1. Start/StockWatch/Steps/Step3CountEveryQuote.cs new file mode 100644 index 0000000..2c16526 --- /dev/null +++ b/8. Concurrent Collections/1. Start/StockWatch/Steps/Step3CountEveryQuote.cs @@ -0,0 +1,99 @@ +using StockWatch.Components.Pages; + +namespace StockWatch; + +public sealed class Step3CountEveryQuote : WorkshopStep +{ + // Refreshes that overlap, the way timer ticks do when the feed is slow, so far more increments land at the same moment than on one tick + const int _overlappingRefreshes = 32; + + const string _lostCountHint = "Quotes were applied and never counted, and nothing threw. _refreshCount++ is a read, an add, and a write, and two quotes that land at the same moment can both read the same count. " + + "Replace it with an atomic increment."; + + public override int Number => 3; + + public override string Scenario => "Count every quote"; + + public override string Title => "RefreshQuotes() and RefreshCount"; + + public override string Story => "The quotes applied tile is how the support desk tells a slow feed from a stalled one. It should read 60 on the first paint and climb by exactly 60 on every tick. " + + "_refreshCount++ runs once per quote, from whichever Parallel.ForEachAsync worker fetched it, and ++ is a read, an add, and a write. Two quotes that land at the same moment can both read the same count, and one increment disappears. " + + "One lost increment out of sixty, with nothing to tell you it happened, is the quietest bug on this page."; + + public override string SeeItInTheApp => "On the Dashboard page, read the quotes applied tile. The first paint should read 60, and every so often it reads 59 instead, more often on a machine with more cores. " + + "Wait 2 seconds: it should climb by exactly 60 on every tick, and every so often it climbs by 59."; + + public override string FileToChange => "Components/Pages/Dashboard.razor.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Replace _refreshCount++ in RefreshQuotes() with an atomic increment.", + "Read _refreshCount in the RefreshCount getter in a way that forces a real read of the field, instead of a value the JIT cached in a register.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "_refreshCount++ looks like one operation. It is three: read the field, add one, and write it back. Increments that interleave get lost.", + "RefreshCount is read by Blazor's renderer on a different thread than the one that last wrote _refreshCount.", + "An int does not need a lock. Interlocked updates one atomically, and Volatile reads one safely.", + ]; + + public override string TimeoutHint => "RefreshQuotes() never returned. Check that nothing in it waits on a lock or a semaphore."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + if (!DashboardHarness.HasMembers(report, "RefreshQuotes", "StopRefreshTimer")) + return; + + // Part 1: a fresh dashboard, the same page a new browser tab gets. Its first paint shows one quote applied per symbol. + await using var harness = await DashboardHarness.Render().ConfigureAwait(false); + + if (!await harness.LoadAndStopTimer(report, token).ConfigureAwait(false)) + return; + + report.Expect("After loading, quotes applied reads 60", DashboardHarness.SymbolCount, harness.Dashboard.RefreshCount, _lostCountHint); + + // Part 2: overlapping refreshes, so many quotes land at the same moment + var countBefore = harness.Dashboard.RefreshCount; + var expectedCount = countBefore + (_overlappingRefreshes * DashboardHarness.SymbolCount); + report.Log($"Running {_overlappingRefreshes} refreshes at the same time, {_overlappingRefreshes * DashboardHarness.SymbolCount:N0} quotes in all"); + + var refreshes = Task.WhenAll(Enumerable.Range(0, _overlappingRefreshes).Select(_ => harness.RefreshQuotes(token))); + var refreshOutcome = await DashboardHarness.Describe(refreshes, TimeSpan.FromSeconds(10), token).ConfigureAwait(false); + + report.Log($"The refreshes {refreshOutcome}, and quotes applied reads {harness.Dashboard.RefreshCount:N0}"); + + if (refreshOutcome is not DashboardHarness.Finished) + { + report.Expect("Overlapping refreshes finish", DashboardHarness.Finished, refreshOutcome, false, "RefreshQuotes() has to finish every refresh, even when refreshes overlap. Check what the code in its loop can throw or wait on."); + return; + } + + report.Expect($"{_overlappingRefreshes} overlapping refreshes add exactly 60 each", expectedCount, harness.Dashboard.RefreshCount, _lostCountHint); + + // Part 3: a lost increment is timing dependent, so the refreshes above can come out right by luck. These read your compiled code instead. + var refreshCalls = CodeInspector.GetCalledMethods(typeof(DashboardPageBase), "RefreshQuotes"); + var usesInterlocked = refreshCalls.Contains("Interlocked.Increment") || refreshCalls.Contains("Interlocked.Add"); + report.Log($"RefreshQuotes() and its lambdas call {refreshCalls.Count} methods{(usesInterlocked ? ", including Interlocked.Increment" : ", and none of them is on Interlocked")}"); + + report.Expect( + "RefreshQuotes() counts each quote with Interlocked", + "Interlocked.Increment", + usesInterlocked ? "Interlocked.Increment" : "no Interlocked call", + usesInterlocked, + "Lost increments depend on timing, so this checks your code instead of trusting one lucky run. _refreshCount++ is a read, an add, and a write. Replace it with the atomic increment from the Interlocked class."); + + var getterCalls = CodeInspector.GetCalledMethods(typeof(DashboardPageBase), "get_RefreshCount"); + var usesVolatileRead = getterCalls.Contains("Volatile.Read"); + + report.Expect( + "The RefreshCount getter reads _refreshCount with Volatile.Read", + "Volatile.Read", + usesVolatileRead ? "Volatile.Read" : "a plain field read", + usesVolatileRead, + "No test can reliably catch a stale read, so this checks your code. RefreshCount is read by Blazor's renderer on a different thread than the one that last wrote _refreshCount. Read the field in a way that forces a real read, instead of a value the JIT may have cached."); + } +} \ No newline at end of file diff --git a/8. Concurrent Collections/1. Start/StockWatch/Steps/Step4GuardRefreshTimer.cs b/8. Concurrent Collections/1. Start/StockWatch/Steps/Step4GuardRefreshTimer.cs new file mode 100644 index 0000000..a263b9a --- /dev/null +++ b/8. Concurrent Collections/1. Start/StockWatch/Steps/Step4GuardRefreshTimer.cs @@ -0,0 +1,302 @@ +namespace StockWatch; + +public sealed class Step4GuardRefreshTimer : WorkshopStep +{ + // Starts and stops at the same time, the way pages that open, reload and close all day produce them + const int _concurrentCalls = 400; + + // What a timer method did while this step held the semaphore + const string _waited = "waited for the semaphore, then finished"; + const string _didNotWait = "finished without waiting for the semaphore"; + const string _blocked = "blocked a thread while it waited"; + const string _stillWaiting = "still waiting 1 second after the semaphore was released"; + + const string _noSemaphoreHint = "Nothing orders StartRefreshTimer() against StopRefreshTimer(), so two callers can both read _refreshTimer and leak a live timer. " + + "Add a SemaphoreSlim created with new(1, 1), and guard every read and write of _refreshTimer with it. Do not use lock: it cannot be held across an await, and every method here awaits."; + + const string _skippedHint = "Add the semaphore first. Calling the unguarded timer methods at the same time leaks live timers, and a leaked timer that fires after DisposeAsync() throws inside an async void timer callback, which ends the whole app. " + + "So this check waits until the timer is guarded."; + + const string _releaseHint = "Something took the semaphore and never gave it back, so the next caller would wait forever. Release it inside a finally block, so every WaitAsync is matched by exactly one Release, even when the guarded code throws."; + + const string _deadlockHint = "SemaphoreSlim is not reentrant. A method that holds the semaphore and then calls another method that waits on it waits on itself forever. " + + "Move the timer disposal into a private method that assumes the semaphore is already held, and call that method from inside both guarded blocks."; + + const string _leakHint = "A timer is still refreshing a page that stopped its timer. Two calls read and wrote _refreshTimer at the same time, and one timer was replaced without being disposed. " + + "Hold the semaphore for the whole time a method reads, disposes, creates or assigns the timer, not just part of it."; + + // Longer than the timer's 2 second period, so any timer that is still running ticks at least once + static readonly TimeSpan _tickWait = TimeSpan.FromSeconds(2.5); + + // How long this step holds the semaphore to see whether a timer method waits for it + static readonly TimeSpan _holdTime = TimeSpan.FromMilliseconds(300); + + static readonly TimeSpan _callTimeout = TimeSpan.FromSeconds(1); + + public override int Number => 4; + + public override string Scenario => "Guard the refresh timer"; + + public override string Title => "StartRefreshTimer(), StopRefreshTimer() and DisposeAsync()"; + + public override string Story => "People open the dashboard, reload it, and close it all day, so the page starts and stops its refresh timer constantly. " + + "StartRefreshTimer() and StopRefreshTimer() both read and write _refreshTimer, and nothing orders them. A page torn down while it is still initializing can null the field out from under the method that is assigning it, " + + "and the timer it leaks keeps refreshing a page nobody is looking at. When that timer next fires, the CancellationTokenSource its callback reads has already been disposed, and an exception in an async void timer callback ends the whole process."; + + public override string SeeItInTheApp => "You will rarely see this one in the browser, which is exactly what makes it dangerous. It needs a page torn down at the same moment it starts its timer, such as a tab closed or reloaded while the dashboard is still loading. " + + "The Feed fault panel cannot catch this one: the exception is thrown on a timer thread, not while the page renders, so the whole app exits and the browser just loses its connection. The terminal running the app is the only place that tells you why."; + + public override string FileToChange => "Components/Pages/Dashboard.razor.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Add a SemaphoreSlim created with new(1, 1), and use it to guard every read and write of _refreshTimer.", + "Do not use lock. lock cannot be held across an await, and every method here awaits.", + "Wait on the semaphore with await WaitAsync(...), and release it inside a finally block.", + "Move the timer disposal into a private method that assumes the semaphore is already held, and call that method from inside both guarded blocks.", + "Dispose the semaphore in DisposeAsync().", + "Keep the InvokeAsync(StateHasChanged) call and the CancellationToken plumbing that are already there.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "StartRefreshTimer() and StopRefreshTimer() both touch _refreshTimer, and nothing stops them running at the same time.", + "The guarded code awaits, so it needs a lock that can be awaited. A SemaphoreSlim with one slot is exactly that.", + "SemaphoreSlim is not reentrant. StartRefreshTimer() calls StopRefreshTimer(), so if both take the semaphore, the first one waits on itself forever.", + "A common pattern: public methods that take the semaphore, and one private method that assumes it is already held.", + "StopRefreshTimer() runs from DisposeAsync() after the dispose token has been cancelled. If it waits with that token, cleanup never runs. Dispose the semaphore last, once nothing can wait on it again.", + ]; + + public override string TimeoutHint => "A timer method never returned. Check that every WaitAsync is matched by a Release in a finally block, and that no method waits on the semaphore while it already holds it."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + if (!DashboardHarness.HasMembers(report, "_refreshTimer", "StartRefreshTimer", "StopRefreshTimer")) + return; + + // Part 1: without a semaphore, calling the timer methods at the same time leaks live timers. A leaked timer that fires after DisposeAsync() + // throws inside an async void callback, and that would end this app, so nothing below runs them at the same time until the timer is guarded. + var semaphoreFields = DashboardHarness.FindSemaphoreFields(); + var semaphoreNames = string.Join(", ", semaphoreFields.Select(static field => field.Name)); + + report.Log(semaphoreFields.Count is 0 ? "Dashboard.razor.cs has no SemaphoreSlim field" : $"Found the SemaphoreSlim field {semaphoreNames}"); + report.Expect("Dashboard.razor.cs has a SemaphoreSlim field to guard _refreshTimer", "a SemaphoreSlim field", semaphoreFields.Count is 0 ? "none" : semaphoreNames, semaphoreFields.Count > 0, _noSemaphoreHint); + + if (semaphoreFields.Count is 0) + { + const string skipped = "Skipped: nothing guards the timer yet"; + + report.Expect($"{_concurrentCalls} StartRefreshTimer() and StopRefreshTimer() calls at the same time all finish", $"{_concurrentCalls} of {_concurrentCalls}", skipped, false, _skippedHint); + report.Expect("No refresh runs after StopRefreshTimer()", "0 quotes applied", skipped, false, _skippedHint); + report.Expect("DisposeAsync() finishes and disposes the semaphore", "finished, semaphore disposed", skipped, false, _skippedHint); + return; + } + + // Part 2: a fresh dashboard. OnInitializedAsync() starts the timer, so a method that waits on itself never lets the page finish loading. + await using var harness = await DashboardHarness.Render().ConfigureAwait(false); + + if (!await harness.Load(report, token).ConfigureAwait(false)) + return; + + var semaphores = harness.GetSemaphores(); + var freeSemaphores = semaphores.Count(static semaphore => semaphore.CurrentCount > 0); + + report.Expect("Once the page has loaded, the semaphore is free again", "free", freeSemaphores == semaphores.Count ? "free" : "still held", freeSemaphores == semaphores.Count, _releaseHint); + + if (freeSemaphores != semaphores.Count) + { + harness.LeaveRunning(); + return; + } + + // Part 3: with the timer already running, StartRefreshTimer() has to stop it first. That is where a guarded method calling another guarded method waits on itself. + var restartOutcome = await DashboardHarness.Describe(harness.StartRefreshTimer(), _callTimeout, token).ConfigureAwait(false); + report.Log($"StartRefreshTimer() with the timer already running {restartOutcome}"); + + report.Expect( + "StartRefreshTimer() replaces a running timer within 1 second", + DashboardHarness.Finished, + restartOutcome, + restartOutcome is DashboardHarness.Finished, + restartOutcome.StartsWith("still running", StringComparison.Ordinal) ? _deadlockHint : "StartRefreshTimer() has to dispose the running timer and create a new one without throwing."); + + if (restartOutcome is not DashboardHarness.Finished) + { + harness.LeaveRunning(); + return; + } + + // Part 4: this step takes the semaphore itself, then calls each timer method. A guarded method has to wait until the step lets go. + var startWhileHeld = await CallWhileHoldingSemaphores(semaphores, harness.StartRefreshTimer, token).ConfigureAwait(false); + report.Log($"While this step held the semaphore, StartRefreshTimer() {startWhileHeld}"); + report.Expect("StartRefreshTimer() waits for the semaphore before it touches _refreshTimer", _waited, startWhileHeld, startWhileHeld is _waited, WaitHint("StartRefreshTimer()", startWhileHeld)); + + var stopWhileHeld = await CallWhileHoldingSemaphores(semaphores, harness.StopRefreshTimer, token).ConfigureAwait(false); + report.Log($"While this step held the semaphore, StopRefreshTimer() {stopWhileHeld}"); + report.Expect("StopRefreshTimer() waits for the semaphore before it touches _refreshTimer", _waited, stopWhileHeld, stopWhileHeld is _waited, WaitHint("StopRefreshTimer()", stopWhileHeld)); + + if (startWhileHeld is not _waited || stopWhileHeld is not _waited) + { + // A method that did not wait ran on its own, so nothing leaked. One that threw or is still waiting left the timer in an unknown state. + if (startWhileHeld is not (_waited or _didNotWait or _blocked) || stopWhileHeld is not (_waited or _didNotWait or _blocked)) + { + harness.LeaveRunning(); + } + + return; + } + + // Part 5: both methods are guarded, so now they can safely run at the same time + report.Log($"Calling StartRefreshTimer() and StopRefreshTimer() {_concurrentCalls} times at the same time"); + + var calls = Enumerable.Range(0, _concurrentCalls) + .Select(call => Task.Run(() => call % 2 is 0 ? harness.StartRefreshTimer() : harness.StopRefreshTimer(), token)) + .ToList(); + + var callsOutcome = await DashboardHarness.Describe(Task.WhenAll(calls), TimeSpan.FromSeconds(10), token).ConfigureAwait(false); + var finishedCalls = calls.Count(static call => call.IsCompletedSuccessfully); + var failedCalls = calls.Count(static call => call.IsFaulted || call.IsCanceled); + + report.Log($"{finishedCalls} calls finished and {failedCalls} threw"); + + report.Expect( + $"{_concurrentCalls} StartRefreshTimer() and StopRefreshTimer() calls at the same time all finish", + $"{_concurrentCalls} of {_concurrentCalls}", + failedCalls > 0 ? $"{finishedCalls} of {_concurrentCalls}, {failedCalls} threw" : $"{finishedCalls} of {_concurrentCalls}", + finishedCalls is _concurrentCalls, + callsOutcome.StartsWith("still running", StringComparison.Ordinal) + ? $"Some calls never finished. {_releaseHint}" + : "Some calls threw. Every read and write of _refreshTimer has to happen while the semaphore is held, so no call can find a timer that another call already disposed or set to null."); + + if (finishedCalls is not _concurrentCalls) + { + harness.LeaveRunning(); + return; + } + + // Part 6: one last stop, the way DisposeAsync() stops the timer when a tab closes + var finalStopOutcome = await DashboardHarness.Describe(harness.StopRefreshTimer(), _callTimeout, token).ConfigureAwait(false); + var timerAfterStop = harness.GetField("_refreshTimer"); + + report.Expect( + "After a final StopRefreshTimer(), _refreshTimer is null", + "null", + finalStopOutcome is not DashboardHarness.Finished ? $"StopRefreshTimer() {finalStopOutcome}" : timerAfterStop is null ? "null" : "a Timer", + finalStopOutcome is DashboardHarness.Finished && timerAfterStop is null, + "StopRefreshTimer() has to dispose the timer and set _refreshTimer back to null while it holds the semaphore."); + + if (finalStopOutcome is not DashboardHarness.Finished || timerAfterStop is not null) + { + harness.LeaveRunning(); + return; + } + + // Part 7: a timer that leaked during those calls is still ticking, and every tick applies another 60 quotes + await Task.Delay(TimeSpan.FromMilliseconds(250), token).ConfigureAwait(false); + + var countAfterStop = harness.Dashboard.RefreshCount; + report.Log($"Waiting {_tickWait.TotalSeconds} seconds, longer than the timer's 2 second period, to see whether any timer is still running"); + + await Task.Delay(_tickWait, token).ConfigureAwait(false); + + var quotesAfterStop = harness.Dashboard.RefreshCount - countAfterStop; + report.Expect("No refresh runs after StopRefreshTimer()", "0 quotes applied", $"{quotesAfterStop:N0} quotes applied", quotesAfterStop is 0, _leakHint); + + if (quotesAfterStop is not 0) + { + // Disposing this page would dispose what the leaked timer's callback still reads, and its next tick would end the app + harness.LeaveRunning(); + report.Log("Left this dashboard running instead of disposing it, because a leaked timer is still using it"); + return; + } + + // Part 8: close the tab. DisposeAsync() runs after the dispose token is cancelled, and has to clean up the semaphore too. + var disposeOutcome = await harness.DisposeDashboard(token).ConfigureAwait(false); + var disposedSemaphores = semaphores.Count(IsDisposed); + var semaphoresDisposed = disposedSemaphores == semaphores.Count; + + report.Log($"DisposeAsync() {disposeOutcome}"); + + report.Expect( + "DisposeAsync() finishes and disposes the semaphore", + "finished, semaphore disposed", + disposeOutcome is not DashboardHarness.Finished ? $"DisposeAsync() {disposeOutcome}" : semaphoresDisposed ? "finished, semaphore disposed" : "finished, semaphore not disposed", + disposeOutcome is DashboardHarness.Finished && semaphoresDisposed, + DisposeHint(disposeOutcome)); + } + + // Takes every semaphore, calls the method, and describes what it did before and after the semaphores were released + static async Task CallWhileHoldingSemaphores(IReadOnlyList semaphores, Func call, CancellationToken token) + { + var heldSemaphores = new List(); + Task invocation; + bool returnedWhileHeld; + bool finishedWhileHeld; + + try + { + foreach (var semaphore in semaphores) + { + if (semaphore.Wait(0, token)) + heldSemaphores.Add(semaphore); + } + + // A thread of its own, so a method that blocks while it waits cannot block this step + invocation = Task.Factory.StartNew(call, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default); + + await Task.Delay(_holdTime, token).ConfigureAwait(false); + + returnedWhileHeld = invocation.IsCompleted; + finishedWhileHeld = returnedWhileHeld && invocation.Result.IsCompleted; + } + finally + { + heldSemaphores.ForEach(static semaphore => semaphore.Release()); + } + + var outcome = await DashboardHarness.Describe(invocation.Unwrap(), _callTimeout, token).ConfigureAwait(false); + + if (outcome is not DashboardHarness.Finished) + return outcome.StartsWith("still running", StringComparison.Ordinal) ? _stillWaiting : outcome; + + if (finishedWhileHeld) + return _didNotWait; + + return returnedWhileHeld ? _waited : _blocked; + } + + static string WaitHint(string methodName, string outcome) => outcome switch + { + _didNotWait => $"{methodName} read or wrote _refreshTimer while another caller held the semaphore. Wait on the semaphore before touching the timer, and release it in a finally block.", + _blocked => $"{methodName} blocked a thread until the semaphore was free. Wait with await WaitAsync(...) instead, so the thread goes back to the pool while it waits.", + _stillWaiting => $"{methodName} never got the semaphore after it was released. {_deadlockHint}", + _ => $"{methodName} has to wait for the semaphore, then touch the timer without throwing.", + }; + + static string DisposeHint(string outcome) => outcome switch + { + DashboardHarness.Finished => "SemaphoreSlim owns a wait handle. Dispose it in DisposeAsync(), once the timer has stopped and nothing can wait on the semaphore again.", + "threw OperationCanceledException" or "threw TaskCanceledException" => "DisposeAsync() cancels the dispose token before it stops the timer, so a StopRefreshTimer() that waits with that token throws instead of cleaning up. Cleanup still has to run.", + "threw ObjectDisposedException" => "Something used the semaphore or the CancellationTokenSource after it was disposed. Dispose them last, after the timer has stopped.", + _ => $"DisposeAsync() has to stop the timer and dispose what it owns. {_releaseHint}", + }; + + // SemaphoreSlim has no IsDisposed property, but every wait on a disposed one throws + static bool IsDisposed(SemaphoreSlim semaphore) + { + try + { + if (semaphore.Wait(0)) + semaphore.Release(); + + return false; + } + catch (ObjectDisposedException) + { + return true; + } + } +} \ No newline at end of file diff --git a/8. Concurrent Collections/1. Start/StockWatch/Steps/StepContext.cs b/8. Concurrent Collections/1. Start/StockWatch/Steps/StepContext.cs new file mode 100644 index 0000000..345d282 --- /dev/null +++ b/8. Concurrent Collections/1. Start/StockWatch/Steps/StepContext.cs @@ -0,0 +1,4 @@ +namespace StockWatch; + +// Token is cancelled when the run is stopped, times out, or the app shuts down +public sealed record StepContext(StepReport Report, CancellationToken Token); \ No newline at end of file diff --git a/8. Concurrent Collections/1. Start/StockWatch/Steps/WorkshopStep.cs b/8. Concurrent Collections/1. Start/StockWatch/Steps/WorkshopStep.cs new file mode 100644 index 0000000..f4ef153 --- /dev/null +++ b/8. Concurrent Collections/1. Start/StockWatch/Steps/WorkshopStep.cs @@ -0,0 +1,37 @@ +namespace StockWatch; + +// Every step is one bug in the Dashboard page's code-behind. +// Its tab in the workshop guide tells you what to fix, and Run() drives your code the way a busy market does, logs what happens, +// and records an expected result, with a hint, for everything that should be true once the bug is fixed. +public abstract class WorkshopStep +{ + public abstract int Number { get; } + + // What this step fixes, in a few words + public abstract string Scenario { get; } + + // The members this step exercises + public abstract string Title { get; } + + // What goes wrong in production, and why nobody noticed on a quiet day + public abstract string Story { get; } + + // How to watch this bug happen on the Dashboard page + public abstract string SeeItInTheApp { get; } + + // Where the bug lives + public abstract string FileToChange { get; } + + // What this step asks you to change. Some of it cannot be measured from outside, so not every task has an expected result. + public abstract IReadOnlyList Tasks { get; } + + // Shown only when you ask for them + public abstract IReadOnlyList Clues { get; } + + // What is most likely waiting forever when this step does not finish in time + public abstract string TimeoutHint { get; } + + public string SourceFile => $"Steps/{GetType().Name}.cs"; + + public abstract Task Run(StepContext context); +} \ No newline at end of file diff --git a/8. Concurrent Collections/1. Start/StockWatch/Verification/ActiveStepRun.cs b/8. Concurrent Collections/1. Start/StockWatch/Verification/ActiveStepRun.cs new file mode 100644 index 0000000..6a79edd --- /dev/null +++ b/8. Concurrent Collections/1. Start/StockWatch/Verification/ActiveStepRun.cs @@ -0,0 +1,3 @@ +namespace StockWatch; + +public sealed record ActiveStepRun(WorkshopStep Step, StepReport Report); \ No newline at end of file diff --git a/8. Concurrent Collections/1. Start/StockWatch/Verification/CodeInspector.cs b/8. Concurrent Collections/1. Start/StockWatch/Verification/CodeInspector.cs new file mode 100644 index 0000000..232b4e1 --- /dev/null +++ b/8. Concurrent Collections/1. Start/StockWatch/Verification/CodeInspector.cs @@ -0,0 +1,91 @@ +using System.Reflection; +using System.Reflection.Emit; + +namespace StockWatch; + +// Workshop plumbing: lists the methods a member of a type calls, including inside its lambdas and async state machines. +// A few requirements have nothing a check can observe from outside, such as Volatile.Read in a getter, so those checks read your compiled code instead. +public static class CodeInspector +{ + const BindingFlags _declaredMembers = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + + // Every IL opcode by its value, so the method body can be walked one instruction at a time + static readonly Dictionary _opCodes = typeof(OpCodes) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Select(static field => field.GetValue(null)) + .OfType() + .ToDictionary(static opCode => opCode.Value); + + // Returns names such as "Interlocked.Increment" for every call memberName makes, in Debug and in Release builds. + // The compiler names a lambda b__0 and its state machine <b__0>d, so those count as part of RefreshQuotes. + public static IReadOnlySet GetCalledMethods(Type type, string memberName) + { + var calledMethods = new HashSet(StringComparer.Ordinal); + + AddCalledMethods(type, memberName, false, calledMethods); + + return calledMethods; + } + + static void AddCalledMethods(Type type, string memberName, bool isPartOfMember, HashSet calledMethods) + { + foreach (var method in type.GetMethods(_declaredMembers).Concat(type.GetConstructors(_declaredMembers))) + { + if (isPartOfMember || IsPartOf(method.Name, memberName)) + AddCalls(method, calledMethods); + } + + foreach (var nestedType in type.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic)) + { + AddCalledMethods(nestedType, memberName, isPartOfMember || IsPartOf(nestedType.Name, memberName), calledMethods); + } + } + + static void AddCalls(MethodBase method, HashSet calledMethods) + { + if (method.GetMethodBody()?.GetILAsByteArray() is not { } il) + return; + + var typeArguments = method.DeclaringType is { IsGenericType: true } declaringType ? declaringType.GetGenericArguments() : null; + var methodArguments = method.IsGenericMethod ? method.GetGenericArguments() : null; + + var offset = 0; + + while (offset < il.Length) + { + // Two-byte opcodes start with 0xFE + var value = (short)il[offset++]; + + if (value is 0xFE && offset < il.Length) + value = unchecked((short)(0xFE00 | il[offset++])); + + if (!_opCodes.TryGetValue(value, out var opCode)) + return; + + if (opCode.OperandType is OperandType.InlineMethod) + { + try + { + if (method.Module.ResolveMethod(BitConverter.ToInt32(il, offset), typeArguments, methodArguments) is { } calledMethod) + calledMethods.Add($"{calledMethod.DeclaringType?.Name}.{calledMethod.Name}"); + } + catch (ArgumentException) + { + // A token this module cannot resolve is not a call the steps look for + } + } + + offset += opCode.OperandType switch + { + OperandType.InlineNone => 0, + OperandType.ShortInlineBrTarget or OperandType.ShortInlineI or OperandType.ShortInlineVar => 1, + OperandType.InlineVar => 2, + OperandType.InlineI8 or OperandType.InlineR => 8, + OperandType.InlineSwitch => 4 + (BitConverter.ToInt32(il, offset) * 4), + _ => 4, + }; + } + } + + static bool IsPartOf(string name, string memberName) => name == memberName || name.Contains($"<{memberName}>", StringComparison.Ordinal); +} \ No newline at end of file diff --git a/8. Concurrent Collections/1. Start/StockWatch/Verification/DashboardHarness.cs b/8. Concurrent Collections/1. Start/StockWatch/Verification/DashboardHarness.cs new file mode 100644 index 0000000..560bb77 --- /dev/null +++ b/8. Concurrent Collections/1. Start/StockWatch/Verification/DashboardHarness.cs @@ -0,0 +1,229 @@ +using System.Diagnostics; +using System.Reflection; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using StockWatch.Components.Pages; + +namespace StockWatch; + +// Workshop plumbing: renders a fresh Dashboard with its own MarketDataService, the way a new browser tab does, +// and reaches the private members the steps name through reflection. They are private on purpose, so keep their names. +public sealed class DashboardHarness : IAsyncDisposable +{ + public const int SymbolCount = 60; + public const string Finished = "finished"; + + const BindingFlags _instanceMembers = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + + const string _semaphoreHint = "If you added a semaphore, check that every WaitAsync has a Release in a finally block, and that no method waits on the semaphore while it already holds it."; + + // Loading fetches 60 quotes that each take under 60 milliseconds, so these only run out when something is stuck + static readonly TimeSpan _loadTimeout = TimeSpan.FromSeconds(5); + static readonly TimeSpan _callTimeout = TimeSpan.FromSeconds(2); + + readonly ServiceProvider _serviceProvider; + readonly HtmlRenderer _renderer; + readonly Task _loaded; + + bool _isDisposed; + bool _isLeftRunning; + + DashboardHarness(ServiceProvider serviceProvider, HtmlRenderer renderer, DashboardProbe dashboard, Task loaded) + { + _serviceProvider = serviceProvider; + _renderer = renderer; + _loaded = loaded; + Dashboard = dashboard; + } + + public DashboardProbe Dashboard { get; } + + // The checks call private members by name, so a renamed one is reported instead of throwing + public static IReadOnlyList FindMissingMembers(params string[] names) => + [.. names.Where(static name => typeof(DashboardPageBase).GetMember(name, _instanceMembers).Length is 0)]; + + public static IReadOnlyList FindSemaphoreFields() => + [.. typeof(DashboardPageBase).GetFields(_instanceMembers).Where(static field => field.FieldType == typeof(SemaphoreSlim))]; + + public static Type? FindFieldType(string name) => typeof(DashboardPageBase).GetField(name, _instanceMembers)?.FieldType; + + // Records a failed expected result for every member that is missing, and returns false when there is one + public static bool HasMembers(StepReport report, params string[] names) + { + var missingMembers = FindMissingMembers(names); + + if (missingMembers.Count is 0) + return true; + + report.Expect( + "Dashboard.razor.cs still has every member this step calls", + string.Join(", ", names), + $"missing: {string.Join(", ", missingMembers)}", + false, + "The checks call these members by name. Keep their names and change what they do, the way the ToDo Refactor comments ask."); + + return false; + } + + // Renders the page the way Blazor Server does for a new browser tab, which starts OnInitializedAsync() + public static async Task Render() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(new MarketDataService()); + + var serviceProvider = services.BuildServiceProvider(); + var renderer = new HtmlRenderer(serviceProvider, serviceProvider.GetRequiredService()); + + DashboardProbe? dashboard = null; + + var parameters = ParameterView.FromDictionary(new Dictionary + { + [nameof(DashboardProbe.Initialized)] = (Action)(probe => dashboard = probe), + }); + + // Components run on the renderer's dispatcher, the same way they run on a circuit's in Blazor Server + var root = await renderer.Dispatcher.InvokeAsync(() => renderer.BeginRenderingComponent(parameters)).ConfigureAwait(false); + + return new DashboardHarness(serviceProvider, renderer, dashboard ?? throw new InvalidOperationException("The Dashboard page was not initialized"), root.QuiescenceTask); + } + + // Describes how a call ended: "finished", "threw ObjectDisposedException", or "still running after 2 seconds" + public static async Task Describe(Task call, TimeSpan timeout, CancellationToken token) + { + try + { + await call.WaitAsync(timeout, token).ConfigureAwait(false); + return Finished; + } + catch (TimeoutException) + { + return timeout.TotalSeconds is 1 ? "still running after 1 second" : $"still running after {timeout.TotalSeconds:0.#} seconds"; + } + catch (Exception e) when (!token.IsCancellationRequested) + { + // Only the exception type goes to the page + return $"threw {e.GetType().Name}"; + } + } + + // Waits for OnInitializedAsync() to finish, and records a failed expected result when it does not + public async Task Load(StepReport report, CancellationToken token) + { + var stopwatch = Stopwatch.StartNew(); + var outcome = await Describe(_loaded, _loadTimeout, token).ConfigureAwait(false); + + if (outcome is Finished) + { + report.Log($"A fresh dashboard loaded in {stopwatch.ElapsedMilliseconds} ms. OnInitializedAsync() applied the first round of quotes and started the refresh timer, and quotes applied reads {Dashboard.RefreshCount}"); + return true; + } + + // Something in the timer code is stuck or broken, so disposing the page could hang, or dispose what a running timer still uses + LeaveRunning(); + + report.Log($"OnInitializedAsync() {outcome}"); + report.Expect( + "A fresh dashboard finishes loading", + "finished within 5 seconds", + $"OnInitializedAsync() {outcome}", + false, + $"OnInitializedAsync() did not finish, so this page would never start refreshing. It awaits RefreshQuotes() and then StartRefreshTimer(). {_semaphoreHint}"); + + return false; + } + + // Steps 1 to 3 stop the timer once the page has loaded, so a tick 2 seconds in can never change what they measure + public async Task LoadAndStopTimer(StepReport report, CancellationToken token) + { + if (!await Load(report, token).ConfigureAwait(false)) + return false; + + var outcome = await Describe(StopRefreshTimer(), _callTimeout, token).ConfigureAwait(false); + + if (outcome is Finished) + { + report.Log("Stopped the refresh timer, so no timer tick changes the results below"); + return true; + } + + LeaveRunning(); + + report.Expect("StopRefreshTimer() stops the refresh timer", Finished, outcome, false, $"StopRefreshTimer() did not finish. {_semaphoreHint}"); + + return false; + } + + public object? GetField(string name) => typeof(DashboardPageBase).GetField(name, _instanceMembers)?.GetValue(Dashboard); + + public IReadOnlyList GetSemaphores() => [.. FindSemaphoreFields().Select(field => field.GetValue(Dashboard)).OfType()]; + + public Task RefreshQuotes(CancellationToken token) => Invoke("RefreshQuotes", [typeof(CancellationToken)], [token]); + + public Task StartRefreshTimer() => Invoke("StartRefreshTimer", Type.EmptyTypes, []); + + public Task StopRefreshTimer() => Invoke("StopRefreshTimer", Type.EmptyTypes, []); + + // Keeps the page from being disposed. A check calls this when a refresh timer might still be running with nothing to stop it: + // DisposeAsync() disposes the CancellationTokenSource that the timer's callback reads, and an exception in that async void callback would end the app. + // Cancelling that source without disposing it is safe, and it turns every later tick of a leaked timer into a refresh that stops before it fetches anything. + public void LeaveRunning() + { + _isLeftRunning = true; + + try + { + if (GetField("_disposeCancellationTokenSource") is CancellationTokenSource disposeCancellationTokenSource) + disposeCancellationTokenSource.Cancel(); + } + catch (ObjectDisposedException) + { + // The page already disposed it, so there is nothing left to cancel + } + } + + // Disposes the page the way Blazor Server does when a browser tab closes, which calls its DisposeAsync() + public async Task DisposeDashboard(CancellationToken token) + { + if (_isLeftRunning) + return "left running"; + + if (_isDisposed) + return Finished; + + _isDisposed = true; + + var outcome = await Describe(DisposeRenderer(), _callTimeout, token).ConfigureAwait(false); + + if (outcome is Finished) + await _serviceProvider.DisposeAsync().ConfigureAwait(false); + + return outcome; + } + + public async ValueTask DisposeAsync() + { + await DisposeDashboard(CancellationToken.None).ConfigureAwait(false); + } + + async Task DisposeRenderer() => await _renderer.DisposeAsync().ConfigureAwait(false); + + Task Invoke(string name, Type[] parameterTypes, object?[] arguments) + { + try + { + var method = typeof(DashboardPageBase).GetMethod(name, _instanceMembers, parameterTypes) ?? throw new MissingMethodException(nameof(DashboardPageBase), name); + + return method.Invoke(Dashboard, BindingFlags.DoNotWrapExceptions, null, arguments, null) switch + { + Task task => task, + ValueTask valueTask => valueTask.AsTask(), + _ => Task.CompletedTask, + }; + } + catch (Exception e) + { + return Task.FromException(e); + } + } +} \ No newline at end of file diff --git a/8. Concurrent Collections/1. Start/StockWatch/Verification/DashboardProbe.cs b/8. Concurrent Collections/1. Start/StockWatch/Verification/DashboardProbe.cs new file mode 100644 index 0000000..eee6c6a --- /dev/null +++ b/8. Concurrent Collections/1. Start/StockWatch/Verification/DashboardProbe.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using StockWatch.Components.Pages; + +namespace StockWatch; + +// Workshop plumbing: the Dashboard page with your code-behind, rendered by the checks instead of a browser. +// It paints only the quotes applied count. In the browser, a render that throws swaps the dashboard for a Feed fault panel, +// but a check needs the exception itself, so the steps call Symbols directly, where an exception can be caught and reported. +public sealed class DashboardProbe : Dashboard +{ + // Hands the rendered instance to the check before OnInitializedAsync() starts loading quotes + [Parameter, EditorRequired] + public required Action Initialized { get; set; } + + protected override void OnInitialized() => Initialized(this); + + protected override void BuildRenderTree(RenderTreeBuilder builder) => builder.AddContent(0, RefreshCount); +} \ No newline at end of file diff --git a/8. Concurrent Collections/1. Start/StockWatch/Verification/StepCheck.cs b/8. Concurrent Collections/1. Start/StockWatch/Verification/StepCheck.cs new file mode 100644 index 0000000..02dd71d --- /dev/null +++ b/8. Concurrent Collections/1. Start/StockWatch/Verification/StepCheck.cs @@ -0,0 +1,3 @@ +namespace StockWatch; + +public sealed record StepCheck(string Description, string Expected, string Actual, bool Passed, string Hint); \ No newline at end of file diff --git a/8. Concurrent Collections/1. Start/StockWatch/Verification/StepLogEntry.cs b/8. Concurrent Collections/1. Start/StockWatch/Verification/StepLogEntry.cs new file mode 100644 index 0000000..6f321dc --- /dev/null +++ b/8. Concurrent Collections/1. Start/StockWatch/Verification/StepLogEntry.cs @@ -0,0 +1,3 @@ +namespace StockWatch; + +public sealed record StepLogEntry(TimeSpan Elapsed, int ThreadId, bool IsThreadPoolThread, string Message); \ No newline at end of file diff --git a/8. Concurrent Collections/1. Start/StockWatch/Verification/StepReport.cs b/8. Concurrent Collections/1. Start/StockWatch/Verification/StepReport.cs new file mode 100644 index 0000000..17ba493 --- /dev/null +++ b/8. Concurrent Collections/1. Start/StockWatch/Verification/StepReport.cs @@ -0,0 +1,82 @@ +using System.Diagnostics; + +namespace StockWatch; + +// Collects what a step logged and every expected result it checked. +// Steps write to it from thread pool threads while the workshop guide reads it, so every member takes the lock. +public sealed class StepReport +{ + readonly Lock _lock = new(); + readonly Stopwatch _stopwatch = Stopwatch.StartNew(); + readonly List _entries = []; + readonly List _checks = []; + + bool _isComplete; + + public IReadOnlyList Entries + { + get + { + lock (_lock) + { + return [.. _entries]; + } + } + } + + public IReadOnlyList Checks + { + get + { + lock (_lock) + { + return [.. _checks]; + } + } + } + + public void Log(string message) + { + var threadId = Environment.CurrentManagedThreadId; + var isThreadPoolThread = Thread.CurrentThread.IsThreadPoolThread; + + lock (_lock) + { + if (!_isComplete) + _entries.Add(new StepLogEntry(_stopwatch.Elapsed, threadId, isThreadPoolThread, message)); + } + } + + // The hint is shown under the expected result only when it does not match + public void Expect(string description, T expected, T actual, string hint) + { + Expect(description, Format(expected), Format(actual), EqualityComparer.Default.Equals(expected, actual), hint); + } + + public void Expect(string description, string expected, string actual, bool passed, string hint) + { + lock (_lock) + { + if (!_isComplete) + _checks.Add(new StepCheck(description, expected, actual, passed, hint)); + } + } + + // Called when the run ends, including a run that timed out or was stopped while its step is still blocked somewhere. + // Anything that abandoned step writes later is ignored, so the result in the workshop guide can no longer change. + public void Complete() + { + lock (_lock) + { + _isComplete = true; + _stopwatch.Stop(); + } + } + + static string Format(T value) => value switch + { + null => "null", + int or long => $"{value:N0}", + _ => value.ToString() ?? "null", + }; +} \ No newline at end of file diff --git a/8. Concurrent Collections/1. Start/StockWatch/Verification/StepState.cs b/8. Concurrent Collections/1. Start/StockWatch/Verification/StepState.cs new file mode 100644 index 0000000..9a4e96b --- /dev/null +++ b/8. Concurrent Collections/1. Start/StockWatch/Verification/StepState.cs @@ -0,0 +1,9 @@ +namespace StockWatch; + +// ExceptionType is set for Crashed. Only the name is kept: exception messages and stack traces stay in the terminal. +// CompletedAt is when the run that produced this state ended, so the workshop guide can show that a click really ran again. +public sealed record StepState( + StepStatus Status, + StepReport? Report, + string? ExceptionType = null, + DateTimeOffset? CompletedAt = null); \ No newline at end of file diff --git a/8. Concurrent Collections/1. Start/StockWatch/Verification/StepStatus.cs b/8. Concurrent Collections/1. Start/StockWatch/Verification/StepStatus.cs new file mode 100644 index 0000000..ff0cbbd --- /dev/null +++ b/8. Concurrent Collections/1. Start/StockWatch/Verification/StepStatus.cs @@ -0,0 +1,12 @@ +namespace StockWatch; + +public enum StepStatus +{ + NotRun, + Running, + Passed, + Failed, + TimedOut, + Crashed, + Stopped, +} \ No newline at end of file diff --git a/8. Concurrent Collections/1. Start/StockWatch/Verification/StepVerifier.cs b/8. Concurrent Collections/1. Start/StockWatch/Verification/StepVerifier.cs new file mode 100644 index 0000000..2984cbf --- /dev/null +++ b/8. Concurrent Collections/1. Start/StockWatch/Verification/StepVerifier.cs @@ -0,0 +1,231 @@ +namespace StockWatch; + +// Workshop plumbing: runs the steps against your Dashboard code and keeps their results. +// Every step renders its own Dashboard with its own MarketDataService, so a check never disturbs the page open in your browser. +// Only one step runs at a time, so a step that measures contention never competes with another run for thread pool threads. +public sealed class StepVerifier(ILogger logger, IHostApplicationLifetime applicationLifetime) : BackgroundService +{ + public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); + + readonly Lock _lock = new(); + readonly StepState[] _states = [.. Enumerable.Repeat(new StepState(StepStatus.NotRun, null), 4)]; + + // Both start at 1: the app checks every step on startup, and nothing else may run before that finishes + int _isBusy = 1; + int _isCheckingAllSteps = 1; + + ActiveStepRun? _activeRun; + TaskCompletionSource? _stopRequested; + + public IReadOnlyList Steps { get; } = + [ + new Step1ShowEveryCard(), + new Step2KeepEveryQuote(), + new Step3CountEveryQuote(), + new Step4GuardRefreshTimer(), + ]; + + public bool IsBusy => Volatile.Read(ref _isBusy) is 1; + + public bool IsCheckingAllSteps => Volatile.Read(ref _isCheckingAllSteps) is 1; + + public ActiveStepRun? ActiveRun + { + get + { + lock (_lock) + { + return _activeRun; + } + } + } + + public WorkshopStep? FindStep(int number) => Steps.FirstOrDefault(step => step.Number == number); + + public StepState GetState(int number) + { + lock (_lock) + { + return _states[number - 1]; + } + } + + public bool IsUnlocked(int number) => number is 1 || GetState(number - 1).Status is StepStatus.Passed; + + // Re-runs every step, the same way startup does + public async Task CheckAllSteps() + { + if (Interlocked.CompareExchange(ref _isBusy, 1, 0) is not 0) + return; + + Volatile.Write(ref _isCheckingAllSteps, 1); + + try + { + await CheckSteps(applicationLifetime.ApplicationStopping).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isCheckingAllSteps, 0); + Volatile.Write(ref _isBusy, 0); + } + } + + public async Task RunStep(WorkshopStep step) + { + if (!IsUnlocked(step.Number) || Interlocked.CompareExchange(ref _isBusy, 1, 0) is not 0) + return GetState(step.Number); + + try + { + return await Run(step, applicationLifetime.ApplicationStopping).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isBusy, 0); + } + } + + public void StopRun() + { + lock (_lock) + { + _stopRequested?.TrySetResult(); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await CheckSteps(stoppingToken).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isCheckingAllSteps, 0); + Volatile.Write(ref _isBusy, 0); + } + } + + async Task CheckSteps(CancellationToken token) + { + try + { + // Results from before the latest change to the code are not trusted + lock (_lock) + { + Array.Fill(_states, new StepState(StepStatus.NotRun, null)); + } + + logger.LogInformation("Checking your Dashboard code against every step. Open the app in your browser: the workshop guide beside the Dashboard page shows what to fix next"); + + foreach (var step in Steps) + { + var state = await Run(step, token).ConfigureAwait(false); + + if (state.Status is not StepStatus.Passed) + { + logger.LogInformation("Stopped checking at Step {StepNumber}. Open it in the app to see what to change, with a hint for every result that did not match. Later steps unlock after it passes", step.Number); + return; + } + } + + logger.LogInformation("Every step passed. Pause here for the group review"); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The app is shutting down + } + catch (Exception e) + { + logger.LogError(e, "Checking the steps failed unexpectedly"); + } + } + + async Task Run(WorkshopStep step, CancellationToken token) + { + var report = new StepReport(); + var stopRequested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var previousState = GetState(step.Number); + + lock (_lock) + { + _activeRun = new ActiveStepRun(step, report); + _stopRequested = stopRequested; + _states[step.Number - 1] = new StepState(StepStatus.Running, report); + } + + // Cancelled when the run ends for any reason, so a step that is still working can stop + using var runCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + + var state = previousState; + + try + { + state = await Execute(step, report, stopRequested.Task, runCancellationTokenSource.Token, token).ConfigureAwait(false); + } + finally + { + await runCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + report.Complete(); + + lock (_lock) + { + _activeRun = null; + _stopRequested = null; + _states[step.Number - 1] = state with { CompletedAt = DateTimeOffset.Now }; + } + } + + return state; + } + + async Task Execute(WorkshopStep step, StepReport report, Task stopRequested, CancellationToken runToken, CancellationToken token) + { + try + { + // A dedicated thread runs the step until its first await. If a deadlock blocks it forever, + // a thread pool thread is not lost for the rest of the app's life. + var stepTask = Task.Factory.StartNew(() => step.Run(new StepContext(report, runToken)), token, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); + + var finished = await Task.WhenAny(stepTask, stopRequested).WaitAsync(Timeout, token).ConfigureAwait(false); + + if (finished == stopRequested) + { + logger.LogInformation("Step {StepNumber} was stopped", step.Number); + return new StepState(StepStatus.Stopped, report); + } + + await stepTask.ConfigureAwait(false); + + var checks = report.Checks; + var passedChecks = checks.Count(static check => check.Passed); + var status = checks.Count > 0 && passedChecks == checks.Count ? StepStatus.Passed : StepStatus.Failed; + + logger.LogInformation("Step {StepNumber} {Result} ({PassedChecks}/{TotalChecks} checks)", step.Number, status is StepStatus.Passed ? "passed" : "failed", passedChecks, checks.Count); + + // The hints belong in the terminal too, next to anything the app logged while the step ran + foreach (var check in checks.Where(static check => !check.Passed)) + { + logger.LogWarning("Step {StepNumber}: {Description}. Expected {Expected}, actual {Actual}. Hint: {Hint}", step.Number, check.Description, check.Expected, check.Actual, check.Hint); + } + + return new StepState(status, report); + } + catch (TimeoutException) + { + logger.LogWarning("Step {StepNumber} did not finish within {Timeout}. {TimeoutHint}", step.Number, Timeout, step.TimeoutHint); + return new StepState(StepStatus.TimedOut, report); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + return new StepState(StepStatus.NotRun, null); + } + catch (Exception e) + { + logger.LogError(e, "Step {StepNumber} threw an unexpected exception", step.Number); + return new StepState(StepStatus.Crashed, report, e.GetType().Name); + } + } +} \ No newline at end of file diff --git a/8. Concurrent Collections/1. Start/StockWatch/wwwroot/app.css b/8. Concurrent Collections/1. Start/StockWatch/wwwroot/app.css index 08f9afa..7a521cd 100644 --- a/8. Concurrent Collections/1. Start/StockWatch/wwwroot/app.css +++ b/8. Concurrent Collections/1. Start/StockWatch/wwwroot/app.css @@ -1,19 +1,46 @@ +/* StockWatch: a market data terminal, with the workshop guide docked beside it as the incident that paged the on-call engineer */ + :root { - --canvas: #eef3f1; - --panel: #ffffff; - --panel-strong: #112a2d; - --ink: #18211f; - --muted: #62706d; - --line: #d7e0dd; - --accent: #f46a1f; - --teal: #0f766e; - --up: #0f8a52; - --down: #cf202f; - --shadow: 0 18px 55px rgb(28 45 42 / 14%); - color-scheme: light; - font-family: "Aptos", "Avenir Next", "Segoe UI", sans-serif; - background: var(--canvas); - color: var(--ink); + --term-bg: #05070a; + --term-bar: #000000; + --term-card: #0c1117; + --term-line: #1b232d; + --term-line-strong: #2b3643; + --term-ink: #d9e0e8; + --term-bright: #f4f7fa; + --term-muted: #8391a1; + --amber: #ffb000; + --amber-soft: #ffc54a; + --up: #2ee07f; + --down: #ff5566; + --flat: #a3adb8; + --sheet: #ffffff; + --canvas: #f3f4f6; + --line: #dde1e6; + --line-strong: #c3c9d1; + --ink: #15191e; + --ink-soft: #3d4651; + --muted: #5b6571; + --red: #d92d20; + --red-dark: #b42318; + --red-ink: #7a271a; + --red-tint: #fef3f2; + --red-line: #fdb4ab; + --good: #067647; + --good-ink: #05603a; + --good-tint: #ecfdf3; + --good-line: #a6e9c1; + --warn: #93370d; + --warn-accent: #dc6803; + --warn-tint: #fffaeb; + --warn-line: #fedf89; + --mono: ui-monospace, "SF Mono", "Cascadia Mono", "Cascadia Code", Menlo, Consolas, "Liberation Mono", monospace; + --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif; + --guide-width: 460px; + color-scheme: dark; + font-family: var(--sans); + background: var(--term-bg); + color: var(--term-ink); } * { @@ -23,150 +50,1172 @@ body { margin: 0; overflow-x: clip; - background: - linear-gradient(135deg, rgb(15 118 110 / 14%), transparent 32%), - radial-gradient(circle at top right, rgb(244 106 31 / 16%), transparent 30%), - var(--canvas); + background: var(--term-bg); +} + +/* FocusOnNavigate moves focus to the page heading for screen readers; the heading is not interactive, so hide the ring */ +h1:focus { + outline: none; } -.shell { +code { + font-family: var(--mono); + font-size: 0.88em; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +/* The app and the guide share the screen */ + +.workspace { + display: flex; min-height: 100vh; } -.dashboard { - width: min(1120px, calc(100% - 32px)); - margin: 0 auto; - padding: 32px 0 56px; +.app-pane { + flex: 1 1 auto; + min-width: 0; } -.hero-panel { - display: grid; - grid-template-columns: 1fr auto; - gap: 28px; - align-items: end; - margin-bottom: 22px; - padding: 30px; - border: 1px solid rgb(36 35 31 / 10%); - border-radius: 8px; - background: linear-gradient(135deg, var(--panel-strong), #15463e 58%, #6f3b17); - box-shadow: var(--shadow); - color: #f8fffc; +/* Buttons */ + +button.action { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 10px 18px; + border: 1px solid #1d2939; + border-radius: 6px; + background: #1d2939; + color: #fff; + font: inherit; + font-weight: 600; + cursor: pointer; } -.hero-copy { - max-width: 680px; +button.action:hover:not(:disabled) { + border-color: #0c111d; + background: #0c111d; } -.eyebrow { - margin: 0 0 6px; +button.action:disabled { + border-color: #98a2b3; + background: #98a2b3; + cursor: not-allowed; +} + +button.action.secondary { + border-color: var(--line-strong); + background: var(--sheet); + color: var(--ink); +} + +button.action.secondary:hover:not(:disabled) { + border-color: var(--ink-soft); + background: var(--canvas); +} + +button.action.small { + padding: 6px 12px; + font-size: 0.86rem; +} + +.spinner { + display: inline-block; + width: 14px; + height: 14px; + margin-right: 8px; + border: 2px solid rgb(255 255 255 / 35%); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.banner .spinner { + border-color: rgb(147 55 13 / 25%); + border-top-color: var(--warn-accent); + vertical-align: -2px; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* The terminal */ + +.terminal { + min-height: 100vh; + background: + repeating-linear-gradient(0deg, rgb(255 255 255 / 1.2%) 0 1px, transparent 1px 3px), + var(--term-bg); +} + +.term-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 18px; + padding: 9px 24px; + border-bottom: 1px solid var(--term-line-strong); + background: var(--term-bar); + font-family: var(--mono); font-size: 0.78rem; - font-weight: 600; - letter-spacing: 0.12em; + letter-spacing: 0.06em; text-transform: uppercase; - color: rgb(248 255 252 / 72%); } -.hero-panel h1 { - margin: 0 0 10px; - font-size: clamp(1.9rem, 3vw, 2.6rem); - line-height: 1.1; +.term-brand { + color: var(--amber); + font-weight: 800; + letter-spacing: 0.16em; +} + +.term-keys { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.term-key { + padding: 3px 8px; + border: 1px solid var(--term-line-strong); + border-radius: 2px; + background: #10161d; + color: var(--term-muted); +} + +.term-key.current { + border-color: var(--amber); + background: var(--amber); + color: #140d00; + font-weight: 700; +} + +.term-live { + display: inline-flex; + align-items: center; + gap: 8px; + margin-left: auto; + color: var(--up); +} + +.live-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--up); + animation: live 1.6s ease-out infinite; } -.hero-summary { +@keyframes live { + from { + box-shadow: 0 0 0 0 rgb(46 224 127 / 60%); + } + + to { + box-shadow: 0 0 0 8px rgb(46 224 127 / 0%); + } +} + +.term-head { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: end; + gap: 16px 24px; + padding: 22px 24px 18px; +} + +.term-eyebrow { + margin: 0 0 6px; + color: var(--term-muted); + font-family: var(--mono); + font-size: 0.74rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.term-head h1 { margin: 0; - color: rgb(248 255 252 / 80%); + color: var(--amber); + font-family: var(--mono); + font-size: clamp(1.45rem, 2.4vw, 2rem); + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.term-summary { + margin: 6px 0 0; + color: var(--term-ink); + font-size: 0.92rem; } .status-tile { display: grid; - gap: 4px; justify-items: end; - padding: 14px 18px; - border: 1px solid rgb(248 255 252 / 22%); - border-radius: 8px; - background: rgb(248 255 252 / 10%); + gap: 4px; + min-width: 180px; + padding: 10px 16px; + border: 1px solid rgb(255 176 0 / 50%); + border-radius: 2px; + background: #0f0b02; + font-family: var(--mono); } .status-value { - font-size: 1.9rem; + color: var(--amber-soft); + font-size: 2rem; font-weight: 700; + font-variant-numeric: tabular-nums; line-height: 1; } .status-label { - font-size: 0.82rem; - color: rgb(248 255 252 / 78%); + color: var(--term-muted); + font-size: 0.72rem; + letter-spacing: 0.12em; + text-transform: uppercase; } +/* The ticker tape: the board's symbols twice over, so sliding the strip left by half loops without a seam */ + +.tape { + overflow: hidden; + border-block: 1px solid var(--term-line-strong); + background: var(--term-bar); + mask-image: linear-gradient(90deg, transparent, #000 48px, #000 calc(100% - 48px), transparent); +} + +.tape-track { + display: flex; + width: max-content; + animation: tape 150s linear infinite; +} + +.tape-item { + display: inline-flex; + align-items: baseline; + gap: 8px; + padding: 8px 18px; + border-right: 1px solid var(--term-line); + font-family: var(--mono); + font-size: 0.8rem; + white-space: nowrap; +} + +.tape-symbol { + color: var(--amber); + font-weight: 700; +} + +.tape-price { + color: var(--term-bright); + font-variant-numeric: tabular-nums; +} + +.tape-change { + color: var(--flat); + font-variant-numeric: tabular-nums; +} + +@keyframes tape { + to { + transform: translateX(-50%); + } +} + +/* The quote board */ + .ticker-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); - gap: 14px; + grid-template-columns: repeat(auto-fill, minmax(164px, 1fr)); + gap: 6px; + padding: 16px 24px 40px; } .ticker-card { display: grid; - gap: 6px; - padding: 16px 18px; - border: 1px solid var(--line); - border-left: 4px solid var(--muted); - border-radius: 8px; - background: var(--panel); - box-shadow: 0 10px 26px rgb(28 45 42 / 8%); + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + "symbol change" + "company company" + "price price"; + gap: 2px 8px; + padding: 9px 11px 10px; + border: 1px solid var(--term-line); + border-left: 3px solid var(--term-line-strong); + border-radius: 2px; + background: var(--term-card); + font-family: var(--mono); } .ticker-card.trend-up { border-left-color: var(--up); + background: linear-gradient(90deg, rgb(46 224 127 / 7%), transparent 55%), var(--term-card); } .ticker-card.trend-down { border-left-color: var(--down); + background: linear-gradient(90deg, rgb(255 85 102 / 8%), transparent 55%), var(--term-card); } +/* The symbol and the company name sit in the card's own grid */ .ticker-heading { - display: grid; - gap: 2px; + display: contents; } .ticker-symbol { - font-size: 1.05rem; + grid-area: symbol; + color: var(--term-bright); + font-size: 0.92rem; font-weight: 700; - letter-spacing: 0.02em; + letter-spacing: 0.04em; } .ticker-company { - font-size: 0.8rem; - color: var(--muted); + grid-area: company; + overflow: hidden; + color: var(--term-muted); + font-size: 0.7rem; + text-overflow: ellipsis; + white-space: nowrap; } .ticker-price { - font-size: 1.5rem; - font-weight: 600; + grid-area: price; + margin-top: 6px; + color: var(--term-bright); + font-size: 1.22rem; + font-weight: 700; font-variant-numeric: tabular-nums; } .ticker-change { - font-size: 0.9rem; - font-weight: 600; + grid-area: change; + align-self: start; + padding: 1px 5px; + border-radius: 2px; + color: var(--flat); + font-size: 0.76rem; font-variant-numeric: tabular-nums; - color: var(--muted); + white-space: nowrap; } +.trend-up .tape-change, .trend-up .ticker-change { color: var(--up); } +.trend-down .tape-change, +.trend-down .ticker-change { + color: var(--down); +} + +.trend-up .ticker-change { + background: rgb(46 224 127 / 12%); +} + .trend-down .ticker-change { + background: rgb(255 85 102 / 14%); +} + +.trend-up .tape-change::before, +.trend-up .ticker-change::before { + content: "▲ "; +} + +.trend-down .tape-change::before, +.trend-down .ticker-change::before { + content: "▼ "; +} + +/* The layout shows this in place of the dashboard when the page throws */ +.feed-fault { + margin: 24px; + padding: 18px 20px; + border: 1px solid rgb(255 85 102 / 55%); + border-left: 6px solid var(--down); + border-radius: 2px; + background: rgb(255 85 102 / 9%); + color: var(--term-bright); + font-family: var(--mono); + line-height: 1.55; +} + +.feed-fault p { + margin: 0; +} + +.feed-fault .feed-fault-code { + margin-bottom: 6px; color: var(--down); + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; } -@media (max-width: 640px) { - .hero-panel { - grid-template-columns: 1fr; - align-items: start; +.feed-fault-code::before { + content: "■ "; +} + +@media (prefers-reduced-motion: reduce) { + .tape-track, + .live-dot { + animation: none; + } +} + +/* The workshop guide: the incident that paged the on-call engineer, pinned beside the terminal */ + +.guide { + position: sticky; + top: 0; + flex: 0 0 var(--guide-width); + height: 100vh; + overflow-y: auto; + border-left: 1px solid #000; + background: var(--canvas); + box-shadow: -18px 0 44px rgb(0 0 0 / 45%); + color: var(--ink); + color-scheme: light; + font-family: var(--sans); +} + +/* The severity strip stays pinned while the incident scrolls */ +.guide::before { + content: "● SEV-2 · Triggered · StockWatch market data"; + content: "● SEV-2 · Triggered · StockWatch market data" / ""; + position: sticky; + top: 0; + z-index: 1; + display: block; + padding: 7px 22px; + background: var(--red); + color: #fff; + font-family: var(--mono); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +/* The incident moves on as the steps pass: mitigating once one passes, resolved once every one does */ +.guide:not(.collapsed):has(.progress-segment.done)::before { + content: "● SEV-2 · Mitigating · StockWatch market data"; + content: "● SEV-2 · Mitigating · StockWatch market data" / ""; +} + +.guide:not(.collapsed):not(:has(.progress-segment:not(.done)))::before { + content: "✓ Resolved · StockWatch market data"; + content: "✓ Resolved · StockWatch market data" / ""; + background: var(--good); +} + +.guide.collapsed { + flex-basis: 76px; + display: grid; + align-content: start; + justify-items: center; + gap: 14px; + padding: 0 0 14px; +} + +.guide.collapsed::before { + content: "SEV-2"; + content: "SEV-2" / ""; + justify-self: stretch; + padding: 6px 0; + letter-spacing: 0.04em; + text-align: center; +} + +.guide.collapsed:not(:has(.badge:not(.badge-passed)))::before { + content: "Resolved"; + content: "Resolved" / ""; + background: var(--good); +} + +.guide-header { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 18px 22px 14px; + background: var(--sheet); +} + +.guide-eyebrow { + margin: 0 0 4px; + color: var(--red-dark); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.guide-title { + margin: 0; + font-size: 1.3rem; + font-weight: 700; + line-height: 1.25; + letter-spacing: -0.01em; +} + +.guide-subtitle { + margin: 8px 0 0; + color: var(--muted); + font-size: 0.88rem; + line-height: 1.45; +} + +.guide-incident { + display: inline-block; + margin-right: 6px; + padding: 0 6px; + border: 1px solid var(--line-strong); + border-radius: 4px; + background: var(--canvas); + color: var(--ink); + font-family: var(--mono); + font-size: 0.8rem; + font-weight: 600; +} + +.guide-toggle { + flex: none; + align-self: start; + display: grid; + justify-items: center; + gap: 8px; + min-width: 36px; + padding: 6px 8px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--sheet); + color: var(--ink); + font: inherit; + font-size: 1.1rem; + font-weight: 700; + cursor: pointer; +} + +.guide-toggle:hover { + border-color: var(--red); + color: var(--red-dark); +} + +.guide-rail-label { + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + writing-mode: vertical-rl; +} + +.guide-rail { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.guide-rail-step { + display: grid; + justify-items: center; + gap: 2px; + width: 52px; + padding: 6px 0; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--sheet); + color: var(--ink); + font: inherit; + cursor: pointer; +} + +.guide-rail-step:hover { + border-color: var(--line-strong); +} + +.guide-rail-number { + font-family: var(--mono); + font-weight: 700; +} + +.guide-progress { + display: grid; + gap: 10px; + padding: 0 22px 16px; + border-bottom: 1px solid var(--line); + background: var(--sheet); +} + +.progress-meter { + display: flex; + gap: 4px; +} + +.progress-segment { + flex: 1; + height: 6px; + border-radius: 2px; + background: #e1e5ea; +} + +.progress-segment.done { + background: var(--good); +} + +.guide-progress-row { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.guide-progress-count { + margin: 0; + color: var(--muted); +} + +.guide-progress-count strong { + color: var(--ink); + font-size: 1.05rem; +} + +/* Your challenge, then every step as a remediation item */ +.guide-tabs { + display: grid; + border-bottom: 1px solid var(--line); + background: var(--sheet); +} + +.guide-tab { + display: grid; + grid-template-columns: 28px 1fr auto; + gap: 10px; + align-items: center; + padding: 10px 22px 10px 19px; + border: 0; + border-left: 3px solid transparent; + border-top: 1px solid #edf0f3; + background: transparent; + color: var(--ink); + font: inherit; + font-size: 0.92rem; + text-align: left; + cursor: pointer; +} + +.guide-tab:first-child { + border-top: 0; +} + +.guide-tab:hover { + background: #f8f9fb; +} + +.guide-tab-number { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border: 1px solid var(--line-strong); + border-radius: 4px; + background: var(--sheet); + color: var(--ink-soft); + font-family: var(--mono); + font-size: 0.82rem; + font-weight: 700; +} + +.guide-tab:has(.badge-failed) .guide-tab-number { + border-color: var(--red-line); + background: var(--red-tint); + color: var(--red-dark); +} + +.guide-tab:has(.badge-passed) .guide-tab-number { + border-color: var(--good-line); + background: var(--good-tint); + color: var(--good); +} + +.guide-tab.selected { + border-left-color: var(--red); + background: #f6f7f9; + font-weight: 600; +} + +.guide-tab.selected .guide-tab-number { + border-color: var(--ink); + background: var(--ink); + color: #fff; +} + +.guide-body { + display: grid; + gap: 12px; + padding: 16px 22px 40px; +} + +.guide-section { + padding: 14px 16px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--sheet); +} + +.guide-section h3, +.guide-section h4 { + margin: 0 0 8px; + color: var(--ink-soft); + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.guide-section p { + margin: 0 0 10px; + line-height: 1.55; +} + +.guide-section p:last-child { + margin-bottom: 0; +} + +.guide-note { + color: var(--muted); + font-size: 0.88rem; +} + +.step-header { + display: grid; + gap: 10px; + padding: 14px 16px; + border: 1px solid var(--line); + border-top: 3px solid var(--red); + border-radius: 8px; + background: var(--sheet); +} + +.step-eyebrow { + margin: 0; + color: var(--red-dark); + font-family: var(--mono); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.step-eyebrow::before { + content: "Remediation · "; + content: "Remediation · " / ""; + color: var(--muted); +} + +.step-title { + margin: 2px 0 4px; + font-size: 1.2rem; + font-weight: 700; + line-height: 1.3; +} + +.step-members { + color: var(--muted); + overflow-wrap: anywhere; +} + +.step-actions { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.step-buttons { + display: flex; + gap: 8px; +} + +.how-it-works, +.task-list { + margin: 0 0 12px; + padding-left: 20px; + line-height: 1.55; +} + +.how-it-works li + li, +.task-list li + li { + margin-top: 6px; +} + +.how-it-works { + margin-bottom: 0; +} + +.facts { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 12px; + margin: 0 0 12px; + font-size: 0.9rem; +} + +.facts dt { + color: var(--muted); + font-weight: 600; +} + +.facts dd { + margin: 0; + overflow-wrap: anywhere; +} + +.see-it { + padding: 10px 14px; + border-left: 4px solid #3b5bdb; + border-radius: 6px; + background: #eef2ff; + color: #1f2a55; +} + +.ai-note { + padding: 10px 14px; + border-left: 4px solid #f79009; + border-radius: 6px; + background: var(--warn-tint); + color: #7a2e0e; +} + +.clues { + padding: 10px 14px; + border: 1px dashed var(--line-strong); + border-radius: 6px; + background: #f9fafb; +} + +.clues summary, +.guide-log summary { + font-weight: 700; + cursor: pointer; +} + +.clues ul { + margin: 10px 0 0; + padding-left: 20px; + line-height: 1.55; +} + +.guide-log-count { + margin-left: 6px; + color: var(--muted); + font-size: 0.85rem; + font-weight: 400; +} + +/* Step results */ + +.badge { + display: inline-block; + padding: 2px 9px; + border: 1px solid #e1e5ea; + border-radius: 999px; + background: #f2f4f7; + color: var(--muted); + font-size: 0.76rem; + font-weight: 600; + white-space: nowrap; +} + +.badge-compact { + padding: 0; + border: 0; + background: transparent; + font-size: 0.9rem; +} + +.badge-passed { + border-color: var(--good-line); + background: var(--good-tint); + color: var(--good); +} + +.badge-failed { + border-color: var(--red-line); + background: var(--red-tint); + color: var(--red-dark); +} + +.badge-running { + border-color: var(--warn-line); + background: var(--warn-tint); + color: var(--warn); +} + +.badge-compact.badge-passed, +.badge-compact.badge-failed, +.badge-compact.badge-running { + background: transparent; +} + +.banner { + display: grid; + justify-items: start; + gap: 6px; + padding: 12px 14px; + border: 1px solid var(--line); + border-left: 4px solid #98a2b3; + border-radius: 8px; + background: var(--sheet); + line-height: 1.5; +} + +.banner-passed { + border-color: var(--good-line); + border-left-color: var(--good); + background: var(--good-tint); + color: var(--good-ink); +} + +.banner-failed { + border-color: var(--red-line); + border-left-color: var(--red); + background: var(--red-tint); + color: #912018; +} + +.banner-running { + border-color: var(--warn-line); + border-left-color: var(--warn-accent); + background: var(--warn-tint); + color: var(--warn); +} + +.banner-locked { + color: var(--muted); +} + +.banner-note { + font-size: 0.82rem; + opacity: 0.85; +} + +/* @key recreates the banner for every new result, so this animation plays again after each click */ +.banner-fresh { + animation: banner-in 0.35s ease-out; +} + +@keyframes banner-in { + from { + opacity: 0; + transform: translateY(-6px); + } + + to { + opacity: 1; + transform: none; + } +} + +.link-button { + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + font-weight: 700; + text-decoration: underline; + text-underline-offset: 3px; + cursor: pointer; +} + +.link-button:hover { + text-decoration-thickness: 2px; +} + +.check-list { + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.check { + display: grid; + grid-template-columns: 24px 1fr; + gap: 8px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--sheet); +} + +.check-failed { + border-color: var(--red-line); + border-left: 4px solid var(--red); + background: #fffbfa; +} + +.check-body { + min-width: 0; +} + +.guide-section .check-description { + margin: 0 0 6px; + font-weight: 600; + line-height: 1.4; +} + +.check-values { + display: grid; + grid-template-columns: max-content 1fr; + gap: 2px 10px; + margin: 0; + font-size: 0.86rem; +} + +.check-values dt { + color: var(--muted); +} + +.check-values dd { + margin: 0; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} + +.guide-section .check-hint { + margin: 8px 0 0; + padding: 8px 10px; + border: 1px solid var(--red-line); + border-radius: 6px; + background: #fee4e2; + color: var(--red-ink); + font-size: 0.87rem; + line-height: 1.5; +} + +.table-scroll { + margin-top: 10px; + overflow-x: auto; +} + +.step-log { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} + +.step-log th, +.step-log td { + padding: 6px 8px; + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: top; +} + +.step-log th { + color: var(--muted); +} + +.step-log td:nth-child(-n + 3) { + font-family: var(--mono); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* Narrow screens stack the guide under the app */ + +@media (max-width: 1080px) { + :root { + --guide-width: 380px; + } + + .ticker-grid { + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + } +} + +@media (max-width: 860px) { + .workspace { + flex-direction: column; + } + + .terminal { + min-height: 0; + } + + .guide, + .guide.collapsed { + position: static; + flex-basis: auto; + height: auto; + border-left: 0; + box-shadow: none; + } + + .guide::before { + position: static; + } + + .guide.collapsed { + grid-auto-flow: column; + justify-content: start; + align-items: center; + padding: 0 16px 0 0; + } + + .guide.collapsed::before { + align-self: stretch; + display: grid; + place-items: center; + padding: 0 12px; + } + + .guide.collapsed .guide-rail { + grid-auto-flow: column; + padding-block: 12px; + } + + .guide-rail-label { + writing-mode: horizontal-tb; + } + + .term-bar, + .term-head { + padding-inline: 16px; + } + + .term-live { + margin-left: 0; } .status-tile { justify-items: start; + min-width: 0; + } + + .ticker-grid { + padding: 12px 16px 28px; + } + + .feed-fault { + margin-inline: 16px; } } diff --git a/8. Concurrent Collections/2. Finish/StockWatch/Components/Layout/MainLayout.razor b/8. Concurrent Collections/2. Finish/StockWatch/Components/Layout/MainLayout.razor index 1c2a641..92b039a 100644 --- a/8. Concurrent Collections/2. Finish/StockWatch/Components/Layout/MainLayout.razor +++ b/8. Concurrent Collections/2. Finish/StockWatch/Components/Layout/MainLayout.razor @@ -1,5 +1,21 @@ @inherits LayoutComponentBase -
- @Body -
+
+
+ @* The dashboard and the workshop guide share one circuit. An exception the page does not handle would end that circuit, and the guide would stop responding with it. *@ + + + @Body + + + + + + +
+ + +
diff --git a/8. Concurrent Collections/2. Finish/StockWatch/Components/Layout/StepBadge.razor b/8. Concurrent Collections/2. Finish/StockWatch/Components/Layout/StepBadge.razor new file mode 100644 index 0000000..352439e --- /dev/null +++ b/8. Concurrent Collections/2. Finish/StockWatch/Components/Layout/StepBadge.razor @@ -0,0 +1,10 @@ +@inherits StepBadgeBase + +@if (IsCompact) +{ + @Label +} +else +{ + @Icon @Label +} diff --git a/8. Concurrent Collections/2. Finish/StockWatch/Components/Layout/StepBadge.razor.cs b/8. Concurrent Collections/2. Finish/StockWatch/Components/Layout/StepBadge.razor.cs new file mode 100644 index 0000000..ede3096 --- /dev/null +++ b/8. Concurrent Collections/2. Finish/StockWatch/Components/Layout/StepBadge.razor.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Components; + +namespace StockWatch.Components.Layout; + +public partial class StepBadgeBase : ComponentBase +{ + [Parameter, EditorRequired] + public StepStatus Status { get; set; } + + [Parameter] + public bool IsUnlocked { get; set; } = true; + + // Only the icon, for the collapsed guide + [Parameter] + public bool IsCompact { get; set; } + + public string Icon => Status is StepStatus.Running ? "⏳" : !IsUnlocked ? "🔒" : Status switch + { + StepStatus.Passed => "✅", + StepStatus.Failed or StepStatus.TimedOut or StepStatus.Crashed => "❌", + _ => "•", + }; + + public string Label => Status is StepStatus.Running ? "Running" : !IsUnlocked ? "Locked" : Status switch + { + StepStatus.Passed => "Passed", + StepStatus.Failed => "Failed", + StepStatus.TimedOut => "Timed out", + StepStatus.Crashed => "Crashed", + StepStatus.Stopped => "Stopped", + _ => "Not run", + }; + + public string CssClass => Status is StepStatus.Running ? "badge-running" : !IsUnlocked ? "badge-locked" : Status switch + { + StepStatus.Passed => "badge-passed", + StepStatus.Failed or StepStatus.TimedOut or StepStatus.Crashed => "badge-failed", + _ => "badge-idle", + }; +} \ No newline at end of file diff --git a/8. Concurrent Collections/2. Finish/StockWatch/Components/Layout/WorkshopGuide.razor b/8. Concurrent Collections/2. Finish/StockWatch/Components/Layout/WorkshopGuide.razor new file mode 100644 index 0000000..b3b410c --- /dev/null +++ b/8. Concurrent Collections/2. Finish/StockWatch/Components/Layout/WorkshopGuide.razor @@ -0,0 +1,326 @@ +@inherits WorkshopGuideBase + + diff --git a/8. Concurrent Collections/2. Finish/StockWatch/Components/Layout/WorkshopGuide.razor.cs b/8. Concurrent Collections/2. Finish/StockWatch/Components/Layout/WorkshopGuide.razor.cs new file mode 100644 index 0000000..c201ad4 --- /dev/null +++ b/8. Concurrent Collections/2. Finish/StockWatch/Components/Layout/WorkshopGuide.razor.cs @@ -0,0 +1,179 @@ +using Microsoft.AspNetCore.Components; + +namespace StockWatch.Components.Layout; + +// Workshop plumbing: the guide docked beside the app. It shows your challenge, every step's result, and the step you are working on. +public partial class WorkshopGuideBase : ComponentBase, IAsyncDisposable +{ + // Selecting this shows your challenge instead of a step + public const int ChallengeTab = 0; + + // A run that fails quickly can finish faster than anyone can see a spinner + static readonly TimeSpan _minimumActivityIndicatorTime = TimeSpan.FromMilliseconds(600); + + readonly CancellationTokenSource _disposeCancellationTokenSource = new(); + + Task _polling = Task.CompletedTask; + bool _isStartingCheck; + int? _startingRunStepNumber; + + [Inject] + public required StepVerifier Verifier { get; init; } + + [Inject] + public required ILogger Logger { get; init; } + + public int SelectedTab { get; private set; } = ChallengeTab; + + public bool IsCollapsed { get; private set; } + + public string? ErrorMessage { get; private set; } + + public int PassedStepCount => Verifier.Steps.Count(step => Verifier.GetState(step.Number).Status is StepStatus.Passed); + + public bool IsCheckingSteps => _isStartingCheck || Verifier.IsCheckingAllSteps; + + // The first step that does not pass yet, which is the one to work on next + public WorkshopStep? StoppedAtStep => Verifier.Steps.FirstOrDefault(step => Verifier.GetState(step.Number).Status is not StepStatus.Passed); + + // The last step the check reaches: the first one that did not pass, or the last step when they all passed + public WorkshopStep LastCheckedStep => StoppedAtStep ?? Verifier.Steps[^1]; + + public WorkshopStep? SelectedStep => Verifier.FindStep(SelectedTab); + + // The run lives in the verifier, not in this guide, so a step that is still running shows up again after a reload + public ActiveStepRun? SelectedRun => Verifier.ActiveRun is { } run && run.Step.Number == SelectedTab ? run : null; + + public bool IsSelectedStepRunning => _startingRunStepNumber == SelectedTab || SelectedRun is not null; + + public async ValueTask DisposeAsync() + { + // Only stops refreshing the guide. A step that is running keeps running. + await _disposeCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + await _polling.ConfigureAwait(false); + + _disposeCancellationTokenSource.Dispose(); + + GC.SuppressFinalize(this); + } + + protected override void OnInitialized() + { + _polling = PollVerifier(_disposeCancellationTokenSource.Token); + } + + protected void SelectTab(int tab) + { + SelectedTab = tab; + IsCollapsed = false; + ErrorMessage = null; + } + + protected void ToggleCollapsed() => IsCollapsed = !IsCollapsed; + + protected async Task CheckAllStepsAsync() + { + var token = _disposeCancellationTokenSource.Token; + string? errorMessage = null; + + ErrorMessage = null; + _isStartingCheck = true; + + // Show the activity indicator before the check starts + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + try + { + // Keep the indicator on screen long enough to notice, even when Step 1 fails quickly + await Task.WhenAll(Verifier.CheckAllSteps(), Task.Delay(_minimumActivityIndicatorTime, token)).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The guide was closed while the steps were being checked + return; + } + catch (Exception e) + { + Logger.LogError(e, "Checking every step failed"); + + errorMessage = "Checking the steps failed. The terminal running the app has the details."; + } + + // The continuation is off Blazor's renderer, so every component state change goes back through it + await InvokeAsync(() => + { + ErrorMessage = errorMessage; + _isStartingCheck = false; + + StateHasChanged(); + }).ConfigureAwait(false); + } + + protected async Task RunSelectedStepAsync() + { + if (SelectedStep is not { } step) + return; + + var token = _disposeCancellationTokenSource.Token; + string? errorMessage = null; + + ErrorMessage = null; + _startingRunStepNumber = step.Number; + + // Show the activity indicator before the run starts + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + try + { + // Keep the indicator on screen long enough to notice, even when the step fails quickly + await Task.WhenAll(Verifier.RunStep(step), Task.Delay(_minimumActivityIndicatorTime, token)).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The guide was closed while the step was running + return; + } + catch (Exception e) + { + Logger.LogError(e, "Running Step {StepNumber} failed", step.Number); + + errorMessage = "The step could not be run. The terminal running the app has the details."; + } + + // The continuation is off Blazor's renderer, so every component state change goes back through it + await InvokeAsync(() => + { + ErrorMessage = errorMessage; + _startingRunStepNumber = null; + + StateHasChanged(); + }).ConfigureAwait(false); + } + + protected void StopRun() => Verifier.StopRun(); + + // Re-renders while the verifier is busy, plus once more when it finishes, so the live log and results appear without a refresh + async Task PollVerifier(CancellationToken token) + { + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100)); + var wasBusy = true; + + try + { + while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false)) + { + var isBusy = Verifier.IsBusy; + + if (isBusy || wasBusy) + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + wasBusy = isBusy; + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // Expected when the guide is disposed + } + } +} \ No newline at end of file diff --git a/8. Concurrent Collections/2. Finish/StockWatch/Components/Pages/Dashboard.razor b/8. Concurrent Collections/2. Finish/StockWatch/Components/Pages/Dashboard.razor index 3d78637..0105271 100644 --- a/8. Concurrent Collections/2. Finish/StockWatch/Components/Pages/Dashboard.razor +++ b/8. Concurrent Collections/2. Finish/StockWatch/Components/Pages/Dashboard.razor @@ -3,22 +3,62 @@ StockWatch -
-
-
-

StockWatch

+
+
+ StockWatch + + + + Live feed · 2s refresh +
+ +
+
+

US equities · 60 symbols · parallel feed

Market dashboard

-

Every symbol refreshes in parallel on Thread Pool threads.

+

Every symbol refreshes in parallel on Thread Pool threads.

@RefreshCount quotes applied
-
+
+ + @{ + var symbols = Symbols; + } + +
- @foreach (var symbol in Symbols) + @foreach (var symbol in symbols) {
@@ -38,4 +78,4 @@
}
- + diff --git a/8. Concurrent Collections/2. Finish/StockWatch/Program.cs b/8. Concurrent Collections/2. Finish/StockWatch/Program.cs index 986eac7..028d95f 100644 --- a/8. Concurrent Collections/2. Finish/StockWatch/Program.cs +++ b/8. Concurrent Collections/2. Finish/StockWatch/Program.cs @@ -13,6 +13,11 @@ public static void Main(string[] args) builder.Services.AddSingleton(); + // Workshop plumbing: one StepVerifier holds every step's result. It is also a hosted service, + // so it checks your Dashboard code against every step each time the app starts. + builder.Services.AddSingleton(); + builder.Services.AddHostedService(static serviceProvider => serviceProvider.GetRequiredService()); + var app = builder.Build(); if (!app.Environment.IsDevelopment()) diff --git a/8. Concurrent Collections/2. Finish/StockWatch/Steps/Step1ShowEveryCard.cs b/8. Concurrent Collections/2. Finish/StockWatch/Steps/Step1ShowEveryCard.cs new file mode 100644 index 0000000..3583c8e --- /dev/null +++ b/8. Concurrent Collections/2. Finish/StockWatch/Steps/Step1ShowEveryCard.cs @@ -0,0 +1,122 @@ +namespace StockWatch; + +public sealed class Step1ShowEveryCard : WorkshopStep +{ + // One call can get lucky. Two hundred calls in a row, each adding 60 cards from several workers, almost never do. + const int _callCount = 200; + + const string _missingCardsHint = "Cards went missing, and nothing warned you. List.Add writes into an internal array and sometimes grows it, so two Parallel.ForEach workers adding at once can write the same slot, or write into the old array while another worker replaces it. " + + "Collect the cards in a collection that many threads can add to at once."; + + const string _throwsHint = "A torn List leaves an empty slot in its backing array, or throws while it grows. OrderBy then dereferences the empty slot, which is the NullReferenceException behind the Feed fault panel in the browser. " + + "Collect the cards in a collection that many threads can add to at once."; + + const string _orderHint = "Parallel.ForEach adds the cards in whatever order its workers finish. Keep the OrderBy on the last line of GetSymbols(), so the grid stays alphabetical."; + + public override int Number => 1; + + public override string Scenario => "Show every card"; + + public override string Title => "Symbols and GetSymbols()"; + + public override string Story => "Traders keep StockWatch open all day, and they trust the grid: a symbol that is not on it is a symbol they are not watching. " + + "The Symbols property builds a card for every one of the 60 symbols from inside Parallel.ForEach, on every render, and List was never built for more than one writer. " + + "Cards silently go missing, a different number on every reload, and every so often the render throws and the quote board stops updating altogether."; + + public override string SeeItInTheApp => "On the Dashboard page, count the cards. There should be 60, and you will usually count somewhere in the forties or fifties. " + + "Reload a few times and the number changes. Every so often a Feed fault panel replaces the whole dashboard until you reload, and the terminal running the app shows a NullReferenceException from OrderBy."; + + public override string FileToChange => "Components/Pages/Dashboard.razor.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Add using System.Collections.Concurrent; to the top of Dashboard.razor.cs.", + "Replace the List in GetSymbols() with a thread-safe collection that many Parallel.ForEach workers can add to at once.", + "Keep the OrderBy on the last line of GetSymbols(), so the cards stay in alphabetical order.", + "Keep the Parallel.ForEach, and leave MarketDataService, the models, and Dashboard.razor alone. The parallel loop is not the bug.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "List is not thread safe. Add writes into an internal array and sometimes replaces that array with a bigger one.", + "A missing card and a NullReferenceException from OrderBy are the same bug: a slot in the list's array that no worker ever filled.", + "Nothing here needs the cards in the order they were added, because OrderBy sorts them anyway.", + "System.Collections.Concurrent has a collection built for exactly this job: collect results from many threads now, and sort them later.", + ]; + + public override string TimeoutHint => "Symbols never returned. Check that nothing in GetSymbols() waits on a lock or a semaphore that is never released."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + if (!DashboardHarness.HasMembers(report, "StopRefreshTimer")) + return; + + // Part 1: a fresh dashboard, the same page a new browser tab gets, with a price for every symbol + await using var harness = await DashboardHarness.Render().ConfigureAwait(false); + + if (!await harness.LoadAndStopTimer(report, token).ConfigureAwait(false)) + return; + + // Part 2: every render reads Symbols, so read it the way two hundred renders in a row would + var expectedSymbols = harness.Dashboard.MarketDataService.Symbols.Order().ToList(); + report.Log($"Reading Symbols {_callCount} times. Each read runs Parallel.ForEach over {expectedSymbols.Count} symbols, with up to one worker per core ({Environment.ProcessorCount} on this machine)"); + + var completeCalls = 0; + var orderedCalls = 0; + var returnedCalls = 0; + var fewestCards = int.MaxValue; + var exceptionTypes = new SortedSet(StringComparer.Ordinal); + + for (var call = 0; call < _callCount; call++) + { + token.ThrowIfCancellationRequested(); + + IReadOnlyList cards; + + try + { + cards = harness.Dashboard.Symbols; + } + catch (Exception e) + { + // Only the exception type goes to the page + exceptionTypes.Add(e.GetType().Name); + continue; + } + + returnedCalls++; + fewestCards = Math.Min(fewestCards, cards.Count); + + var symbols = cards.Select(static card => card.Symbol).ToList(); + + if (symbols.Order().SequenceEqual(expectedSymbols)) + completeCalls++; + + if (symbols.SequenceEqual(symbols.Order())) + orderedCalls++; + } + + var threwCalls = _callCount - returnedCalls; + + report.Log(returnedCalls is 0 + ? $"Every read of Symbols threw: {string.Join(", ", exceptionTypes)}" + : $"{completeCalls} of {_callCount} reads returned all {expectedSymbols.Count} cards. The fewest cards any read returned was {fewestCards}"); + + if (threwCalls > 0) + report.Log($"{threwCalls} reads threw: {string.Join(", ", exceptionTypes)}"); + + report.Expect($"{_callCount} reads of Symbols in a row each return a card for all {DashboardHarness.SymbolCount} symbols", $"{_callCount} of {_callCount}", $"{completeCalls} of {_callCount}", completeCalls is _callCount, _missingCardsHint); + + report.Expect($"None of the {_callCount} reads throws", 0, threwCalls, _throwsHint); + + report.Expect( + "Every read lists its cards in alphabetical order", + "every read", + returnedCalls is 0 ? "no read returned" : $"{orderedCalls} of {returnedCalls} reads that returned", + returnedCalls > 0 && orderedCalls == returnedCalls, + returnedCalls is 0 ? _throwsHint : _orderHint); + } +} \ No newline at end of file diff --git a/8. Concurrent Collections/2. Finish/StockWatch/Steps/Step2KeepEveryQuote.cs b/8. Concurrent Collections/2. Finish/StockWatch/Steps/Step2KeepEveryQuote.cs new file mode 100644 index 0000000..4fa48bb --- /dev/null +++ b/8. Concurrent Collections/2. Finish/StockWatch/Steps/Step2KeepEveryQuote.cs @@ -0,0 +1,120 @@ +namespace StockWatch; + +public sealed class Step2KeepEveryQuote : WorkshopStep +{ + const string _newerSymbol = "AAPL"; + const string _olderSymbol = "MSFT"; + + const string _collectionHint = "Dictionary supports one writer at a time, and RefreshQuotes() writes all 60 quotes from Parallel.ForEachAsync workers at once. " + + "A lost insert is a card stuck on --, and a corrupted dictionary can throw or lose every quote. Replace it with a keyed collection from System.Collections.Concurrent that is built for many concurrent writers."; + + const string _newerQuoteHint = "The refresh replaced a newer quote with an older one. TryAdd followed by an indexer assignment, or an update that always stores the incoming quote, lets a slow refresh overwrite a quote that already landed. " + + "Add or update each quote in a single call that keeps whichever quote has the newer Timestamp."; + + const string _olderQuoteHint = "The refresh has to update a quote that is already there, not only add the missing ones. Compare the two Timestamps, and store the newer quote."; + + const string _everyQuoteHint = "Every symbol needs a quote after a refresh, or its card shows -- instead of a price. Check that every quote RefreshQuotes() fetches is added or updated."; + + public override int Number => 2; + + public override string Scenario => "Keep every quote, and keep the newest"; + + public override string Title => "_latestQuotes and RefreshQuotes()"; + + public override string Story => "Every 2 seconds, RefreshQuotes() fetches all 60 quotes at once through Parallel.ForEachAsync and writes each one into _latestQuotes the moment it arrives. " + + "Dictionary supports one writer at a time, so a concurrent insert can lose a quote, corrupt the dictionary, or throw. " + + "And when a slow tick overlaps the next one, TryAdd followed by an indexer assignment lets an older quote overwrite a newer one that already landed. A trader sees a stale price, with nothing to say it is stale."; + + public override string SeeItInTheApp => "On the Dashboard page, look for cards that show -- instead of a price. GetSymbols() builds a card for every symbol either way, so a card showing -- is a quote that never made it into _latestQuotes. " + + "It is rarer than a missing card, so reload a few times. A stale price looks exactly like a real one, which is why this step checks for it directly."; + + public override string FileToChange => "Components/Pages/Dashboard.razor.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Replace _latestQuotes with a collection from System.Collections.Concurrent that is built for many concurrent writers.", + "Replace the TryAdd and the indexer assignment with a single call that adds the quote, or updates the existing one, keeping whichever quote has the newer Timestamp.", + "Keep the delegate you pass to that call cheap and free of side effects. It runs outside the collection's internal lock, and it can run more than once.", + "Keep the InvokeAsync(StateHasChanged) call and the CancellationToken plumbing that are already in RefreshQuotes().", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "A thread-safe TryAdd followed by a thread-safe indexer assignment is still two operations. Another thread can write between them.", + "Quotes arrive in whatever order the feed answers. When two refreshes overlap, the last write is not always the newest quote.", + "Keyed lookup from many concurrent writers is exactly what ConcurrentDictionary is for.", + "AddOrUpdate takes the key, the value to add when the key is missing, and a delegate that picks the value to store when it is already there. Compare the two Timestamps, return the winner, and do nothing else.", + ]; + + public override string TimeoutHint => "RefreshQuotes() never returned. Check that nothing in it waits on a lock or a semaphore, and that it still passes its CancellationToken to Parallel.ForEachAsync."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + if (!DashboardHarness.HasMembers(report, "_latestQuotes", "RefreshQuotes", "StopRefreshTimer")) + return; + + // Part 1: the collection itself. A lost insert is rare enough that one refresh can get lucky, so the type is checked directly. + var quotesType = DashboardHarness.FindFieldType("_latestQuotes"); + report.Log($"_latestQuotes is a {FormatType(quotesType)} from {quotesType?.Namespace}"); + + report.Expect( + "_latestQuotes is a collection from System.Collections.Concurrent", + "System.Collections.Concurrent", + $"{FormatType(quotesType)} from {quotesType?.Namespace}", + quotesType?.Namespace is "System.Collections.Concurrent", + _collectionHint); + + // Part 2: a fresh dashboard, the same page a new browser tab gets + await using var harness = await DashboardHarness.Render().ConfigureAwait(false); + + if (!await harness.LoadAndStopTimer(report, token).ConfigureAwait(false)) + return; + + report.Expect("After loading, every symbol has a quote", $"{DashboardHarness.SymbolCount} of {DashboardHarness.SymbolCount}", $"{CountQuotes(harness)} of {DashboardHarness.SymbolCount}", _everyQuoteHint); + + if (harness.GetField("_latestQuotes") is not IDictionary latestQuotes) + { + report.Expect("_latestQuotes can be read by symbol", "a dictionary of StockQuoteModel by symbol", FormatType(quotesType), false, "The dashboard looks up each card's quote by its symbol, so _latestQuotes has to stay a keyed collection of StockQuoteModel by symbol string."); + return; + } + + // Part 3: two refreshes overlap. A quote timestamped an hour from now has already landed for one symbol, and a quote from an hour ago for another. + var newerQuote = await harness.Dashboard.MarketDataService.GetStockQuote(_newerSymbol, token).ConfigureAwait(false) with { Timestamp = DateTimeOffset.UtcNow.AddHours(1) }; + var olderQuote = await harness.Dashboard.MarketDataService.GetStockQuote(_olderSymbol, token).ConfigureAwait(false) with { Timestamp = DateTimeOffset.UtcNow.AddHours(-1) }; + + latestQuotes[_newerSymbol] = newerQuote; + latestQuotes[_olderSymbol] = olderQuote; + + report.Log($"Stored a quote for {_newerSymbol} timestamped an hour from now and a quote for {_olderSymbol} from an hour ago, then called RefreshQuotes(), the way an overlapping timer tick would"); + + var refreshOutcome = await DashboardHarness.Describe(harness.RefreshQuotes(token), TimeSpan.FromSeconds(5), token).ConfigureAwait(false); + report.Log($"RefreshQuotes() {refreshOutcome}"); + + if (refreshOutcome is not DashboardHarness.Finished) + { + report.Expect("RefreshQuotes() finishes", DashboardHarness.Finished, refreshOutcome, false, "RefreshQuotes() has to finish every refresh, and only an OperationCanceledException from its own token is expected. Check what the code that stores each quote can throw or wait on."); + return; + } + + var keptNewerQuote = latestQuotes.TryGetValue(_newerSymbol, out var storedNewerQuote) && storedNewerQuote == newerQuote; + var replacedOlderQuote = latestQuotes.TryGetValue(_olderSymbol, out var storedOlderQuote) && storedOlderQuote.Timestamp > olderQuote.Timestamp; + + report.Expect($"A newer quote for {_newerSymbol}, timestamped an hour from now, survives the refresh", "kept", keptNewerQuote ? "kept" : "replaced by an older quote", keptNewerQuote, _newerQuoteHint); + report.Expect($"An older quote for {_olderSymbol}, from an hour ago, is replaced by the refresh", "replaced", replacedOlderQuote ? "replaced" : "still the old quote", replacedOlderQuote, _olderQuoteHint); + report.Expect("After the refresh, every symbol still has a quote", $"{DashboardHarness.SymbolCount} of {DashboardHarness.SymbolCount}", $"{CountQuotes(harness)} of {DashboardHarness.SymbolCount}", _everyQuoteHint); + } + + // Counts the cards showing a price, the same way the grid decides between a price and -- + static int CountQuotes(DashboardHarness harness) => harness.Dashboard.Symbols.Count(static card => card.Quote is not null); + + static string FormatType(Type? type) => type switch + { + null => "missing field", + { IsGenericType: true } => $"{type.Name[..type.Name.IndexOf('`', StringComparison.Ordinal)]}<{string.Join(", ", type.GetGenericArguments().Select(FormatType))}>", + _ when type == typeof(string) => "string", + _ => type.Name, + }; +} \ No newline at end of file diff --git a/8. Concurrent Collections/2. Finish/StockWatch/Steps/Step3CountEveryQuote.cs b/8. Concurrent Collections/2. Finish/StockWatch/Steps/Step3CountEveryQuote.cs new file mode 100644 index 0000000..2c16526 --- /dev/null +++ b/8. Concurrent Collections/2. Finish/StockWatch/Steps/Step3CountEveryQuote.cs @@ -0,0 +1,99 @@ +using StockWatch.Components.Pages; + +namespace StockWatch; + +public sealed class Step3CountEveryQuote : WorkshopStep +{ + // Refreshes that overlap, the way timer ticks do when the feed is slow, so far more increments land at the same moment than on one tick + const int _overlappingRefreshes = 32; + + const string _lostCountHint = "Quotes were applied and never counted, and nothing threw. _refreshCount++ is a read, an add, and a write, and two quotes that land at the same moment can both read the same count. " + + "Replace it with an atomic increment."; + + public override int Number => 3; + + public override string Scenario => "Count every quote"; + + public override string Title => "RefreshQuotes() and RefreshCount"; + + public override string Story => "The quotes applied tile is how the support desk tells a slow feed from a stalled one. It should read 60 on the first paint and climb by exactly 60 on every tick. " + + "_refreshCount++ runs once per quote, from whichever Parallel.ForEachAsync worker fetched it, and ++ is a read, an add, and a write. Two quotes that land at the same moment can both read the same count, and one increment disappears. " + + "One lost increment out of sixty, with nothing to tell you it happened, is the quietest bug on this page."; + + public override string SeeItInTheApp => "On the Dashboard page, read the quotes applied tile. The first paint should read 60, and every so often it reads 59 instead, more often on a machine with more cores. " + + "Wait 2 seconds: it should climb by exactly 60 on every tick, and every so often it climbs by 59."; + + public override string FileToChange => "Components/Pages/Dashboard.razor.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Replace _refreshCount++ in RefreshQuotes() with an atomic increment.", + "Read _refreshCount in the RefreshCount getter in a way that forces a real read of the field, instead of a value the JIT cached in a register.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "_refreshCount++ looks like one operation. It is three: read the field, add one, and write it back. Increments that interleave get lost.", + "RefreshCount is read by Blazor's renderer on a different thread than the one that last wrote _refreshCount.", + "An int does not need a lock. Interlocked updates one atomically, and Volatile reads one safely.", + ]; + + public override string TimeoutHint => "RefreshQuotes() never returned. Check that nothing in it waits on a lock or a semaphore."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + if (!DashboardHarness.HasMembers(report, "RefreshQuotes", "StopRefreshTimer")) + return; + + // Part 1: a fresh dashboard, the same page a new browser tab gets. Its first paint shows one quote applied per symbol. + await using var harness = await DashboardHarness.Render().ConfigureAwait(false); + + if (!await harness.LoadAndStopTimer(report, token).ConfigureAwait(false)) + return; + + report.Expect("After loading, quotes applied reads 60", DashboardHarness.SymbolCount, harness.Dashboard.RefreshCount, _lostCountHint); + + // Part 2: overlapping refreshes, so many quotes land at the same moment + var countBefore = harness.Dashboard.RefreshCount; + var expectedCount = countBefore + (_overlappingRefreshes * DashboardHarness.SymbolCount); + report.Log($"Running {_overlappingRefreshes} refreshes at the same time, {_overlappingRefreshes * DashboardHarness.SymbolCount:N0} quotes in all"); + + var refreshes = Task.WhenAll(Enumerable.Range(0, _overlappingRefreshes).Select(_ => harness.RefreshQuotes(token))); + var refreshOutcome = await DashboardHarness.Describe(refreshes, TimeSpan.FromSeconds(10), token).ConfigureAwait(false); + + report.Log($"The refreshes {refreshOutcome}, and quotes applied reads {harness.Dashboard.RefreshCount:N0}"); + + if (refreshOutcome is not DashboardHarness.Finished) + { + report.Expect("Overlapping refreshes finish", DashboardHarness.Finished, refreshOutcome, false, "RefreshQuotes() has to finish every refresh, even when refreshes overlap. Check what the code in its loop can throw or wait on."); + return; + } + + report.Expect($"{_overlappingRefreshes} overlapping refreshes add exactly 60 each", expectedCount, harness.Dashboard.RefreshCount, _lostCountHint); + + // Part 3: a lost increment is timing dependent, so the refreshes above can come out right by luck. These read your compiled code instead. + var refreshCalls = CodeInspector.GetCalledMethods(typeof(DashboardPageBase), "RefreshQuotes"); + var usesInterlocked = refreshCalls.Contains("Interlocked.Increment") || refreshCalls.Contains("Interlocked.Add"); + report.Log($"RefreshQuotes() and its lambdas call {refreshCalls.Count} methods{(usesInterlocked ? ", including Interlocked.Increment" : ", and none of them is on Interlocked")}"); + + report.Expect( + "RefreshQuotes() counts each quote with Interlocked", + "Interlocked.Increment", + usesInterlocked ? "Interlocked.Increment" : "no Interlocked call", + usesInterlocked, + "Lost increments depend on timing, so this checks your code instead of trusting one lucky run. _refreshCount++ is a read, an add, and a write. Replace it with the atomic increment from the Interlocked class."); + + var getterCalls = CodeInspector.GetCalledMethods(typeof(DashboardPageBase), "get_RefreshCount"); + var usesVolatileRead = getterCalls.Contains("Volatile.Read"); + + report.Expect( + "The RefreshCount getter reads _refreshCount with Volatile.Read", + "Volatile.Read", + usesVolatileRead ? "Volatile.Read" : "a plain field read", + usesVolatileRead, + "No test can reliably catch a stale read, so this checks your code. RefreshCount is read by Blazor's renderer on a different thread than the one that last wrote _refreshCount. Read the field in a way that forces a real read, instead of a value the JIT may have cached."); + } +} \ No newline at end of file diff --git a/8. Concurrent Collections/2. Finish/StockWatch/Steps/Step4GuardRefreshTimer.cs b/8. Concurrent Collections/2. Finish/StockWatch/Steps/Step4GuardRefreshTimer.cs new file mode 100644 index 0000000..a263b9a --- /dev/null +++ b/8. Concurrent Collections/2. Finish/StockWatch/Steps/Step4GuardRefreshTimer.cs @@ -0,0 +1,302 @@ +namespace StockWatch; + +public sealed class Step4GuardRefreshTimer : WorkshopStep +{ + // Starts and stops at the same time, the way pages that open, reload and close all day produce them + const int _concurrentCalls = 400; + + // What a timer method did while this step held the semaphore + const string _waited = "waited for the semaphore, then finished"; + const string _didNotWait = "finished without waiting for the semaphore"; + const string _blocked = "blocked a thread while it waited"; + const string _stillWaiting = "still waiting 1 second after the semaphore was released"; + + const string _noSemaphoreHint = "Nothing orders StartRefreshTimer() against StopRefreshTimer(), so two callers can both read _refreshTimer and leak a live timer. " + + "Add a SemaphoreSlim created with new(1, 1), and guard every read and write of _refreshTimer with it. Do not use lock: it cannot be held across an await, and every method here awaits."; + + const string _skippedHint = "Add the semaphore first. Calling the unguarded timer methods at the same time leaks live timers, and a leaked timer that fires after DisposeAsync() throws inside an async void timer callback, which ends the whole app. " + + "So this check waits until the timer is guarded."; + + const string _releaseHint = "Something took the semaphore and never gave it back, so the next caller would wait forever. Release it inside a finally block, so every WaitAsync is matched by exactly one Release, even when the guarded code throws."; + + const string _deadlockHint = "SemaphoreSlim is not reentrant. A method that holds the semaphore and then calls another method that waits on it waits on itself forever. " + + "Move the timer disposal into a private method that assumes the semaphore is already held, and call that method from inside both guarded blocks."; + + const string _leakHint = "A timer is still refreshing a page that stopped its timer. Two calls read and wrote _refreshTimer at the same time, and one timer was replaced without being disposed. " + + "Hold the semaphore for the whole time a method reads, disposes, creates or assigns the timer, not just part of it."; + + // Longer than the timer's 2 second period, so any timer that is still running ticks at least once + static readonly TimeSpan _tickWait = TimeSpan.FromSeconds(2.5); + + // How long this step holds the semaphore to see whether a timer method waits for it + static readonly TimeSpan _holdTime = TimeSpan.FromMilliseconds(300); + + static readonly TimeSpan _callTimeout = TimeSpan.FromSeconds(1); + + public override int Number => 4; + + public override string Scenario => "Guard the refresh timer"; + + public override string Title => "StartRefreshTimer(), StopRefreshTimer() and DisposeAsync()"; + + public override string Story => "People open the dashboard, reload it, and close it all day, so the page starts and stops its refresh timer constantly. " + + "StartRefreshTimer() and StopRefreshTimer() both read and write _refreshTimer, and nothing orders them. A page torn down while it is still initializing can null the field out from under the method that is assigning it, " + + "and the timer it leaks keeps refreshing a page nobody is looking at. When that timer next fires, the CancellationTokenSource its callback reads has already been disposed, and an exception in an async void timer callback ends the whole process."; + + public override string SeeItInTheApp => "You will rarely see this one in the browser, which is exactly what makes it dangerous. It needs a page torn down at the same moment it starts its timer, such as a tab closed or reloaded while the dashboard is still loading. " + + "The Feed fault panel cannot catch this one: the exception is thrown on a timer thread, not while the page renders, so the whole app exits and the browser just loses its connection. The terminal running the app is the only place that tells you why."; + + public override string FileToChange => "Components/Pages/Dashboard.razor.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Add a SemaphoreSlim created with new(1, 1), and use it to guard every read and write of _refreshTimer.", + "Do not use lock. lock cannot be held across an await, and every method here awaits.", + "Wait on the semaphore with await WaitAsync(...), and release it inside a finally block.", + "Move the timer disposal into a private method that assumes the semaphore is already held, and call that method from inside both guarded blocks.", + "Dispose the semaphore in DisposeAsync().", + "Keep the InvokeAsync(StateHasChanged) call and the CancellationToken plumbing that are already there.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "StartRefreshTimer() and StopRefreshTimer() both touch _refreshTimer, and nothing stops them running at the same time.", + "The guarded code awaits, so it needs a lock that can be awaited. A SemaphoreSlim with one slot is exactly that.", + "SemaphoreSlim is not reentrant. StartRefreshTimer() calls StopRefreshTimer(), so if both take the semaphore, the first one waits on itself forever.", + "A common pattern: public methods that take the semaphore, and one private method that assumes it is already held.", + "StopRefreshTimer() runs from DisposeAsync() after the dispose token has been cancelled. If it waits with that token, cleanup never runs. Dispose the semaphore last, once nothing can wait on it again.", + ]; + + public override string TimeoutHint => "A timer method never returned. Check that every WaitAsync is matched by a Release in a finally block, and that no method waits on the semaphore while it already holds it."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + if (!DashboardHarness.HasMembers(report, "_refreshTimer", "StartRefreshTimer", "StopRefreshTimer")) + return; + + // Part 1: without a semaphore, calling the timer methods at the same time leaks live timers. A leaked timer that fires after DisposeAsync() + // throws inside an async void callback, and that would end this app, so nothing below runs them at the same time until the timer is guarded. + var semaphoreFields = DashboardHarness.FindSemaphoreFields(); + var semaphoreNames = string.Join(", ", semaphoreFields.Select(static field => field.Name)); + + report.Log(semaphoreFields.Count is 0 ? "Dashboard.razor.cs has no SemaphoreSlim field" : $"Found the SemaphoreSlim field {semaphoreNames}"); + report.Expect("Dashboard.razor.cs has a SemaphoreSlim field to guard _refreshTimer", "a SemaphoreSlim field", semaphoreFields.Count is 0 ? "none" : semaphoreNames, semaphoreFields.Count > 0, _noSemaphoreHint); + + if (semaphoreFields.Count is 0) + { + const string skipped = "Skipped: nothing guards the timer yet"; + + report.Expect($"{_concurrentCalls} StartRefreshTimer() and StopRefreshTimer() calls at the same time all finish", $"{_concurrentCalls} of {_concurrentCalls}", skipped, false, _skippedHint); + report.Expect("No refresh runs after StopRefreshTimer()", "0 quotes applied", skipped, false, _skippedHint); + report.Expect("DisposeAsync() finishes and disposes the semaphore", "finished, semaphore disposed", skipped, false, _skippedHint); + return; + } + + // Part 2: a fresh dashboard. OnInitializedAsync() starts the timer, so a method that waits on itself never lets the page finish loading. + await using var harness = await DashboardHarness.Render().ConfigureAwait(false); + + if (!await harness.Load(report, token).ConfigureAwait(false)) + return; + + var semaphores = harness.GetSemaphores(); + var freeSemaphores = semaphores.Count(static semaphore => semaphore.CurrentCount > 0); + + report.Expect("Once the page has loaded, the semaphore is free again", "free", freeSemaphores == semaphores.Count ? "free" : "still held", freeSemaphores == semaphores.Count, _releaseHint); + + if (freeSemaphores != semaphores.Count) + { + harness.LeaveRunning(); + return; + } + + // Part 3: with the timer already running, StartRefreshTimer() has to stop it first. That is where a guarded method calling another guarded method waits on itself. + var restartOutcome = await DashboardHarness.Describe(harness.StartRefreshTimer(), _callTimeout, token).ConfigureAwait(false); + report.Log($"StartRefreshTimer() with the timer already running {restartOutcome}"); + + report.Expect( + "StartRefreshTimer() replaces a running timer within 1 second", + DashboardHarness.Finished, + restartOutcome, + restartOutcome is DashboardHarness.Finished, + restartOutcome.StartsWith("still running", StringComparison.Ordinal) ? _deadlockHint : "StartRefreshTimer() has to dispose the running timer and create a new one without throwing."); + + if (restartOutcome is not DashboardHarness.Finished) + { + harness.LeaveRunning(); + return; + } + + // Part 4: this step takes the semaphore itself, then calls each timer method. A guarded method has to wait until the step lets go. + var startWhileHeld = await CallWhileHoldingSemaphores(semaphores, harness.StartRefreshTimer, token).ConfigureAwait(false); + report.Log($"While this step held the semaphore, StartRefreshTimer() {startWhileHeld}"); + report.Expect("StartRefreshTimer() waits for the semaphore before it touches _refreshTimer", _waited, startWhileHeld, startWhileHeld is _waited, WaitHint("StartRefreshTimer()", startWhileHeld)); + + var stopWhileHeld = await CallWhileHoldingSemaphores(semaphores, harness.StopRefreshTimer, token).ConfigureAwait(false); + report.Log($"While this step held the semaphore, StopRefreshTimer() {stopWhileHeld}"); + report.Expect("StopRefreshTimer() waits for the semaphore before it touches _refreshTimer", _waited, stopWhileHeld, stopWhileHeld is _waited, WaitHint("StopRefreshTimer()", stopWhileHeld)); + + if (startWhileHeld is not _waited || stopWhileHeld is not _waited) + { + // A method that did not wait ran on its own, so nothing leaked. One that threw or is still waiting left the timer in an unknown state. + if (startWhileHeld is not (_waited or _didNotWait or _blocked) || stopWhileHeld is not (_waited or _didNotWait or _blocked)) + { + harness.LeaveRunning(); + } + + return; + } + + // Part 5: both methods are guarded, so now they can safely run at the same time + report.Log($"Calling StartRefreshTimer() and StopRefreshTimer() {_concurrentCalls} times at the same time"); + + var calls = Enumerable.Range(0, _concurrentCalls) + .Select(call => Task.Run(() => call % 2 is 0 ? harness.StartRefreshTimer() : harness.StopRefreshTimer(), token)) + .ToList(); + + var callsOutcome = await DashboardHarness.Describe(Task.WhenAll(calls), TimeSpan.FromSeconds(10), token).ConfigureAwait(false); + var finishedCalls = calls.Count(static call => call.IsCompletedSuccessfully); + var failedCalls = calls.Count(static call => call.IsFaulted || call.IsCanceled); + + report.Log($"{finishedCalls} calls finished and {failedCalls} threw"); + + report.Expect( + $"{_concurrentCalls} StartRefreshTimer() and StopRefreshTimer() calls at the same time all finish", + $"{_concurrentCalls} of {_concurrentCalls}", + failedCalls > 0 ? $"{finishedCalls} of {_concurrentCalls}, {failedCalls} threw" : $"{finishedCalls} of {_concurrentCalls}", + finishedCalls is _concurrentCalls, + callsOutcome.StartsWith("still running", StringComparison.Ordinal) + ? $"Some calls never finished. {_releaseHint}" + : "Some calls threw. Every read and write of _refreshTimer has to happen while the semaphore is held, so no call can find a timer that another call already disposed or set to null."); + + if (finishedCalls is not _concurrentCalls) + { + harness.LeaveRunning(); + return; + } + + // Part 6: one last stop, the way DisposeAsync() stops the timer when a tab closes + var finalStopOutcome = await DashboardHarness.Describe(harness.StopRefreshTimer(), _callTimeout, token).ConfigureAwait(false); + var timerAfterStop = harness.GetField("_refreshTimer"); + + report.Expect( + "After a final StopRefreshTimer(), _refreshTimer is null", + "null", + finalStopOutcome is not DashboardHarness.Finished ? $"StopRefreshTimer() {finalStopOutcome}" : timerAfterStop is null ? "null" : "a Timer", + finalStopOutcome is DashboardHarness.Finished && timerAfterStop is null, + "StopRefreshTimer() has to dispose the timer and set _refreshTimer back to null while it holds the semaphore."); + + if (finalStopOutcome is not DashboardHarness.Finished || timerAfterStop is not null) + { + harness.LeaveRunning(); + return; + } + + // Part 7: a timer that leaked during those calls is still ticking, and every tick applies another 60 quotes + await Task.Delay(TimeSpan.FromMilliseconds(250), token).ConfigureAwait(false); + + var countAfterStop = harness.Dashboard.RefreshCount; + report.Log($"Waiting {_tickWait.TotalSeconds} seconds, longer than the timer's 2 second period, to see whether any timer is still running"); + + await Task.Delay(_tickWait, token).ConfigureAwait(false); + + var quotesAfterStop = harness.Dashboard.RefreshCount - countAfterStop; + report.Expect("No refresh runs after StopRefreshTimer()", "0 quotes applied", $"{quotesAfterStop:N0} quotes applied", quotesAfterStop is 0, _leakHint); + + if (quotesAfterStop is not 0) + { + // Disposing this page would dispose what the leaked timer's callback still reads, and its next tick would end the app + harness.LeaveRunning(); + report.Log("Left this dashboard running instead of disposing it, because a leaked timer is still using it"); + return; + } + + // Part 8: close the tab. DisposeAsync() runs after the dispose token is cancelled, and has to clean up the semaphore too. + var disposeOutcome = await harness.DisposeDashboard(token).ConfigureAwait(false); + var disposedSemaphores = semaphores.Count(IsDisposed); + var semaphoresDisposed = disposedSemaphores == semaphores.Count; + + report.Log($"DisposeAsync() {disposeOutcome}"); + + report.Expect( + "DisposeAsync() finishes and disposes the semaphore", + "finished, semaphore disposed", + disposeOutcome is not DashboardHarness.Finished ? $"DisposeAsync() {disposeOutcome}" : semaphoresDisposed ? "finished, semaphore disposed" : "finished, semaphore not disposed", + disposeOutcome is DashboardHarness.Finished && semaphoresDisposed, + DisposeHint(disposeOutcome)); + } + + // Takes every semaphore, calls the method, and describes what it did before and after the semaphores were released + static async Task CallWhileHoldingSemaphores(IReadOnlyList semaphores, Func call, CancellationToken token) + { + var heldSemaphores = new List(); + Task invocation; + bool returnedWhileHeld; + bool finishedWhileHeld; + + try + { + foreach (var semaphore in semaphores) + { + if (semaphore.Wait(0, token)) + heldSemaphores.Add(semaphore); + } + + // A thread of its own, so a method that blocks while it waits cannot block this step + invocation = Task.Factory.StartNew(call, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default); + + await Task.Delay(_holdTime, token).ConfigureAwait(false); + + returnedWhileHeld = invocation.IsCompleted; + finishedWhileHeld = returnedWhileHeld && invocation.Result.IsCompleted; + } + finally + { + heldSemaphores.ForEach(static semaphore => semaphore.Release()); + } + + var outcome = await DashboardHarness.Describe(invocation.Unwrap(), _callTimeout, token).ConfigureAwait(false); + + if (outcome is not DashboardHarness.Finished) + return outcome.StartsWith("still running", StringComparison.Ordinal) ? _stillWaiting : outcome; + + if (finishedWhileHeld) + return _didNotWait; + + return returnedWhileHeld ? _waited : _blocked; + } + + static string WaitHint(string methodName, string outcome) => outcome switch + { + _didNotWait => $"{methodName} read or wrote _refreshTimer while another caller held the semaphore. Wait on the semaphore before touching the timer, and release it in a finally block.", + _blocked => $"{methodName} blocked a thread until the semaphore was free. Wait with await WaitAsync(...) instead, so the thread goes back to the pool while it waits.", + _stillWaiting => $"{methodName} never got the semaphore after it was released. {_deadlockHint}", + _ => $"{methodName} has to wait for the semaphore, then touch the timer without throwing.", + }; + + static string DisposeHint(string outcome) => outcome switch + { + DashboardHarness.Finished => "SemaphoreSlim owns a wait handle. Dispose it in DisposeAsync(), once the timer has stopped and nothing can wait on the semaphore again.", + "threw OperationCanceledException" or "threw TaskCanceledException" => "DisposeAsync() cancels the dispose token before it stops the timer, so a StopRefreshTimer() that waits with that token throws instead of cleaning up. Cleanup still has to run.", + "threw ObjectDisposedException" => "Something used the semaphore or the CancellationTokenSource after it was disposed. Dispose them last, after the timer has stopped.", + _ => $"DisposeAsync() has to stop the timer and dispose what it owns. {_releaseHint}", + }; + + // SemaphoreSlim has no IsDisposed property, but every wait on a disposed one throws + static bool IsDisposed(SemaphoreSlim semaphore) + { + try + { + if (semaphore.Wait(0)) + semaphore.Release(); + + return false; + } + catch (ObjectDisposedException) + { + return true; + } + } +} \ No newline at end of file diff --git a/8. Concurrent Collections/2. Finish/StockWatch/Steps/StepContext.cs b/8. Concurrent Collections/2. Finish/StockWatch/Steps/StepContext.cs new file mode 100644 index 0000000..345d282 --- /dev/null +++ b/8. Concurrent Collections/2. Finish/StockWatch/Steps/StepContext.cs @@ -0,0 +1,4 @@ +namespace StockWatch; + +// Token is cancelled when the run is stopped, times out, or the app shuts down +public sealed record StepContext(StepReport Report, CancellationToken Token); \ No newline at end of file diff --git a/8. Concurrent Collections/2. Finish/StockWatch/Steps/WorkshopStep.cs b/8. Concurrent Collections/2. Finish/StockWatch/Steps/WorkshopStep.cs new file mode 100644 index 0000000..f4ef153 --- /dev/null +++ b/8. Concurrent Collections/2. Finish/StockWatch/Steps/WorkshopStep.cs @@ -0,0 +1,37 @@ +namespace StockWatch; + +// Every step is one bug in the Dashboard page's code-behind. +// Its tab in the workshop guide tells you what to fix, and Run() drives your code the way a busy market does, logs what happens, +// and records an expected result, with a hint, for everything that should be true once the bug is fixed. +public abstract class WorkshopStep +{ + public abstract int Number { get; } + + // What this step fixes, in a few words + public abstract string Scenario { get; } + + // The members this step exercises + public abstract string Title { get; } + + // What goes wrong in production, and why nobody noticed on a quiet day + public abstract string Story { get; } + + // How to watch this bug happen on the Dashboard page + public abstract string SeeItInTheApp { get; } + + // Where the bug lives + public abstract string FileToChange { get; } + + // What this step asks you to change. Some of it cannot be measured from outside, so not every task has an expected result. + public abstract IReadOnlyList Tasks { get; } + + // Shown only when you ask for them + public abstract IReadOnlyList Clues { get; } + + // What is most likely waiting forever when this step does not finish in time + public abstract string TimeoutHint { get; } + + public string SourceFile => $"Steps/{GetType().Name}.cs"; + + public abstract Task Run(StepContext context); +} \ No newline at end of file diff --git a/8. Concurrent Collections/2. Finish/StockWatch/Verification/ActiveStepRun.cs b/8. Concurrent Collections/2. Finish/StockWatch/Verification/ActiveStepRun.cs new file mode 100644 index 0000000..6a79edd --- /dev/null +++ b/8. Concurrent Collections/2. Finish/StockWatch/Verification/ActiveStepRun.cs @@ -0,0 +1,3 @@ +namespace StockWatch; + +public sealed record ActiveStepRun(WorkshopStep Step, StepReport Report); \ No newline at end of file diff --git a/8. Concurrent Collections/2. Finish/StockWatch/Verification/CodeInspector.cs b/8. Concurrent Collections/2. Finish/StockWatch/Verification/CodeInspector.cs new file mode 100644 index 0000000..232b4e1 --- /dev/null +++ b/8. Concurrent Collections/2. Finish/StockWatch/Verification/CodeInspector.cs @@ -0,0 +1,91 @@ +using System.Reflection; +using System.Reflection.Emit; + +namespace StockWatch; + +// Workshop plumbing: lists the methods a member of a type calls, including inside its lambdas and async state machines. +// A few requirements have nothing a check can observe from outside, such as Volatile.Read in a getter, so those checks read your compiled code instead. +public static class CodeInspector +{ + const BindingFlags _declaredMembers = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + + // Every IL opcode by its value, so the method body can be walked one instruction at a time + static readonly Dictionary _opCodes = typeof(OpCodes) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Select(static field => field.GetValue(null)) + .OfType() + .ToDictionary(static opCode => opCode.Value); + + // Returns names such as "Interlocked.Increment" for every call memberName makes, in Debug and in Release builds. + // The compiler names a lambda b__0 and its state machine <b__0>d, so those count as part of RefreshQuotes. + public static IReadOnlySet GetCalledMethods(Type type, string memberName) + { + var calledMethods = new HashSet(StringComparer.Ordinal); + + AddCalledMethods(type, memberName, false, calledMethods); + + return calledMethods; + } + + static void AddCalledMethods(Type type, string memberName, bool isPartOfMember, HashSet calledMethods) + { + foreach (var method in type.GetMethods(_declaredMembers).Concat(type.GetConstructors(_declaredMembers))) + { + if (isPartOfMember || IsPartOf(method.Name, memberName)) + AddCalls(method, calledMethods); + } + + foreach (var nestedType in type.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic)) + { + AddCalledMethods(nestedType, memberName, isPartOfMember || IsPartOf(nestedType.Name, memberName), calledMethods); + } + } + + static void AddCalls(MethodBase method, HashSet calledMethods) + { + if (method.GetMethodBody()?.GetILAsByteArray() is not { } il) + return; + + var typeArguments = method.DeclaringType is { IsGenericType: true } declaringType ? declaringType.GetGenericArguments() : null; + var methodArguments = method.IsGenericMethod ? method.GetGenericArguments() : null; + + var offset = 0; + + while (offset < il.Length) + { + // Two-byte opcodes start with 0xFE + var value = (short)il[offset++]; + + if (value is 0xFE && offset < il.Length) + value = unchecked((short)(0xFE00 | il[offset++])); + + if (!_opCodes.TryGetValue(value, out var opCode)) + return; + + if (opCode.OperandType is OperandType.InlineMethod) + { + try + { + if (method.Module.ResolveMethod(BitConverter.ToInt32(il, offset), typeArguments, methodArguments) is { } calledMethod) + calledMethods.Add($"{calledMethod.DeclaringType?.Name}.{calledMethod.Name}"); + } + catch (ArgumentException) + { + // A token this module cannot resolve is not a call the steps look for + } + } + + offset += opCode.OperandType switch + { + OperandType.InlineNone => 0, + OperandType.ShortInlineBrTarget or OperandType.ShortInlineI or OperandType.ShortInlineVar => 1, + OperandType.InlineVar => 2, + OperandType.InlineI8 or OperandType.InlineR => 8, + OperandType.InlineSwitch => 4 + (BitConverter.ToInt32(il, offset) * 4), + _ => 4, + }; + } + } + + static bool IsPartOf(string name, string memberName) => name == memberName || name.Contains($"<{memberName}>", StringComparison.Ordinal); +} \ No newline at end of file diff --git a/8. Concurrent Collections/2. Finish/StockWatch/Verification/DashboardHarness.cs b/8. Concurrent Collections/2. Finish/StockWatch/Verification/DashboardHarness.cs new file mode 100644 index 0000000..560bb77 --- /dev/null +++ b/8. Concurrent Collections/2. Finish/StockWatch/Verification/DashboardHarness.cs @@ -0,0 +1,229 @@ +using System.Diagnostics; +using System.Reflection; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using StockWatch.Components.Pages; + +namespace StockWatch; + +// Workshop plumbing: renders a fresh Dashboard with its own MarketDataService, the way a new browser tab does, +// and reaches the private members the steps name through reflection. They are private on purpose, so keep their names. +public sealed class DashboardHarness : IAsyncDisposable +{ + public const int SymbolCount = 60; + public const string Finished = "finished"; + + const BindingFlags _instanceMembers = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + + const string _semaphoreHint = "If you added a semaphore, check that every WaitAsync has a Release in a finally block, and that no method waits on the semaphore while it already holds it."; + + // Loading fetches 60 quotes that each take under 60 milliseconds, so these only run out when something is stuck + static readonly TimeSpan _loadTimeout = TimeSpan.FromSeconds(5); + static readonly TimeSpan _callTimeout = TimeSpan.FromSeconds(2); + + readonly ServiceProvider _serviceProvider; + readonly HtmlRenderer _renderer; + readonly Task _loaded; + + bool _isDisposed; + bool _isLeftRunning; + + DashboardHarness(ServiceProvider serviceProvider, HtmlRenderer renderer, DashboardProbe dashboard, Task loaded) + { + _serviceProvider = serviceProvider; + _renderer = renderer; + _loaded = loaded; + Dashboard = dashboard; + } + + public DashboardProbe Dashboard { get; } + + // The checks call private members by name, so a renamed one is reported instead of throwing + public static IReadOnlyList FindMissingMembers(params string[] names) => + [.. names.Where(static name => typeof(DashboardPageBase).GetMember(name, _instanceMembers).Length is 0)]; + + public static IReadOnlyList FindSemaphoreFields() => + [.. typeof(DashboardPageBase).GetFields(_instanceMembers).Where(static field => field.FieldType == typeof(SemaphoreSlim))]; + + public static Type? FindFieldType(string name) => typeof(DashboardPageBase).GetField(name, _instanceMembers)?.FieldType; + + // Records a failed expected result for every member that is missing, and returns false when there is one + public static bool HasMembers(StepReport report, params string[] names) + { + var missingMembers = FindMissingMembers(names); + + if (missingMembers.Count is 0) + return true; + + report.Expect( + "Dashboard.razor.cs still has every member this step calls", + string.Join(", ", names), + $"missing: {string.Join(", ", missingMembers)}", + false, + "The checks call these members by name. Keep their names and change what they do, the way the ToDo Refactor comments ask."); + + return false; + } + + // Renders the page the way Blazor Server does for a new browser tab, which starts OnInitializedAsync() + public static async Task Render() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(new MarketDataService()); + + var serviceProvider = services.BuildServiceProvider(); + var renderer = new HtmlRenderer(serviceProvider, serviceProvider.GetRequiredService()); + + DashboardProbe? dashboard = null; + + var parameters = ParameterView.FromDictionary(new Dictionary + { + [nameof(DashboardProbe.Initialized)] = (Action)(probe => dashboard = probe), + }); + + // Components run on the renderer's dispatcher, the same way they run on a circuit's in Blazor Server + var root = await renderer.Dispatcher.InvokeAsync(() => renderer.BeginRenderingComponent(parameters)).ConfigureAwait(false); + + return new DashboardHarness(serviceProvider, renderer, dashboard ?? throw new InvalidOperationException("The Dashboard page was not initialized"), root.QuiescenceTask); + } + + // Describes how a call ended: "finished", "threw ObjectDisposedException", or "still running after 2 seconds" + public static async Task Describe(Task call, TimeSpan timeout, CancellationToken token) + { + try + { + await call.WaitAsync(timeout, token).ConfigureAwait(false); + return Finished; + } + catch (TimeoutException) + { + return timeout.TotalSeconds is 1 ? "still running after 1 second" : $"still running after {timeout.TotalSeconds:0.#} seconds"; + } + catch (Exception e) when (!token.IsCancellationRequested) + { + // Only the exception type goes to the page + return $"threw {e.GetType().Name}"; + } + } + + // Waits for OnInitializedAsync() to finish, and records a failed expected result when it does not + public async Task Load(StepReport report, CancellationToken token) + { + var stopwatch = Stopwatch.StartNew(); + var outcome = await Describe(_loaded, _loadTimeout, token).ConfigureAwait(false); + + if (outcome is Finished) + { + report.Log($"A fresh dashboard loaded in {stopwatch.ElapsedMilliseconds} ms. OnInitializedAsync() applied the first round of quotes and started the refresh timer, and quotes applied reads {Dashboard.RefreshCount}"); + return true; + } + + // Something in the timer code is stuck or broken, so disposing the page could hang, or dispose what a running timer still uses + LeaveRunning(); + + report.Log($"OnInitializedAsync() {outcome}"); + report.Expect( + "A fresh dashboard finishes loading", + "finished within 5 seconds", + $"OnInitializedAsync() {outcome}", + false, + $"OnInitializedAsync() did not finish, so this page would never start refreshing. It awaits RefreshQuotes() and then StartRefreshTimer(). {_semaphoreHint}"); + + return false; + } + + // Steps 1 to 3 stop the timer once the page has loaded, so a tick 2 seconds in can never change what they measure + public async Task LoadAndStopTimer(StepReport report, CancellationToken token) + { + if (!await Load(report, token).ConfigureAwait(false)) + return false; + + var outcome = await Describe(StopRefreshTimer(), _callTimeout, token).ConfigureAwait(false); + + if (outcome is Finished) + { + report.Log("Stopped the refresh timer, so no timer tick changes the results below"); + return true; + } + + LeaveRunning(); + + report.Expect("StopRefreshTimer() stops the refresh timer", Finished, outcome, false, $"StopRefreshTimer() did not finish. {_semaphoreHint}"); + + return false; + } + + public object? GetField(string name) => typeof(DashboardPageBase).GetField(name, _instanceMembers)?.GetValue(Dashboard); + + public IReadOnlyList GetSemaphores() => [.. FindSemaphoreFields().Select(field => field.GetValue(Dashboard)).OfType()]; + + public Task RefreshQuotes(CancellationToken token) => Invoke("RefreshQuotes", [typeof(CancellationToken)], [token]); + + public Task StartRefreshTimer() => Invoke("StartRefreshTimer", Type.EmptyTypes, []); + + public Task StopRefreshTimer() => Invoke("StopRefreshTimer", Type.EmptyTypes, []); + + // Keeps the page from being disposed. A check calls this when a refresh timer might still be running with nothing to stop it: + // DisposeAsync() disposes the CancellationTokenSource that the timer's callback reads, and an exception in that async void callback would end the app. + // Cancelling that source without disposing it is safe, and it turns every later tick of a leaked timer into a refresh that stops before it fetches anything. + public void LeaveRunning() + { + _isLeftRunning = true; + + try + { + if (GetField("_disposeCancellationTokenSource") is CancellationTokenSource disposeCancellationTokenSource) + disposeCancellationTokenSource.Cancel(); + } + catch (ObjectDisposedException) + { + // The page already disposed it, so there is nothing left to cancel + } + } + + // Disposes the page the way Blazor Server does when a browser tab closes, which calls its DisposeAsync() + public async Task DisposeDashboard(CancellationToken token) + { + if (_isLeftRunning) + return "left running"; + + if (_isDisposed) + return Finished; + + _isDisposed = true; + + var outcome = await Describe(DisposeRenderer(), _callTimeout, token).ConfigureAwait(false); + + if (outcome is Finished) + await _serviceProvider.DisposeAsync().ConfigureAwait(false); + + return outcome; + } + + public async ValueTask DisposeAsync() + { + await DisposeDashboard(CancellationToken.None).ConfigureAwait(false); + } + + async Task DisposeRenderer() => await _renderer.DisposeAsync().ConfigureAwait(false); + + Task Invoke(string name, Type[] parameterTypes, object?[] arguments) + { + try + { + var method = typeof(DashboardPageBase).GetMethod(name, _instanceMembers, parameterTypes) ?? throw new MissingMethodException(nameof(DashboardPageBase), name); + + return method.Invoke(Dashboard, BindingFlags.DoNotWrapExceptions, null, arguments, null) switch + { + Task task => task, + ValueTask valueTask => valueTask.AsTask(), + _ => Task.CompletedTask, + }; + } + catch (Exception e) + { + return Task.FromException(e); + } + } +} \ No newline at end of file diff --git a/8. Concurrent Collections/2. Finish/StockWatch/Verification/DashboardProbe.cs b/8. Concurrent Collections/2. Finish/StockWatch/Verification/DashboardProbe.cs new file mode 100644 index 0000000..eee6c6a --- /dev/null +++ b/8. Concurrent Collections/2. Finish/StockWatch/Verification/DashboardProbe.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using StockWatch.Components.Pages; + +namespace StockWatch; + +// Workshop plumbing: the Dashboard page with your code-behind, rendered by the checks instead of a browser. +// It paints only the quotes applied count. In the browser, a render that throws swaps the dashboard for a Feed fault panel, +// but a check needs the exception itself, so the steps call Symbols directly, where an exception can be caught and reported. +public sealed class DashboardProbe : Dashboard +{ + // Hands the rendered instance to the check before OnInitializedAsync() starts loading quotes + [Parameter, EditorRequired] + public required Action Initialized { get; set; } + + protected override void OnInitialized() => Initialized(this); + + protected override void BuildRenderTree(RenderTreeBuilder builder) => builder.AddContent(0, RefreshCount); +} \ No newline at end of file diff --git a/8. Concurrent Collections/2. Finish/StockWatch/Verification/StepCheck.cs b/8. Concurrent Collections/2. Finish/StockWatch/Verification/StepCheck.cs new file mode 100644 index 0000000..02dd71d --- /dev/null +++ b/8. Concurrent Collections/2. Finish/StockWatch/Verification/StepCheck.cs @@ -0,0 +1,3 @@ +namespace StockWatch; + +public sealed record StepCheck(string Description, string Expected, string Actual, bool Passed, string Hint); \ No newline at end of file diff --git a/8. Concurrent Collections/2. Finish/StockWatch/Verification/StepLogEntry.cs b/8. Concurrent Collections/2. Finish/StockWatch/Verification/StepLogEntry.cs new file mode 100644 index 0000000..6f321dc --- /dev/null +++ b/8. Concurrent Collections/2. Finish/StockWatch/Verification/StepLogEntry.cs @@ -0,0 +1,3 @@ +namespace StockWatch; + +public sealed record StepLogEntry(TimeSpan Elapsed, int ThreadId, bool IsThreadPoolThread, string Message); \ No newline at end of file diff --git a/8. Concurrent Collections/2. Finish/StockWatch/Verification/StepReport.cs b/8. Concurrent Collections/2. Finish/StockWatch/Verification/StepReport.cs new file mode 100644 index 0000000..17ba493 --- /dev/null +++ b/8. Concurrent Collections/2. Finish/StockWatch/Verification/StepReport.cs @@ -0,0 +1,82 @@ +using System.Diagnostics; + +namespace StockWatch; + +// Collects what a step logged and every expected result it checked. +// Steps write to it from thread pool threads while the workshop guide reads it, so every member takes the lock. +public sealed class StepReport +{ + readonly Lock _lock = new(); + readonly Stopwatch _stopwatch = Stopwatch.StartNew(); + readonly List _entries = []; + readonly List _checks = []; + + bool _isComplete; + + public IReadOnlyList Entries + { + get + { + lock (_lock) + { + return [.. _entries]; + } + } + } + + public IReadOnlyList Checks + { + get + { + lock (_lock) + { + return [.. _checks]; + } + } + } + + public void Log(string message) + { + var threadId = Environment.CurrentManagedThreadId; + var isThreadPoolThread = Thread.CurrentThread.IsThreadPoolThread; + + lock (_lock) + { + if (!_isComplete) + _entries.Add(new StepLogEntry(_stopwatch.Elapsed, threadId, isThreadPoolThread, message)); + } + } + + // The hint is shown under the expected result only when it does not match + public void Expect(string description, T expected, T actual, string hint) + { + Expect(description, Format(expected), Format(actual), EqualityComparer.Default.Equals(expected, actual), hint); + } + + public void Expect(string description, string expected, string actual, bool passed, string hint) + { + lock (_lock) + { + if (!_isComplete) + _checks.Add(new StepCheck(description, expected, actual, passed, hint)); + } + } + + // Called when the run ends, including a run that timed out or was stopped while its step is still blocked somewhere. + // Anything that abandoned step writes later is ignored, so the result in the workshop guide can no longer change. + public void Complete() + { + lock (_lock) + { + _isComplete = true; + _stopwatch.Stop(); + } + } + + static string Format(T value) => value switch + { + null => "null", + int or long => $"{value:N0}", + _ => value.ToString() ?? "null", + }; +} \ No newline at end of file diff --git a/8. Concurrent Collections/2. Finish/StockWatch/Verification/StepState.cs b/8. Concurrent Collections/2. Finish/StockWatch/Verification/StepState.cs new file mode 100644 index 0000000..9a4e96b --- /dev/null +++ b/8. Concurrent Collections/2. Finish/StockWatch/Verification/StepState.cs @@ -0,0 +1,9 @@ +namespace StockWatch; + +// ExceptionType is set for Crashed. Only the name is kept: exception messages and stack traces stay in the terminal. +// CompletedAt is when the run that produced this state ended, so the workshop guide can show that a click really ran again. +public sealed record StepState( + StepStatus Status, + StepReport? Report, + string? ExceptionType = null, + DateTimeOffset? CompletedAt = null); \ No newline at end of file diff --git a/8. Concurrent Collections/2. Finish/StockWatch/Verification/StepStatus.cs b/8. Concurrent Collections/2. Finish/StockWatch/Verification/StepStatus.cs new file mode 100644 index 0000000..ff0cbbd --- /dev/null +++ b/8. Concurrent Collections/2. Finish/StockWatch/Verification/StepStatus.cs @@ -0,0 +1,12 @@ +namespace StockWatch; + +public enum StepStatus +{ + NotRun, + Running, + Passed, + Failed, + TimedOut, + Crashed, + Stopped, +} \ No newline at end of file diff --git a/8. Concurrent Collections/2. Finish/StockWatch/Verification/StepVerifier.cs b/8. Concurrent Collections/2. Finish/StockWatch/Verification/StepVerifier.cs new file mode 100644 index 0000000..2984cbf --- /dev/null +++ b/8. Concurrent Collections/2. Finish/StockWatch/Verification/StepVerifier.cs @@ -0,0 +1,231 @@ +namespace StockWatch; + +// Workshop plumbing: runs the steps against your Dashboard code and keeps their results. +// Every step renders its own Dashboard with its own MarketDataService, so a check never disturbs the page open in your browser. +// Only one step runs at a time, so a step that measures contention never competes with another run for thread pool threads. +public sealed class StepVerifier(ILogger logger, IHostApplicationLifetime applicationLifetime) : BackgroundService +{ + public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); + + readonly Lock _lock = new(); + readonly StepState[] _states = [.. Enumerable.Repeat(new StepState(StepStatus.NotRun, null), 4)]; + + // Both start at 1: the app checks every step on startup, and nothing else may run before that finishes + int _isBusy = 1; + int _isCheckingAllSteps = 1; + + ActiveStepRun? _activeRun; + TaskCompletionSource? _stopRequested; + + public IReadOnlyList Steps { get; } = + [ + new Step1ShowEveryCard(), + new Step2KeepEveryQuote(), + new Step3CountEveryQuote(), + new Step4GuardRefreshTimer(), + ]; + + public bool IsBusy => Volatile.Read(ref _isBusy) is 1; + + public bool IsCheckingAllSteps => Volatile.Read(ref _isCheckingAllSteps) is 1; + + public ActiveStepRun? ActiveRun + { + get + { + lock (_lock) + { + return _activeRun; + } + } + } + + public WorkshopStep? FindStep(int number) => Steps.FirstOrDefault(step => step.Number == number); + + public StepState GetState(int number) + { + lock (_lock) + { + return _states[number - 1]; + } + } + + public bool IsUnlocked(int number) => number is 1 || GetState(number - 1).Status is StepStatus.Passed; + + // Re-runs every step, the same way startup does + public async Task CheckAllSteps() + { + if (Interlocked.CompareExchange(ref _isBusy, 1, 0) is not 0) + return; + + Volatile.Write(ref _isCheckingAllSteps, 1); + + try + { + await CheckSteps(applicationLifetime.ApplicationStopping).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isCheckingAllSteps, 0); + Volatile.Write(ref _isBusy, 0); + } + } + + public async Task RunStep(WorkshopStep step) + { + if (!IsUnlocked(step.Number) || Interlocked.CompareExchange(ref _isBusy, 1, 0) is not 0) + return GetState(step.Number); + + try + { + return await Run(step, applicationLifetime.ApplicationStopping).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isBusy, 0); + } + } + + public void StopRun() + { + lock (_lock) + { + _stopRequested?.TrySetResult(); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await CheckSteps(stoppingToken).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isCheckingAllSteps, 0); + Volatile.Write(ref _isBusy, 0); + } + } + + async Task CheckSteps(CancellationToken token) + { + try + { + // Results from before the latest change to the code are not trusted + lock (_lock) + { + Array.Fill(_states, new StepState(StepStatus.NotRun, null)); + } + + logger.LogInformation("Checking your Dashboard code against every step. Open the app in your browser: the workshop guide beside the Dashboard page shows what to fix next"); + + foreach (var step in Steps) + { + var state = await Run(step, token).ConfigureAwait(false); + + if (state.Status is not StepStatus.Passed) + { + logger.LogInformation("Stopped checking at Step {StepNumber}. Open it in the app to see what to change, with a hint for every result that did not match. Later steps unlock after it passes", step.Number); + return; + } + } + + logger.LogInformation("Every step passed. Pause here for the group review"); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The app is shutting down + } + catch (Exception e) + { + logger.LogError(e, "Checking the steps failed unexpectedly"); + } + } + + async Task Run(WorkshopStep step, CancellationToken token) + { + var report = new StepReport(); + var stopRequested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var previousState = GetState(step.Number); + + lock (_lock) + { + _activeRun = new ActiveStepRun(step, report); + _stopRequested = stopRequested; + _states[step.Number - 1] = new StepState(StepStatus.Running, report); + } + + // Cancelled when the run ends for any reason, so a step that is still working can stop + using var runCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + + var state = previousState; + + try + { + state = await Execute(step, report, stopRequested.Task, runCancellationTokenSource.Token, token).ConfigureAwait(false); + } + finally + { + await runCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + report.Complete(); + + lock (_lock) + { + _activeRun = null; + _stopRequested = null; + _states[step.Number - 1] = state with { CompletedAt = DateTimeOffset.Now }; + } + } + + return state; + } + + async Task Execute(WorkshopStep step, StepReport report, Task stopRequested, CancellationToken runToken, CancellationToken token) + { + try + { + // A dedicated thread runs the step until its first await. If a deadlock blocks it forever, + // a thread pool thread is not lost for the rest of the app's life. + var stepTask = Task.Factory.StartNew(() => step.Run(new StepContext(report, runToken)), token, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); + + var finished = await Task.WhenAny(stepTask, stopRequested).WaitAsync(Timeout, token).ConfigureAwait(false); + + if (finished == stopRequested) + { + logger.LogInformation("Step {StepNumber} was stopped", step.Number); + return new StepState(StepStatus.Stopped, report); + } + + await stepTask.ConfigureAwait(false); + + var checks = report.Checks; + var passedChecks = checks.Count(static check => check.Passed); + var status = checks.Count > 0 && passedChecks == checks.Count ? StepStatus.Passed : StepStatus.Failed; + + logger.LogInformation("Step {StepNumber} {Result} ({PassedChecks}/{TotalChecks} checks)", step.Number, status is StepStatus.Passed ? "passed" : "failed", passedChecks, checks.Count); + + // The hints belong in the terminal too, next to anything the app logged while the step ran + foreach (var check in checks.Where(static check => !check.Passed)) + { + logger.LogWarning("Step {StepNumber}: {Description}. Expected {Expected}, actual {Actual}. Hint: {Hint}", step.Number, check.Description, check.Expected, check.Actual, check.Hint); + } + + return new StepState(status, report); + } + catch (TimeoutException) + { + logger.LogWarning("Step {StepNumber} did not finish within {Timeout}. {TimeoutHint}", step.Number, Timeout, step.TimeoutHint); + return new StepState(StepStatus.TimedOut, report); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + return new StepState(StepStatus.NotRun, null); + } + catch (Exception e) + { + logger.LogError(e, "Step {StepNumber} threw an unexpected exception", step.Number); + return new StepState(StepStatus.Crashed, report, e.GetType().Name); + } + } +} \ No newline at end of file diff --git a/8. Concurrent Collections/2. Finish/StockWatch/wwwroot/app.css b/8. Concurrent Collections/2. Finish/StockWatch/wwwroot/app.css index 08f9afa..7a521cd 100644 --- a/8. Concurrent Collections/2. Finish/StockWatch/wwwroot/app.css +++ b/8. Concurrent Collections/2. Finish/StockWatch/wwwroot/app.css @@ -1,19 +1,46 @@ +/* StockWatch: a market data terminal, with the workshop guide docked beside it as the incident that paged the on-call engineer */ + :root { - --canvas: #eef3f1; - --panel: #ffffff; - --panel-strong: #112a2d; - --ink: #18211f; - --muted: #62706d; - --line: #d7e0dd; - --accent: #f46a1f; - --teal: #0f766e; - --up: #0f8a52; - --down: #cf202f; - --shadow: 0 18px 55px rgb(28 45 42 / 14%); - color-scheme: light; - font-family: "Aptos", "Avenir Next", "Segoe UI", sans-serif; - background: var(--canvas); - color: var(--ink); + --term-bg: #05070a; + --term-bar: #000000; + --term-card: #0c1117; + --term-line: #1b232d; + --term-line-strong: #2b3643; + --term-ink: #d9e0e8; + --term-bright: #f4f7fa; + --term-muted: #8391a1; + --amber: #ffb000; + --amber-soft: #ffc54a; + --up: #2ee07f; + --down: #ff5566; + --flat: #a3adb8; + --sheet: #ffffff; + --canvas: #f3f4f6; + --line: #dde1e6; + --line-strong: #c3c9d1; + --ink: #15191e; + --ink-soft: #3d4651; + --muted: #5b6571; + --red: #d92d20; + --red-dark: #b42318; + --red-ink: #7a271a; + --red-tint: #fef3f2; + --red-line: #fdb4ab; + --good: #067647; + --good-ink: #05603a; + --good-tint: #ecfdf3; + --good-line: #a6e9c1; + --warn: #93370d; + --warn-accent: #dc6803; + --warn-tint: #fffaeb; + --warn-line: #fedf89; + --mono: ui-monospace, "SF Mono", "Cascadia Mono", "Cascadia Code", Menlo, Consolas, "Liberation Mono", monospace; + --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif; + --guide-width: 460px; + color-scheme: dark; + font-family: var(--sans); + background: var(--term-bg); + color: var(--term-ink); } * { @@ -23,150 +50,1172 @@ body { margin: 0; overflow-x: clip; - background: - linear-gradient(135deg, rgb(15 118 110 / 14%), transparent 32%), - radial-gradient(circle at top right, rgb(244 106 31 / 16%), transparent 30%), - var(--canvas); + background: var(--term-bg); +} + +/* FocusOnNavigate moves focus to the page heading for screen readers; the heading is not interactive, so hide the ring */ +h1:focus { + outline: none; } -.shell { +code { + font-family: var(--mono); + font-size: 0.88em; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +/* The app and the guide share the screen */ + +.workspace { + display: flex; min-height: 100vh; } -.dashboard { - width: min(1120px, calc(100% - 32px)); - margin: 0 auto; - padding: 32px 0 56px; +.app-pane { + flex: 1 1 auto; + min-width: 0; } -.hero-panel { - display: grid; - grid-template-columns: 1fr auto; - gap: 28px; - align-items: end; - margin-bottom: 22px; - padding: 30px; - border: 1px solid rgb(36 35 31 / 10%); - border-radius: 8px; - background: linear-gradient(135deg, var(--panel-strong), #15463e 58%, #6f3b17); - box-shadow: var(--shadow); - color: #f8fffc; +/* Buttons */ + +button.action { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 10px 18px; + border: 1px solid #1d2939; + border-radius: 6px; + background: #1d2939; + color: #fff; + font: inherit; + font-weight: 600; + cursor: pointer; } -.hero-copy { - max-width: 680px; +button.action:hover:not(:disabled) { + border-color: #0c111d; + background: #0c111d; } -.eyebrow { - margin: 0 0 6px; +button.action:disabled { + border-color: #98a2b3; + background: #98a2b3; + cursor: not-allowed; +} + +button.action.secondary { + border-color: var(--line-strong); + background: var(--sheet); + color: var(--ink); +} + +button.action.secondary:hover:not(:disabled) { + border-color: var(--ink-soft); + background: var(--canvas); +} + +button.action.small { + padding: 6px 12px; + font-size: 0.86rem; +} + +.spinner { + display: inline-block; + width: 14px; + height: 14px; + margin-right: 8px; + border: 2px solid rgb(255 255 255 / 35%); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.banner .spinner { + border-color: rgb(147 55 13 / 25%); + border-top-color: var(--warn-accent); + vertical-align: -2px; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* The terminal */ + +.terminal { + min-height: 100vh; + background: + repeating-linear-gradient(0deg, rgb(255 255 255 / 1.2%) 0 1px, transparent 1px 3px), + var(--term-bg); +} + +.term-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 18px; + padding: 9px 24px; + border-bottom: 1px solid var(--term-line-strong); + background: var(--term-bar); + font-family: var(--mono); font-size: 0.78rem; - font-weight: 600; - letter-spacing: 0.12em; + letter-spacing: 0.06em; text-transform: uppercase; - color: rgb(248 255 252 / 72%); } -.hero-panel h1 { - margin: 0 0 10px; - font-size: clamp(1.9rem, 3vw, 2.6rem); - line-height: 1.1; +.term-brand { + color: var(--amber); + font-weight: 800; + letter-spacing: 0.16em; +} + +.term-keys { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.term-key { + padding: 3px 8px; + border: 1px solid var(--term-line-strong); + border-radius: 2px; + background: #10161d; + color: var(--term-muted); +} + +.term-key.current { + border-color: var(--amber); + background: var(--amber); + color: #140d00; + font-weight: 700; +} + +.term-live { + display: inline-flex; + align-items: center; + gap: 8px; + margin-left: auto; + color: var(--up); +} + +.live-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--up); + animation: live 1.6s ease-out infinite; } -.hero-summary { +@keyframes live { + from { + box-shadow: 0 0 0 0 rgb(46 224 127 / 60%); + } + + to { + box-shadow: 0 0 0 8px rgb(46 224 127 / 0%); + } +} + +.term-head { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: end; + gap: 16px 24px; + padding: 22px 24px 18px; +} + +.term-eyebrow { + margin: 0 0 6px; + color: var(--term-muted); + font-family: var(--mono); + font-size: 0.74rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.term-head h1 { margin: 0; - color: rgb(248 255 252 / 80%); + color: var(--amber); + font-family: var(--mono); + font-size: clamp(1.45rem, 2.4vw, 2rem); + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.term-summary { + margin: 6px 0 0; + color: var(--term-ink); + font-size: 0.92rem; } .status-tile { display: grid; - gap: 4px; justify-items: end; - padding: 14px 18px; - border: 1px solid rgb(248 255 252 / 22%); - border-radius: 8px; - background: rgb(248 255 252 / 10%); + gap: 4px; + min-width: 180px; + padding: 10px 16px; + border: 1px solid rgb(255 176 0 / 50%); + border-radius: 2px; + background: #0f0b02; + font-family: var(--mono); } .status-value { - font-size: 1.9rem; + color: var(--amber-soft); + font-size: 2rem; font-weight: 700; + font-variant-numeric: tabular-nums; line-height: 1; } .status-label { - font-size: 0.82rem; - color: rgb(248 255 252 / 78%); + color: var(--term-muted); + font-size: 0.72rem; + letter-spacing: 0.12em; + text-transform: uppercase; } +/* The ticker tape: the board's symbols twice over, so sliding the strip left by half loops without a seam */ + +.tape { + overflow: hidden; + border-block: 1px solid var(--term-line-strong); + background: var(--term-bar); + mask-image: linear-gradient(90deg, transparent, #000 48px, #000 calc(100% - 48px), transparent); +} + +.tape-track { + display: flex; + width: max-content; + animation: tape 150s linear infinite; +} + +.tape-item { + display: inline-flex; + align-items: baseline; + gap: 8px; + padding: 8px 18px; + border-right: 1px solid var(--term-line); + font-family: var(--mono); + font-size: 0.8rem; + white-space: nowrap; +} + +.tape-symbol { + color: var(--amber); + font-weight: 700; +} + +.tape-price { + color: var(--term-bright); + font-variant-numeric: tabular-nums; +} + +.tape-change { + color: var(--flat); + font-variant-numeric: tabular-nums; +} + +@keyframes tape { + to { + transform: translateX(-50%); + } +} + +/* The quote board */ + .ticker-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); - gap: 14px; + grid-template-columns: repeat(auto-fill, minmax(164px, 1fr)); + gap: 6px; + padding: 16px 24px 40px; } .ticker-card { display: grid; - gap: 6px; - padding: 16px 18px; - border: 1px solid var(--line); - border-left: 4px solid var(--muted); - border-radius: 8px; - background: var(--panel); - box-shadow: 0 10px 26px rgb(28 45 42 / 8%); + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + "symbol change" + "company company" + "price price"; + gap: 2px 8px; + padding: 9px 11px 10px; + border: 1px solid var(--term-line); + border-left: 3px solid var(--term-line-strong); + border-radius: 2px; + background: var(--term-card); + font-family: var(--mono); } .ticker-card.trend-up { border-left-color: var(--up); + background: linear-gradient(90deg, rgb(46 224 127 / 7%), transparent 55%), var(--term-card); } .ticker-card.trend-down { border-left-color: var(--down); + background: linear-gradient(90deg, rgb(255 85 102 / 8%), transparent 55%), var(--term-card); } +/* The symbol and the company name sit in the card's own grid */ .ticker-heading { - display: grid; - gap: 2px; + display: contents; } .ticker-symbol { - font-size: 1.05rem; + grid-area: symbol; + color: var(--term-bright); + font-size: 0.92rem; font-weight: 700; - letter-spacing: 0.02em; + letter-spacing: 0.04em; } .ticker-company { - font-size: 0.8rem; - color: var(--muted); + grid-area: company; + overflow: hidden; + color: var(--term-muted); + font-size: 0.7rem; + text-overflow: ellipsis; + white-space: nowrap; } .ticker-price { - font-size: 1.5rem; - font-weight: 600; + grid-area: price; + margin-top: 6px; + color: var(--term-bright); + font-size: 1.22rem; + font-weight: 700; font-variant-numeric: tabular-nums; } .ticker-change { - font-size: 0.9rem; - font-weight: 600; + grid-area: change; + align-self: start; + padding: 1px 5px; + border-radius: 2px; + color: var(--flat); + font-size: 0.76rem; font-variant-numeric: tabular-nums; - color: var(--muted); + white-space: nowrap; } +.trend-up .tape-change, .trend-up .ticker-change { color: var(--up); } +.trend-down .tape-change, +.trend-down .ticker-change { + color: var(--down); +} + +.trend-up .ticker-change { + background: rgb(46 224 127 / 12%); +} + .trend-down .ticker-change { + background: rgb(255 85 102 / 14%); +} + +.trend-up .tape-change::before, +.trend-up .ticker-change::before { + content: "▲ "; +} + +.trend-down .tape-change::before, +.trend-down .ticker-change::before { + content: "▼ "; +} + +/* The layout shows this in place of the dashboard when the page throws */ +.feed-fault { + margin: 24px; + padding: 18px 20px; + border: 1px solid rgb(255 85 102 / 55%); + border-left: 6px solid var(--down); + border-radius: 2px; + background: rgb(255 85 102 / 9%); + color: var(--term-bright); + font-family: var(--mono); + line-height: 1.55; +} + +.feed-fault p { + margin: 0; +} + +.feed-fault .feed-fault-code { + margin-bottom: 6px; color: var(--down); + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; } -@media (max-width: 640px) { - .hero-panel { - grid-template-columns: 1fr; - align-items: start; +.feed-fault-code::before { + content: "■ "; +} + +@media (prefers-reduced-motion: reduce) { + .tape-track, + .live-dot { + animation: none; + } +} + +/* The workshop guide: the incident that paged the on-call engineer, pinned beside the terminal */ + +.guide { + position: sticky; + top: 0; + flex: 0 0 var(--guide-width); + height: 100vh; + overflow-y: auto; + border-left: 1px solid #000; + background: var(--canvas); + box-shadow: -18px 0 44px rgb(0 0 0 / 45%); + color: var(--ink); + color-scheme: light; + font-family: var(--sans); +} + +/* The severity strip stays pinned while the incident scrolls */ +.guide::before { + content: "● SEV-2 · Triggered · StockWatch market data"; + content: "● SEV-2 · Triggered · StockWatch market data" / ""; + position: sticky; + top: 0; + z-index: 1; + display: block; + padding: 7px 22px; + background: var(--red); + color: #fff; + font-family: var(--mono); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +/* The incident moves on as the steps pass: mitigating once one passes, resolved once every one does */ +.guide:not(.collapsed):has(.progress-segment.done)::before { + content: "● SEV-2 · Mitigating · StockWatch market data"; + content: "● SEV-2 · Mitigating · StockWatch market data" / ""; +} + +.guide:not(.collapsed):not(:has(.progress-segment:not(.done)))::before { + content: "✓ Resolved · StockWatch market data"; + content: "✓ Resolved · StockWatch market data" / ""; + background: var(--good); +} + +.guide.collapsed { + flex-basis: 76px; + display: grid; + align-content: start; + justify-items: center; + gap: 14px; + padding: 0 0 14px; +} + +.guide.collapsed::before { + content: "SEV-2"; + content: "SEV-2" / ""; + justify-self: stretch; + padding: 6px 0; + letter-spacing: 0.04em; + text-align: center; +} + +.guide.collapsed:not(:has(.badge:not(.badge-passed)))::before { + content: "Resolved"; + content: "Resolved" / ""; + background: var(--good); +} + +.guide-header { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 18px 22px 14px; + background: var(--sheet); +} + +.guide-eyebrow { + margin: 0 0 4px; + color: var(--red-dark); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.guide-title { + margin: 0; + font-size: 1.3rem; + font-weight: 700; + line-height: 1.25; + letter-spacing: -0.01em; +} + +.guide-subtitle { + margin: 8px 0 0; + color: var(--muted); + font-size: 0.88rem; + line-height: 1.45; +} + +.guide-incident { + display: inline-block; + margin-right: 6px; + padding: 0 6px; + border: 1px solid var(--line-strong); + border-radius: 4px; + background: var(--canvas); + color: var(--ink); + font-family: var(--mono); + font-size: 0.8rem; + font-weight: 600; +} + +.guide-toggle { + flex: none; + align-self: start; + display: grid; + justify-items: center; + gap: 8px; + min-width: 36px; + padding: 6px 8px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--sheet); + color: var(--ink); + font: inherit; + font-size: 1.1rem; + font-weight: 700; + cursor: pointer; +} + +.guide-toggle:hover { + border-color: var(--red); + color: var(--red-dark); +} + +.guide-rail-label { + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + writing-mode: vertical-rl; +} + +.guide-rail { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.guide-rail-step { + display: grid; + justify-items: center; + gap: 2px; + width: 52px; + padding: 6px 0; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--sheet); + color: var(--ink); + font: inherit; + cursor: pointer; +} + +.guide-rail-step:hover { + border-color: var(--line-strong); +} + +.guide-rail-number { + font-family: var(--mono); + font-weight: 700; +} + +.guide-progress { + display: grid; + gap: 10px; + padding: 0 22px 16px; + border-bottom: 1px solid var(--line); + background: var(--sheet); +} + +.progress-meter { + display: flex; + gap: 4px; +} + +.progress-segment { + flex: 1; + height: 6px; + border-radius: 2px; + background: #e1e5ea; +} + +.progress-segment.done { + background: var(--good); +} + +.guide-progress-row { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.guide-progress-count { + margin: 0; + color: var(--muted); +} + +.guide-progress-count strong { + color: var(--ink); + font-size: 1.05rem; +} + +/* Your challenge, then every step as a remediation item */ +.guide-tabs { + display: grid; + border-bottom: 1px solid var(--line); + background: var(--sheet); +} + +.guide-tab { + display: grid; + grid-template-columns: 28px 1fr auto; + gap: 10px; + align-items: center; + padding: 10px 22px 10px 19px; + border: 0; + border-left: 3px solid transparent; + border-top: 1px solid #edf0f3; + background: transparent; + color: var(--ink); + font: inherit; + font-size: 0.92rem; + text-align: left; + cursor: pointer; +} + +.guide-tab:first-child { + border-top: 0; +} + +.guide-tab:hover { + background: #f8f9fb; +} + +.guide-tab-number { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border: 1px solid var(--line-strong); + border-radius: 4px; + background: var(--sheet); + color: var(--ink-soft); + font-family: var(--mono); + font-size: 0.82rem; + font-weight: 700; +} + +.guide-tab:has(.badge-failed) .guide-tab-number { + border-color: var(--red-line); + background: var(--red-tint); + color: var(--red-dark); +} + +.guide-tab:has(.badge-passed) .guide-tab-number { + border-color: var(--good-line); + background: var(--good-tint); + color: var(--good); +} + +.guide-tab.selected { + border-left-color: var(--red); + background: #f6f7f9; + font-weight: 600; +} + +.guide-tab.selected .guide-tab-number { + border-color: var(--ink); + background: var(--ink); + color: #fff; +} + +.guide-body { + display: grid; + gap: 12px; + padding: 16px 22px 40px; +} + +.guide-section { + padding: 14px 16px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--sheet); +} + +.guide-section h3, +.guide-section h4 { + margin: 0 0 8px; + color: var(--ink-soft); + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.guide-section p { + margin: 0 0 10px; + line-height: 1.55; +} + +.guide-section p:last-child { + margin-bottom: 0; +} + +.guide-note { + color: var(--muted); + font-size: 0.88rem; +} + +.step-header { + display: grid; + gap: 10px; + padding: 14px 16px; + border: 1px solid var(--line); + border-top: 3px solid var(--red); + border-radius: 8px; + background: var(--sheet); +} + +.step-eyebrow { + margin: 0; + color: var(--red-dark); + font-family: var(--mono); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.step-eyebrow::before { + content: "Remediation · "; + content: "Remediation · " / ""; + color: var(--muted); +} + +.step-title { + margin: 2px 0 4px; + font-size: 1.2rem; + font-weight: 700; + line-height: 1.3; +} + +.step-members { + color: var(--muted); + overflow-wrap: anywhere; +} + +.step-actions { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.step-buttons { + display: flex; + gap: 8px; +} + +.how-it-works, +.task-list { + margin: 0 0 12px; + padding-left: 20px; + line-height: 1.55; +} + +.how-it-works li + li, +.task-list li + li { + margin-top: 6px; +} + +.how-it-works { + margin-bottom: 0; +} + +.facts { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 12px; + margin: 0 0 12px; + font-size: 0.9rem; +} + +.facts dt { + color: var(--muted); + font-weight: 600; +} + +.facts dd { + margin: 0; + overflow-wrap: anywhere; +} + +.see-it { + padding: 10px 14px; + border-left: 4px solid #3b5bdb; + border-radius: 6px; + background: #eef2ff; + color: #1f2a55; +} + +.ai-note { + padding: 10px 14px; + border-left: 4px solid #f79009; + border-radius: 6px; + background: var(--warn-tint); + color: #7a2e0e; +} + +.clues { + padding: 10px 14px; + border: 1px dashed var(--line-strong); + border-radius: 6px; + background: #f9fafb; +} + +.clues summary, +.guide-log summary { + font-weight: 700; + cursor: pointer; +} + +.clues ul { + margin: 10px 0 0; + padding-left: 20px; + line-height: 1.55; +} + +.guide-log-count { + margin-left: 6px; + color: var(--muted); + font-size: 0.85rem; + font-weight: 400; +} + +/* Step results */ + +.badge { + display: inline-block; + padding: 2px 9px; + border: 1px solid #e1e5ea; + border-radius: 999px; + background: #f2f4f7; + color: var(--muted); + font-size: 0.76rem; + font-weight: 600; + white-space: nowrap; +} + +.badge-compact { + padding: 0; + border: 0; + background: transparent; + font-size: 0.9rem; +} + +.badge-passed { + border-color: var(--good-line); + background: var(--good-tint); + color: var(--good); +} + +.badge-failed { + border-color: var(--red-line); + background: var(--red-tint); + color: var(--red-dark); +} + +.badge-running { + border-color: var(--warn-line); + background: var(--warn-tint); + color: var(--warn); +} + +.badge-compact.badge-passed, +.badge-compact.badge-failed, +.badge-compact.badge-running { + background: transparent; +} + +.banner { + display: grid; + justify-items: start; + gap: 6px; + padding: 12px 14px; + border: 1px solid var(--line); + border-left: 4px solid #98a2b3; + border-radius: 8px; + background: var(--sheet); + line-height: 1.5; +} + +.banner-passed { + border-color: var(--good-line); + border-left-color: var(--good); + background: var(--good-tint); + color: var(--good-ink); +} + +.banner-failed { + border-color: var(--red-line); + border-left-color: var(--red); + background: var(--red-tint); + color: #912018; +} + +.banner-running { + border-color: var(--warn-line); + border-left-color: var(--warn-accent); + background: var(--warn-tint); + color: var(--warn); +} + +.banner-locked { + color: var(--muted); +} + +.banner-note { + font-size: 0.82rem; + opacity: 0.85; +} + +/* @key recreates the banner for every new result, so this animation plays again after each click */ +.banner-fresh { + animation: banner-in 0.35s ease-out; +} + +@keyframes banner-in { + from { + opacity: 0; + transform: translateY(-6px); + } + + to { + opacity: 1; + transform: none; + } +} + +.link-button { + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + font-weight: 700; + text-decoration: underline; + text-underline-offset: 3px; + cursor: pointer; +} + +.link-button:hover { + text-decoration-thickness: 2px; +} + +.check-list { + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.check { + display: grid; + grid-template-columns: 24px 1fr; + gap: 8px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--sheet); +} + +.check-failed { + border-color: var(--red-line); + border-left: 4px solid var(--red); + background: #fffbfa; +} + +.check-body { + min-width: 0; +} + +.guide-section .check-description { + margin: 0 0 6px; + font-weight: 600; + line-height: 1.4; +} + +.check-values { + display: grid; + grid-template-columns: max-content 1fr; + gap: 2px 10px; + margin: 0; + font-size: 0.86rem; +} + +.check-values dt { + color: var(--muted); +} + +.check-values dd { + margin: 0; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} + +.guide-section .check-hint { + margin: 8px 0 0; + padding: 8px 10px; + border: 1px solid var(--red-line); + border-radius: 6px; + background: #fee4e2; + color: var(--red-ink); + font-size: 0.87rem; + line-height: 1.5; +} + +.table-scroll { + margin-top: 10px; + overflow-x: auto; +} + +.step-log { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} + +.step-log th, +.step-log td { + padding: 6px 8px; + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: top; +} + +.step-log th { + color: var(--muted); +} + +.step-log td:nth-child(-n + 3) { + font-family: var(--mono); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* Narrow screens stack the guide under the app */ + +@media (max-width: 1080px) { + :root { + --guide-width: 380px; + } + + .ticker-grid { + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + } +} + +@media (max-width: 860px) { + .workspace { + flex-direction: column; + } + + .terminal { + min-height: 0; + } + + .guide, + .guide.collapsed { + position: static; + flex-basis: auto; + height: auto; + border-left: 0; + box-shadow: none; + } + + .guide::before { + position: static; + } + + .guide.collapsed { + grid-auto-flow: column; + justify-content: start; + align-items: center; + padding: 0 16px 0 0; + } + + .guide.collapsed::before { + align-self: stretch; + display: grid; + place-items: center; + padding: 0 12px; + } + + .guide.collapsed .guide-rail { + grid-auto-flow: column; + padding-block: 12px; + } + + .guide-rail-label { + writing-mode: horizontal-tb; + } + + .term-bar, + .term-head { + padding-inline: 16px; + } + + .term-live { + margin-left: 0; } .status-tile { justify-items: start; + min-width: 0; + } + + .ticker-grid { + padding: 12px 16px 28px; + } + + .feed-fault { + margin-inline: 16px; } } diff --git a/8. Concurrent Collections/ConcurrentCollections.pptx b/8. Concurrent Collections/ConcurrentCollections.pptx index 1f0d31f..9c9fe35 100644 Binary files a/8. Concurrent Collections/ConcurrentCollections.pptx and b/8. Concurrent Collections/ConcurrentCollections.pptx differ diff --git a/8. Concurrent Collections/README.md b/8. Concurrent Collections/README.md index f713b46..04f2777 100644 --- a/8. Concurrent Collections/README.md +++ b/8. Concurrent Collections/README.md @@ -9,25 +9,29 @@ The **1. Start** folder contains the intentionally imperfect code you will edit. 1. Using File Explorer on Windows or Finder on macOS, navigate to **BecomeAnExpertWithAsyncAwait/8. Concurrent Collections/1. Start**. 2. Open **StockWatch.slnx** in your IDE. 3. Build the project once and notice that it builds with zero warnings. -4. Run the project and open the dashboard in your browser. -5. Watch it for a minute, and reload it a few times, before you change anything. +4. Run the project and open it in your browser. +5. Watch the **Dashboard** for a minute, and reload it a few times, before you change anything. -The dashboard is at [http://localhost:5005](http://localhost:5005). +The starter app runs at [http://localhost:5005](http://localhost:5005). The finished app runs at [http://localhost:5006](http://localhost:5006), so you can run both at the same time and compare them later. -> **Note:** This app has a race condition, not a guaranteed crash. Some runs look perfect. Reload the page several times before you decide the bug is not there. The page is also blank for a moment on every load while the Blazor circuit connects, which is normal. If the page goes blank and then stops updating entirely, check the `dotnet run` terminal: the parallel loop threw, and this project has no on-page error banner to tell you. +The app opens on the **Dashboard**, with the **workshop guide** docked beside it. Every step in the guide is one bug in the dashboard's code-behind, and every time the app starts it checks your code against them. Right now the guide says it stopped at Step 1, and the terminal running the app shows the same result, with a hint. + +> **Note:** This app has a race condition, not a guaranteed crash. Some runs look perfect. Reload the page several times before you decide the bug is not there. The page is also blank for a moment on every load while the Blazor circuit connects, which is normal. If a **Feed fault** panel replaces the dashboard, check the `dotnet run` terminal: a collection that many threads touched at once threw while the dashboard loaded or rendered. Reload the browser tab to bring the dashboard back. ## 2. Inspect the Starting Code -1. Open **StockWatch/Components/Pages/Dashboard.razor.cs** and find each `// ToDo Refactor` comment. There are five. +1. Open **StockWatch/Components/Pages/Dashboard.razor.cs** and find each `// ToDo Refactor (Step N)` comment. There are eight, and the step number tells you which step checks that line. 2. Read `RefreshQuotes(CancellationToken)`. It calls `Parallel.ForEachAsync(...)` over all 60 symbols, so more than one of those refreshes can be in flight at once. 3. Read `GetSymbols()`. The `Symbols` property calls it on every render, and it uses `Parallel.ForEach(...)` to build the list the grid loops over. 4. Read `StartRefreshTimer()` and `StopRefreshTimer()`. Both read and write `_refreshTimer`, and nothing coordinates them. 5. Open **StockWatch/Services/MarketDataService.cs**. It is a simulated in-process feed that behaves like a remote quote API, so the sample runs offline with no API key, no rate limit, and the same starting prices for every attendee. Notice that `GetStockQuote(...)` waits somewhere under 60 milliseconds before it returns, exactly like a remote call would. -6. Open **StockWatch/Components/Pages/Dashboard.razor** and notice that the grid renders whatever `Symbols` hands it, that a symbol with no quote renders `--` instead of a price, and that the header tile renders `RefreshCount` as "quotes applied". +6. Open **StockWatch/Components/Pages/Dashboard.razor** and notice that the ticker tape and the grid both render whatever `Symbols` hands them, that a symbol with no quote renders `--` instead of a price, and that the header tile renders `RefreshCount` as "quotes applied". +7. Open the **StockWatch/Steps** folder. Each `Step*.cs` file renders a fresh dashboard with its own simulated feed and drives your code from many threads at once, with comments that explain what each expected result checks and why. These files are your guide. +8. Skim **StockWatch/Verification** and **StockWatch/Components/Layout/WorkshopGuide.razor**. They are the workshop plumbing that runs the steps and draws the guide. You do not need to change them. -Now watch what the browser actually does: +Now watch what the **Dashboard** beside the guide actually does: -1. Count the cards in the grid. There should be 60. You will usually count somewhere in the forties or fifties, and the number is different on every reload. Every so often the render throws instead: the torn `List` leaves a null in its backing array, `OrderBy` dereferences it, and the circuit dies. You get no grid at all, and a `NullReferenceException` in the `dotnet run` terminal. That stack trace is expected in the starter project. +1. Count the cards in the grid. There should be 60. You will usually count somewhere in the forties or fifties, and the number is different on every reload. Every so often the render throws instead: the torn `List` leaves a null in its backing array, and `OrderBy` dereferences it. A **Feed fault** panel replaces the dashboard until you reload, and the `dotnet run` terminal shows a `NullReferenceException`. That stack trace is expected in the starter project. 2. Look at the cards that did render. Some of them show `--` instead of a price. `GetSymbols()` builds a card for all 60 symbols either way, so a card showing `--` is a quote that never made it into `_latestQuotes`. A card that is missing entirely is an item that never made it into the `List`. Two different bugs, two different symptoms. 3. Look at the "quotes applied" tile. The first render should read 60, one per symbol. Every so often it reads 59 instead, more often on a machine with more cores. This is the quietest of the bugs: one lost increment out of sixty, with nothing to tell you it happened. 4. Wait 2 seconds. The tile should climb by exactly 60 on every tick. It usually does, and every so often it climbs by 59. @@ -44,6 +48,14 @@ Pay attention to these clues: 6. `StartRefreshTimer()` and `StopRefreshTimer()` both touch `_refreshTimer`, and nothing stops them running at the same time. A component being disposed mid-initialization can null the field out from under the method that is still assigning it. 7. The timer callback and the `Parallel.ForEachAsync(...)` body both run away from Blazor's renderer, which is why the refresh already ends with `InvokeAsync(StateHasChanged)`. +The app walks you through the challenge one step at a time: + +1. Step 1 shows every card, Step 2 keeps every quote and keeps the newest, Step 3 counts every quote, and Step 4 guards the refresh timer. +2. A step unlocks only after the step before it passes. Select a step in the guide to read the story behind the bug, how to see it on the Dashboard, which lines to change, and a task list for that step. If you get stuck, it has clues. +3. Each step renders a fresh dashboard, drives your code from many threads at once, and shows a checklist of every expected result next to what actually happened. Every result that does not match comes with a hint, and the same hint is printed in the terminal running the app. +4. Every time the app starts, it checks your code against every step, so the guide always reflects the code you have now. +5. Stop and run the app again after each change. If your IDE applied the change with Hot Reload, click **Run every step** at the top of the guide instead. + ## 3. Challenge: Make the Dashboard Thread Safe Recommended time: 25 minutes. @@ -52,15 +64,15 @@ Recommended time: 25 minutes. Refactor **Dashboard.razor.cs** so every shared field survives concurrent writes from all 60 symbol refreshes. -Requirements: +Requirements, in the order the steps check them: 1. Add `using System.Collections.Concurrent;`. -2. Replace `_latestQuotes` with a collection from `System.Collections.Concurrent` that is built for many concurrent writers. -3. Replace the `TryAdd(...)` and indexer assignment with a single `AddOrUpdate(...)` call that keeps whichever quote has the newer `Timestamp`. It is one call rather than two, and it cannot lose an update, but it is not atomic, which is what the next requirement is about. -4. Keep the delegate you pass to that call cheap and free of side effects, because it runs outside the collection's internal lock and can be invoked more than once. -5. Replace `_refreshCount++` with an atomic increment. -6. Read `_refreshCount` in a way that forces a real read of the field instead of a value the JIT cached in a register. -7. Replace the `List` in `GetSymbols()` with a thread-safe collection many `Parallel.ForEach(...)` workers can add to, and keep the `OrderBy` so the cards stay alphabetical. +2. Replace the `List` in `GetSymbols()` with a thread-safe collection many `Parallel.ForEach(...)` workers can add to, and keep the `OrderBy` so the cards stay alphabetical. +3. Replace `_latestQuotes` with a collection from `System.Collections.Concurrent` that is built for many concurrent writers. +4. Replace the `TryAdd(...)` and indexer assignment with a single `AddOrUpdate(...)` call that keeps whichever quote has the newer `Timestamp`. It is one call rather than two, and it cannot lose an update, but it is not atomic, which is what the next requirement is about. +5. Keep the delegate you pass to that call cheap and free of side effects, because it runs outside the collection's internal lock and can be invoked more than once. +6. Replace `_refreshCount++` with an atomic increment. +7. Read `_refreshCount` in a way that forces a real read of the field instead of a value the JIT cached in a register. 8. Add a `SemaphoreSlim` created with `new(1, 1)` and use it to guard every read and write of `_refreshTimer`. Do not use `lock`. `lock` cannot be held across an `await`, and every method here awaits. 9. Wait on the semaphore with `await WaitAsync(...)` and release it inside a `finally` block. 10. Move the timer disposal into a private method that assumes the semaphore is already held, and call that method from inside both guarded blocks. `SemaphoreSlim` is not reentrant, so a guarded method that calls another guarded method waits on itself forever. @@ -70,15 +82,16 @@ Requirements: Acceptance checks: 1. **StockWatch.slnx** builds with no warnings. -2. Open [http://localhost:5005](http://localhost:5005) and count the cards. There are 60, on the first paint and after every refresh. -3. The "quotes applied" tile reads exactly 60 when the page first paints. -4. Two seconds later it reads 120, then 180, then 240. It climbs by exactly 60 on every tick. -5. Reload the browser eight times in a row and get 60 cards and a first render of 60 every single time. -6. Prices and percent changes keep updating, and no card is stuck showing `--`. -7. Leave the page open for a minute, then reload the browser. The prices keep updating after the reload, and the `dotnet run` terminal shows no exception. -8. Your code is ready to compare with **2. Finish/StockWatch**. - -If you finish early, swap the collection you chose in `GetSymbols()` for a different type from `System.Collections.Concurrent` and confirm the dashboard still renders all 60 cards. Watch out: this is not always a pure type swap, because not every one of these collections exposes an `Add` method. Then work out why the `OrderBy` on the last line of `GetSymbols()` is what makes the two interchangeable here, and which of the two you would actually ship. +2. The workshop guide shows 4 of 4 steps pass after the app starts. +3. Open [http://localhost:5005](http://localhost:5005) and count the cards on the Dashboard. There are 60, on the first paint and after every refresh. +4. The "quotes applied" tile reads exactly 60 when the page first paints. +5. Two seconds later it reads 120, then 180, then 240. It climbs by exactly 60 on every tick. +6. Reload the browser eight times in a row and get 60 cards and a first render of 60 every single time. +7. Prices and percent changes keep updating, and no card is stuck showing `--`. +8. Leave the page open for a minute, then reload the browser. The prices keep updating after the reload, and the `dotnet run` terminal shows no exception. +9. Your code is ready to compare with **2. Finish/StockWatch**. + +If you finish early, swap the collection you chose in `GetSymbols()` for a different type from `System.Collections.Concurrent` and confirm Step 1 still passes and the dashboard still renders all 60 cards. Watch out: this is not always a pure type swap, because not every one of these collections exposes an `Add` method. Then work out why the `OrderBy` on the last line of `GetSymbols()` is what makes the two interchangeable here, and which of the two you would actually ship. ## 4. Review the Solution diff --git a/8. Concurrent Collections/SOLUTION.md b/8. Concurrent Collections/SOLUTION.md index 1d12126..6b657bb 100644 --- a/8. Concurrent Collections/SOLUTION.md +++ b/8. Concurrent Collections/SOLUTION.md @@ -2,23 +2,58 @@ Use this during the guided walkthrough after the challenge and group review in [README.md](README.md). -The completed project is **2. Finish/StockWatch**. Compare your decisions with the finished Blazor sample as we rebuild the solution step by step. +The completed project is **2. Finish/StockWatch**. Compare your decisions with the finished Blazor sample as we rebuild the solution step by step. Each section below names the workshop step it makes pass, so you can follow along in the workshop guide. + +**1. Start** and **2. Finish** share the same workshop plumbing: the `Steps` and `Verification` folders, the layout with its workshop guide, and the Dashboard page's markup. The only differences are **Components/Pages/Dashboard.razor.cs** and the port in `Properties/launchSettings.json`. Every change lives in one file: **Components/Pages/Dashboard.razor.cs**. Nothing in `MarketDataService`, the models, or the markup needs to move. -## 1. Replace the Dictionary +## 1. Collect Results in a ConcurrentBag (Step 1) -`Dictionary` is fast because it assumes it will never be written by two threads at once. `Parallel.ForEachAsync(...)` refreshes all 60 symbols concurrently, so more than one writer can be in flight at once. A concurrent resize can lose entries, corrupt a bucket chain, or throw. +Every change in this walkthrough uses the concurrent collections namespace, so add it first: -`ConcurrentDictionary` is the replacement. Reads are lock-free, and writes take a lock from a small striped lock array, one lock per processor by default and capped at 1024, rather than one lock over the whole collection, so threads writing different keys usually do not wait on each other. +```cs +using System.Collections.Concurrent; +``` + +`GetSymbols()` runs `Parallel.ForEach(...)` and adds one item per symbol. `List.Add` writes into an internal array and sometimes grows it, so parallel calls silently lose items, leave a null in the backing array for `OrderBy` to trip over, or throw. This is the failure the dashboard shows most often: a card is simply not there. + +Step 1 reads `Symbols` 200 times in a row, the way 200 renders would. The starter returns all 60 cards on well under 200 of those reads, some reads come back with far fewer, and a handful throw the same `NullReferenceException` that puts a Feed fault panel in place of the dashboard in the browser. -Add the namespace: +`ConcurrentBag` is built for this shape of work. It is unordered, it allows duplicates, and it gives each producing thread its own local storage so adds rarely contend at all. ```cs -using System.Collections.Concurrent; + IReadOnlyList GetSymbols() + { + // ConcurrentBag collects results from parallel workers without a lock + ConcurrentBag symbols = []; + + Parallel.ForEach(MarketDataService.Symbols, symbol => + { + _latestQuotes.TryGetValue(symbol, out var quote); + + symbols.Add(new StockSymbolModel(symbol, MarketDataService.GetCompanyName(symbol), quote)); + }); + + return [.. symbols.OrderBy(static symbol => symbol.Symbol)]; + } ``` -Then change the field: +Unordered is fine here because the last line sorts the results anyway. If you needed indexing or `List` semantics, a bag would be the wrong answer. + +`List` has no drop-in concurrent replacement. Pick by how you read the data back: keyed lookup is `ConcurrentDictionary`, first in first out is `ConcurrentQueue`, last in first out is `ConcurrentStack`, and "just collect it and sort later" is `ConcurrentBag`. + +One honest footnote, because someone always asks. `Symbols` is evaluated on Blazor's renderer on every render, and `Parallel.ForEach(...)` is a blocking call that holds that thread until the last partition finishes. Sixty dictionary lookups do not need sixty-way parallelism, and the partitioning costs more than the work. In real code this method would be a plain LINQ projection over `MarketDataService.Symbols`, with no concurrent collection needed at all, because a body that shares nothing cannot race. The parallel loop is here because it is the clearest place to watch `ConcurrentBag` do its job. + +## 2. Replace the Dictionary (Step 2) + +`Dictionary` is fast because it assumes it will never be written by two threads at once. `Parallel.ForEachAsync(...)` refreshes all 60 symbols concurrently, so more than one writer can be in flight at once. A concurrent resize can lose entries, corrupt a bucket chain, or throw. + +`ConcurrentDictionary` is the replacement. Reads are lock-free, and writes take a lock from a small striped lock array, one lock per processor by default and capped at 1024, rather than one lock over the whole collection, so threads writing different keys usually do not wait on each other. + +A lost insert is rare enough that one refresh often comes out right, so Step 2 does not rely on catching one. It checks the type of `_latestQuotes` directly, and the guide shows the type you chose. + +Change the field: ```cs // ConcurrentDictionary is safe for many writers at once. AddOrUpdate replaces @@ -27,7 +62,7 @@ Then change the field: readonly ConcurrentDictionary _latestQuotes = new(); ``` -## 2. Make Each Update a Single Call +## 3. Make Each Update a Single Call (Steps 2 and 3) The starter code calls `TryAdd(...)`, and when that returns `false` it assigns through the indexer. Those are two operations. Between them another thread can write a newer quote that your indexer assignment then overwrites. Swapping in a concurrent dictionary alone does not fix this, because each call being thread safe does not make the pair of them atomic. @@ -79,7 +114,11 @@ Contrast that with the line directly beneath it in the sample. `Interlocked.Incr The timestamp comparison matters for a second reason. When one 2 second tick runs long, two refreshes overlap, and without the comparison a slow thread carrying an older quote could overwrite a newer one that already landed. -## 3. Read the Counter Safely +That is the case Step 2 builds on purpose. It stores a quote timestamped an hour from now for one symbol and a quote from an hour ago for another, runs a refresh, and checks that the newer quote survived and the older one was replaced. Swapping in `ConcurrentDictionary` while keeping `TryAdd(...)` and the indexer passes the type check and fails this one, which is where this section started: every call being thread safe does not make the pair of them atomic. + +Step 3 covers the counter. It runs 32 refreshes at the same time and checks that the count grew by exactly 60 each, but a lost increment is timing dependent, so a single run can come out right by luck. It also reads the compiled code of `RefreshQuotes()`, including the lambda and its async state machine, and checks that it calls `Interlocked.Increment(...)`. + +## 4. Read the Counter Safely (Step 3) `Interlocked.Increment(...)` makes the writes atomic and publishes them. What it does not do is stop the JIT from hoisting a plain `_refreshCount` read into a register and never looking at the field again. @@ -89,39 +128,14 @@ The timestamp comparison matters for a second reason. When one 2 second tick run `Volatile.Read(...)` forces a real acquire-ordered read of the field. In this component the `InvokeAsync(StateHasChanged)` hand-off already orders the render behind the writes, so this is defense in depth, but it costs nothing, and it tells the next person reading this property that the field is shared. -## 4. Collect Results in a ConcurrentBag - -`GetSymbols()` runs `Parallel.ForEach(...)` and adds one item per symbol. `List.Add` writes into an internal array and sometimes grows it, so parallel calls silently lose items, leave a null in the backing array for `OrderBy` to trip over, or throw. This is the failure the dashboard shows most often: a card is simply not there. - -`ConcurrentBag` is built for this shape of work. It is unordered, it allows duplicates, and it gives each producing thread its own local storage so adds rarely contend at all. - -```cs - IReadOnlyList GetSymbols() - { - // ConcurrentBag collects results from parallel workers without a lock - ConcurrentBag symbols = []; +No test can reliably catch a read the JIT served from a register, so Step 3 checks the compiled getter for `Volatile.Read(...)`, the same way it checks `RefreshQuotes()` for `Interlocked.Increment(...)`. - Parallel.ForEach(MarketDataService.Symbols, symbol => - { - _latestQuotes.TryGetValue(symbol, out var quote); - - symbols.Add(new StockSymbolModel(symbol, MarketDataService.GetCompanyName(symbol), quote)); - }); - - return [.. symbols.OrderBy(static symbol => symbol.Symbol)]; - } -``` - -Unordered is fine here because the last line sorts the results anyway. If you needed indexing or `List` semantics, a bag would be the wrong answer. - -`List` has no drop-in concurrent replacement. Pick by how you read the data back: keyed lookup is `ConcurrentDictionary`, first in first out is `ConcurrentQueue`, last in first out is `ConcurrentStack`, and "just collect it and sort later" is `ConcurrentBag`. - -One honest footnote, because someone always asks. `Symbols` is evaluated on Blazor's renderer on every render, and `Parallel.ForEach(...)` is a blocking call that holds that thread until the last partition finishes. Sixty dictionary lookups do not need sixty-way parallelism, and the partitioning costs more than the work. In real code this method would be a plain LINQ projection over `MarketDataService.Symbols`, with no concurrent collection needed at all, because a body that shares nothing cannot race. The parallel loop is here because it is the clearest place to watch `ConcurrentBag` do its job. - -## 5. Guard the Timer With SemaphoreSlim +## 5. Guard the Timer With SemaphoreSlim (Step 4) Nothing in the starter code orders `StartRefreshTimer()` against `StopRefreshTimer()`, so a component torn down mid-initialization can leave a disposed timer assigned or a live timer leaked. This is not a collection problem, so no concurrent collection can solve it. +A leaked timer is worse than it sounds. Its callback reads `_disposeCancellationTokenSource.Token`, and once `DisposeAsync()` has disposed that source, the next tick throws `ObjectDisposedException` inside an `async void` timer callback. Nothing can catch that, and it ends the whole process. That is also why Step 4 will not call the starter's timer methods at the same time: until a `SemaphoreSlim` field exists, it reports the missing semaphore and skips the checks that would leak a timer inside the app that is running them. + You cannot use `lock` here, because the guarded work contains an `await` and a lock cannot be held across an `await`. `SemaphoreSlim` with a count of one is the asynchronous lock: ```cs @@ -160,7 +174,7 @@ Wait on it before touching the field, and release it in a `finally` so a throw i } ``` -## 6. Split Out the Unguarded Core +## 6. Split Out the Unguarded Core (Step 4) `SemaphoreSlim` is not reentrant. The thread that holds it gets no special treatment, so a guarded method that calls another guarded method waits on a permit it is already holding and never returns. @@ -192,9 +206,11 @@ That is why `StartRefreshTimer()` above does not call `StopRefreshTimer()`. The } ``` +`OnInitializedAsync()` calls `StartRefreshTimer()`, so a `StartRefreshTimer()` that calls the guarded `StopRefreshTimer()` never lets the page finish loading. Every step renders a fresh dashboard first, so that mistake shows up as "A fresh dashboard finishes loading" failing on whichever step you run next. + Notice the two different tokens. `StartRefreshTimer()` waits with the dispose token, so a component being torn down stops waiting immediately. `StopRefreshTimer()` waits with `CancellationToken.None`, because it is called from `DisposeAsync()` after that token has already been cancelled, and cleanup still has to run. -## 7. Dispose the Semaphore +## 7. Dispose the Semaphore (Step 4) `SemaphoreSlim` owns a wait handle, so dispose it with the rest of the component state: @@ -216,6 +232,8 @@ The order matters, and it is worth being precise about what it buys you. Cancell One caveat, because someone will ask. `Timer.DisposeAsync()` waits for the callback delegate to return, and the callback here is an async lambda handed to `TimerCallback`, which returns `void`. An async void method returns to its caller at its first `await`, so disposing the timer does not by itself wait for an in-flight refresh to finish. Cancellation is what stops that refresh, which is the real reason cancel comes first. +Once the semaphore exists, Step 4 checks all of this from the outside. It takes the semaphore itself and confirms that `StartRefreshTimer()` and `StopRefreshTimer()` each wait for it. It fires 400 start and stop calls at the same time, stops the timer one last time, and waits 2.5 seconds, longer than one tick, to confirm that no leaked timer is still applying quotes. Then it disposes the page the way a closed tab does and checks that `DisposeAsync()` finished and disposed the semaphore. A `StopRefreshTimer()` that waits with the dispose token fails that last check with a `TaskCanceledException`. + ## 8. Run It and Count From the **2. Finish** folder, run the finished project and open [http://localhost:5006](http://localhost:5006): @@ -224,6 +242,8 @@ From the **2. Finish** folder, run the finished project and open [http://localho dotnet run --project StockWatch/StockWatch.csproj ``` +The workshop guide shows 4 of 4 steps pass once the checks that run after the app starts have finished. On the **Dashboard** beside it: + 1. Count the cards. There are 60, on the first paint and after every refresh. 2. The "quotes applied" tile reads exactly 60 when the page first paints. 3. Two seconds later it reads 120, then 180, then 240. Exactly 60 per tick, every tick. diff --git a/9. Channels/1. Start/TelemetryPipeline/Components/Layout/MainLayout.razor b/9. Channels/1. Start/TelemetryPipeline/Components/Layout/MainLayout.razor index 1c2a641..f745fc5 100644 --- a/9. Channels/1. Start/TelemetryPipeline/Components/Layout/MainLayout.razor +++ b/9. Channels/1. Start/TelemetryPipeline/Components/Layout/MainLayout.razor @@ -1,5 +1,9 @@ @inherits LayoutComponentBase -
- @Body -
+
+
+ @Body +
+ + +
diff --git a/9. Channels/1. Start/TelemetryPipeline/Components/Layout/StepBadge.razor b/9. Channels/1. Start/TelemetryPipeline/Components/Layout/StepBadge.razor new file mode 100644 index 0000000..352439e --- /dev/null +++ b/9. Channels/1. Start/TelemetryPipeline/Components/Layout/StepBadge.razor @@ -0,0 +1,10 @@ +@inherits StepBadgeBase + +@if (IsCompact) +{ + @Label +} +else +{ + @Icon @Label +} diff --git a/9. Channels/1. Start/TelemetryPipeline/Components/Layout/StepBadge.razor.cs b/9. Channels/1. Start/TelemetryPipeline/Components/Layout/StepBadge.razor.cs new file mode 100644 index 0000000..e59465d --- /dev/null +++ b/9. Channels/1. Start/TelemetryPipeline/Components/Layout/StepBadge.razor.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Components; + +namespace TelemetryPipeline.Components.Layout; + +public partial class StepBadgeBase : ComponentBase +{ + [Parameter, EditorRequired] + public StepStatus Status { get; set; } + + [Parameter] + public bool IsUnlocked { get; set; } = true; + + // Only the icon, for the collapsed guide + [Parameter] + public bool IsCompact { get; set; } + + public string Icon => Status is StepStatus.Running ? "⏳" : !IsUnlocked ? "🔒" : Status switch + { + StepStatus.Passed => "✅", + StepStatus.Failed or StepStatus.TimedOut or StepStatus.Crashed => "❌", + StepStatus.NotImplemented => "🧩", + _ => "•", + }; + + public string Label => Status is StepStatus.Running ? "Running" : !IsUnlocked ? "Locked" : Status switch + { + StepStatus.Passed => "Passed", + StepStatus.Failed => "Failed", + StepStatus.TimedOut => "Timed out", + StepStatus.Crashed => "Crashed", + StepStatus.NotImplemented => "Not implemented", + StepStatus.Stopped => "Stopped", + _ => "Not run", + }; + + public string CssClass => Status is StepStatus.Running ? "badge-running" : !IsUnlocked ? "badge-locked" : Status switch + { + StepStatus.Passed => "badge-passed", + StepStatus.Failed or StepStatus.TimedOut or StepStatus.Crashed => "badge-failed", + StepStatus.NotImplemented => "badge-missing", + _ => "badge-idle", + }; +} \ No newline at end of file diff --git a/9. Channels/1. Start/TelemetryPipeline/Components/Layout/WorkshopGuide.razor b/9. Channels/1. Start/TelemetryPipeline/Components/Layout/WorkshopGuide.razor new file mode 100644 index 0000000..b4effab --- /dev/null +++ b/9. Channels/1. Start/TelemetryPipeline/Components/Layout/WorkshopGuide.razor @@ -0,0 +1,338 @@ +@inherits WorkshopGuideBase + + diff --git a/9. Channels/1. Start/TelemetryPipeline/Components/Layout/WorkshopGuide.razor.cs b/9. Channels/1. Start/TelemetryPipeline/Components/Layout/WorkshopGuide.razor.cs new file mode 100644 index 0000000..0b303ab --- /dev/null +++ b/9. Channels/1. Start/TelemetryPipeline/Components/Layout/WorkshopGuide.razor.cs @@ -0,0 +1,179 @@ +using Microsoft.AspNetCore.Components; + +namespace TelemetryPipeline.Components.Layout; + +// Workshop plumbing: the guide docked beside the app. It shows your challenge, every step's result, and the step you are working on. +public partial class WorkshopGuideBase : ComponentBase, IAsyncDisposable +{ + // Selecting this shows your challenge instead of a step + public const int ChallengeTab = 0; + + // A run that fails quickly can finish faster than anyone can see a spinner + static readonly TimeSpan _minimumActivityIndicatorTime = TimeSpan.FromMilliseconds(600); + + readonly CancellationTokenSource _disposeCancellationTokenSource = new(); + + Task _polling = Task.CompletedTask; + bool _isStartingCheck; + int? _startingRunStepNumber; + + [Inject] + public required StepVerifier Verifier { get; init; } + + [Inject] + public required ILogger Logger { get; init; } + + public int SelectedTab { get; private set; } = ChallengeTab; + + public bool IsCollapsed { get; private set; } + + public string? ErrorMessage { get; private set; } + + public int PassedStepCount => Verifier.Steps.Count(step => Verifier.GetState(step.Number).Status is StepStatus.Passed); + + public bool IsCheckingSteps => _isStartingCheck || Verifier.IsCheckingAllSteps; + + // The first step that does not pass yet, which is the one to work on next + public WorkshopStep? StoppedAtStep => Verifier.Steps.FirstOrDefault(step => Verifier.GetState(step.Number).Status is not StepStatus.Passed); + + // The last step the check reaches: the first one that did not pass, or the last step when they all passed + public WorkshopStep LastCheckedStep => StoppedAtStep ?? Verifier.Steps[^1]; + + public WorkshopStep? SelectedStep => Verifier.FindStep(SelectedTab); + + // The run lives in the verifier, not in this guide, so a step that is still running shows up again after a reload + public ActiveStepRun? SelectedRun => Verifier.ActiveRun is { } run && run.Step.Number == SelectedTab ? run : null; + + public bool IsSelectedStepRunning => _startingRunStepNumber == SelectedTab || SelectedRun is not null; + + public async ValueTask DisposeAsync() + { + // Only stops refreshing the guide. A step that is running keeps running. + await _disposeCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + await _polling.ConfigureAwait(false); + + _disposeCancellationTokenSource.Dispose(); + + GC.SuppressFinalize(this); + } + + protected override void OnInitialized() + { + _polling = PollVerifier(_disposeCancellationTokenSource.Token); + } + + protected void SelectTab(int tab) + { + SelectedTab = tab; + IsCollapsed = false; + ErrorMessage = null; + } + + protected void ToggleCollapsed() => IsCollapsed = !IsCollapsed; + + protected async Task CheckAllStepsAsync() + { + var token = _disposeCancellationTokenSource.Token; + string? errorMessage = null; + + ErrorMessage = null; + _isStartingCheck = true; + + // Show the activity indicator before the check starts + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + try + { + // Keep the indicator on screen long enough to notice, even when Step 1 fails quickly + await Task.WhenAll(Verifier.CheckAllSteps(), Task.Delay(_minimumActivityIndicatorTime, token)).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The guide was closed while the steps were being checked + return; + } + catch (Exception e) + { + Logger.LogError(e, "Checking every step failed"); + + errorMessage = "Checking the steps failed. The terminal running the app has the details."; + } + + // The continuation is off Blazor's renderer, so every component state change goes back through it + await InvokeAsync(() => + { + ErrorMessage = errorMessage; + _isStartingCheck = false; + + StateHasChanged(); + }).ConfigureAwait(false); + } + + protected async Task RunSelectedStepAsync() + { + if (SelectedStep is not { } step) + return; + + var token = _disposeCancellationTokenSource.Token; + string? errorMessage = null; + + ErrorMessage = null; + _startingRunStepNumber = step.Number; + + // Show the activity indicator before the run starts + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + try + { + // Keep the indicator on screen long enough to notice, even when the step fails quickly + await Task.WhenAll(Verifier.RunStep(step), Task.Delay(_minimumActivityIndicatorTime, token)).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The guide was closed while the step was running + return; + } + catch (Exception e) + { + Logger.LogError(e, "Running Step {StepNumber} failed", step.Number); + + errorMessage = "The step could not be run. The terminal running the app has the details."; + } + + // The continuation is off Blazor's renderer, so every component state change goes back through it + await InvokeAsync(() => + { + ErrorMessage = errorMessage; + _startingRunStepNumber = null; + + StateHasChanged(); + }).ConfigureAwait(false); + } + + protected void StopRun() => Verifier.StopRun(); + + // Re-renders while the verifier is busy, plus once more when it finishes, so the live log and results appear without a refresh + async Task PollVerifier(CancellationToken token) + { + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100)); + var wasBusy = true; + + try + { + while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false)) + { + var isBusy = Verifier.IsBusy; + + if (isBusy || wasBusy) + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + wasBusy = isBusy; + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // Expected when the guide is disposed + } + } +} \ No newline at end of file diff --git a/9. Channels/1. Start/TelemetryPipeline/Components/Pages/Ingest.razor b/9. Channels/1. Start/TelemetryPipeline/Components/Pages/Ingest.razor index 47877ba..a8687f6 100644 --- a/9. Channels/1. Start/TelemetryPipeline/Components/Pages/Ingest.razor +++ b/9. Channels/1. Start/TelemetryPipeline/Components/Pages/Ingest.razor @@ -1,17 +1,35 @@ @page "/" @inherits IngestPageBase -TelemetryPipeline - -
-
-
-

TelemetryPipeline

-

Device telemetry ingest

-

@EventCount.ToString("N0") devices post readings in a burst. Each reading has to reach the database, and each device gives up if the endpoint is slow.

-
+TelemetryPipeline · Ingest + +
+
+ + Fleet control room · Ingest endpoint +
+ +
+

Device fleet · live ingest

+

Device telemetry ingest

+

@EventCount.ToString("N0") devices post readings in a burst. Each reading has to reach the database, and each device gives up if the endpoint is slow.

+ +
+
+
Device timeout
+
20ms
+
+
+
Database write
+
40ms
+
+
+
Burst
+
@EventCount.ToString("N0") readings
+
+
-
+
-
+
@if (Stats is null && !IsBusy) { -
+

What to look for

-

Receive the burst and watch the average accept time. A device gives up after 20ms, so anything slower than that is a dropped reading in production.

-
+

Receive the burst and watch the average accept time. A device gives up after 20ms, so anything slower than that is a dropped reading in production.

+ } @if (Stats is not null) { -
+ // Each gauge is full at its limit: the whole burst, the 20ms a device waits, and 40ms per reading, the burst duration when every write happens inline + var acceptedGauge = Math.Min(100, Stats.Accepted * 100 / EventCount); + var acceptGauge = Math.Min(100, Stats.AverageAcceptMilliseconds * 100 / 20).ToString("F0"); + var durationGauge = Math.Min(100, Stats.TotalSeconds * 100 / (EventCount * 0.04)).ToString("F0"); + var writtenGauge = Math.Min(100, LiveProcessed * 100 / EventCount); + +

Ingest results

-

Accepting an event should be fast. Processing it can take as long as it needs, as long as nothing is lost.

+

Accepting an event should be fast. Processing it can take as long as it needs, as long as nothing is lost.

-
+
+ Accepted @Stats.Accepted.ToString("N0") of @EventCount.ToString("N0")
-
+
+ Average accept - @Stats.AverageAcceptMilliseconds.ToString("F1")ms + @Stats.AverageAcceptMilliseconds.ToString("F1")ms slowest @Stats.SlowestAcceptMilliseconds.ToString("F0")ms, devices retry past 20ms
-
+
+ Burst duration - @Stats.TotalSeconds.ToString("F2")s + @Stats.TotalSeconds.ToString("F2")s to accept every event
-
+
+ Written to store @LiveProcessed.ToString("N0") @LiveQueueDepth waiting in the queue
-
+
} - + +
+

Pipeline

+

Live counters from the ingest service, as of the last time the page refreshed.

+ +
+
+ + Devices + @EventCount.ToString("N0") + readings per burst +
+ + + +
+ + Queue + @LiveQueueDepth.ToString("N0") + waiting +
+ + + +
+ + Consumers + @(LiveQueueDepth > 0 ? "Draining" : "Idle") + background service +
+ + + +
+ + Store + @LiveProcessed.ToString("N0") + written +
+
+
+
diff --git a/9. Channels/1. Start/TelemetryPipeline/Program.cs b/9. Channels/1. Start/TelemetryPipeline/Program.cs index a97d715..43f5188 100644 --- a/9. Channels/1. Start/TelemetryPipeline/Program.cs +++ b/9. Channels/1. Start/TelemetryPipeline/Program.cs @@ -15,6 +15,11 @@ public static void Main(string[] args) builder.Services.AddSingleton(); builder.Services.AddHostedService(); + // Workshop plumbing: one StepVerifier holds every step's result. It is also a hosted service, + // so it checks your services against every step each time the app starts. + builder.Services.AddSingleton(); + builder.Services.AddHostedService(static serviceProvider => serviceProvider.GetRequiredService()); + var app = builder.Build(); if (!app.Environment.IsDevelopment()) diff --git a/9. Channels/1. Start/TelemetryPipeline/Services/TelemetryIngestService.cs b/9. Channels/1. Start/TelemetryPipeline/Services/TelemetryIngestService.cs index 51d9c63..88e6065 100644 --- a/9. Channels/1. Start/TelemetryPipeline/Services/TelemetryIngestService.cs +++ b/9. Channels/1. Start/TelemetryPipeline/Services/TelemetryIngestService.cs @@ -6,32 +6,45 @@ namespace TelemetryPipeline; // AcceptAsync stands in for a webhook endpoint: devices retry if it is slow. public sealed class TelemetryIngestService(EventStore eventStore) { - // ToDo Refactor: a plain List is not thread safe. The accept path writes to it + // ToDo Refactor (Step 2): a plain List is not thread safe. The accept path writes to it // while the drain loop reads from it, so events are lost or this throws. + // Nothing limits how large it can grow, and nothing can wait on it. readonly List _pending = []; int _accepted; int _processed; + // ToDo Refactor (Step 2): a producer and several consumers are about to touch these counters at once, + // and an int read can be stale. The page should read the latest value, not one cached in a register. public int Accepted => _accepted; + // ToDo Refactor (Step 2): an int read can be stale public int Processed => _processed; + // ToDo Refactor (Step 2): report the depth of the queue itself public int QueueDepth => _pending.Count; + // ToDo Refactor (Step 1): queuing an event almost always completes synchronously, and Task allocates + // on every call. Return the task type that allocates nothing when the call completes synchronously. public async Task AcceptAsync(TelemetryEvent telemetryEvent, CancellationToken token) { - // ToDo Refactor: the device is waiting on this call while we write to the + // ToDo Refactor (Step 1): the device is waiting on this call while we write to the // database. A webhook should accept the event and return immediately. await eventStore.SaveAsync(telemetryEvent, token).ConfigureAwait(false); _pending.Add(telemetryEvent); + + // ToDo Refactor (Step 2): `++` is a read, an add, and a write, and many devices post at once _accepted++; + + // ToDo Refactor (Step 1): this counts the event as written to the store before any consumer + // has written it, so "Written to store" counts the same work as "Accepted" _processed++; } - // ToDo Refactor: this loop spins whether or not there is anything to do, - // and nothing tells it when the pipeline has been shut down. + // ToDo Refactor (Step 3): this loop spins whether or not there is anything to do, + // and it removes events without ever writing them to the store. + // ToDo Refactor (Step 4): nothing tells it when the pipeline has been shut down. public async Task DrainAsync(CancellationToken token) { while (!token.IsCancellationRequested) @@ -46,8 +59,16 @@ public async Task DrainAsync(CancellationToken token) } } + // ToDo Refactor (Step 4): nothing tells the consumers that no more events are coming + public void CompleteWriting() + { + throw new NotImplementedException("TelemetryIngestService.CompleteWriting() is not implemented yet. Hint: mark the queue complete, so the consumers finish writing the backlog and then stop reading. Do not call it from RunBurstAsync: a completed queue is closed for good, and the next burst would throw. It belongs on the shutdown path, in TelemetryProcessor."); + } + public async Task RunBurstAsync(int eventCount, CancellationToken token) { + // ToDo Refactor (Step 3): the consumers keep draining earlier bursts in the background, + // so clearing the counters here makes them wrong Reset(); var stopwatch = Stopwatch.StartNew(); @@ -80,6 +101,8 @@ await AcceptAsync( stopwatch.Elapsed.TotalSeconds); } + // ToDo Refactor (Step 2): this clears the list + // ToDo Refactor (Step 3): and it resets counters that the consumers are still updating void Reset() { _pending.Clear(); diff --git a/9. Channels/1. Start/TelemetryPipeline/Services/TelemetryProcessor.cs b/9. Channels/1. Start/TelemetryPipeline/Services/TelemetryProcessor.cs index bce18cd..c1559cd 100644 --- a/9. Channels/1. Start/TelemetryPipeline/Services/TelemetryProcessor.cs +++ b/9. Channels/1. Start/TelemetryPipeline/Services/TelemetryProcessor.cs @@ -1,12 +1,16 @@ namespace TelemetryPipeline; // The consumer half of the pipeline. +// ToDo Refactor (Step 4): when the app shuts down, nothing completes the queue first, +// so the readings still waiting in it never reach the store. public sealed class TelemetryProcessor(TelemetryIngestService ingest) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try { + // ToDo Refactor (Step 4): the host cancels stoppingToken at shutdown, and cancelling + // the read loop abandons every reading that is still waiting in the queue await ingest.DrainAsync(stoppingToken).ConfigureAwait(false); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) diff --git a/9. Channels/1. Start/TelemetryPipeline/Steps/Step1AnswerRightAway.cs b/9. Channels/1. Start/TelemetryPipeline/Steps/Step1AnswerRightAway.cs new file mode 100644 index 0000000..7d01620 --- /dev/null +++ b/9. Channels/1. Start/TelemetryPipeline/Steps/Step1AnswerRightAway.cs @@ -0,0 +1,89 @@ +namespace TelemetryPipeline; + +public sealed class Step1AnswerRightAway : WorkshopStep +{ + const int _readingCount = 20; + const double _maximumAverageAcceptMilliseconds = 5; + + const string _waitsForStoreHint = "Every accept waits for the 40 millisecond database write before it answers, so the device holds the connection open for the whole write and gives up after 20. " + + "Accepting a reading should only queue it. Move the EventStore.SaveAsync call out of the accept path entirely."; + + const string _countedAsWrittenHint = "AcceptAsync counts the reading as processed, so Written to store counts the same work as Accepted. " + + "Only a consumer writes readings to the store, so only a consumer should count them as processed. Nothing is consuming in this check."; + + public override int Number => 1; + + public override string Scenario => "Answer the device right away"; + + public override string Title => "AcceptAsync()"; + + public override string Story => "Every device in the fleet posts its readings to this endpoint, and every reading has to reach the database. The database takes 40 milliseconds per write. " + + "The devices do not care how long that write takes. They care how long the endpoint takes to answer, and they give up after 20 milliseconds. " + + "AcceptAsync does the write before it answers, so every reading becomes a retry, and the retries land on an endpoint that is already slow. " + + "There is already a background service that was supposed to do this work, and today it does nothing useful."; + + public override string SeeItInTheApp => "On the Ingest page, press Receive 400 events. The button spins for about 16 seconds. " + + "Average accept reads about 41ms on a red card, more than twice the 20ms a device waits. " + + "Written to store already reads 400 with nothing waiting in the queue, because every write happened inside the accept call. The background service did none of it."; + + public override string FileToChange => "Services/TelemetryIngestService.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Change AcceptAsync so it only queues the reading. Move the EventStore.SaveAsync call out of the accept path entirely.", + "Stop counting the reading as processed in AcceptAsync. Written to store should count readings that actually reached the store, and a consumer does that work.", + "Return the task type that allocates nothing when the call completes synchronously, because queuing a reading almost always does.", + "Keep adding the reading to _pending for now. Step 2 replaces the list with a real queue.", + "Leave EventStore alone. The 40 millisecond write is the one thing you cannot make faster.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "AcceptAsync awaits eventStore.SaveAsync(...) before it returns. The device holds the connection open for the entire database write.", + "_processed++ happens in the accept path, so Written to store is counting the same work as Accepted. The consumer is decoration.", + "Task allocates on every call. Its value type sibling in System.Threading.Tasks does not, when the method completes synchronously.", + ]; + + public override string TimeoutHint => "Accepting 20 readings never finished. Check that AcceptAsync no longer waits on anything that could take a long time."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + // Part 1: warm up on a throwaway service, so compiling AcceptAsync for the first time is not counted as accept time + await new TelemetryIngestService(new EventStore()).RunBurstAsync(1, token).ConfigureAwait(false); + + // Part 2: the same burst the Ingest page sends, only smaller, to a fresh service with no consumers running + var ingest = new TelemetryIngestService(new EventStore()); + report.Log($"Receiving a burst of {_readingCount} readings through RunBurstAsync, with no consumers running"); + + var stats = await ingest.RunBurstAsync(_readingCount, token).ConfigureAwait(false); + report.Log($"The burst took {stats.TotalSeconds:F2}s. Average accept {stats.AverageAcceptMilliseconds:F1}ms, slowest {stats.SlowestAcceptMilliseconds:F1}ms"); + + report.Expect( + $"Accepting a reading takes under {_maximumAverageAcceptMilliseconds}ms on average", + $"under {_maximumAverageAcceptMilliseconds}ms", + $"{stats.AverageAcceptMilliseconds:F1}ms", + stats.AverageAcceptMilliseconds < _maximumAverageAcceptMilliseconds, + _waitsForStoreHint); + + report.Expect($"Accepted reads {_readingCount}", _readingCount, ingest.Accepted, "Every reading AcceptAsync takes has to be counted in Accepted, once."); + + // Part 3: nothing is draining this service, so a reading that was only queued has not been written yet + report.Log($"Right after the burst, Processed reads {ingest.Processed} and QueueDepth reads {ingest.QueueDepth}"); + + report.Expect("Right after the burst, nothing has been written to the store yet", 0, ingest.Processed, _countedAsWrittenHint); + report.Expect($"Right after the burst, all {_readingCount} readings are waiting in the queue", _readingCount, ingest.QueueDepth, "Accepting a reading has to put it in the queue, where a consumer can pick it up later."); + + // Part 4: the return type. A ValueTask that completes synchronously allocates nothing, and this call is made constantly. + var acceptAsync = typeof(TelemetryIngestService).GetMethod(nameof(TelemetryIngestService.AcceptAsync), [typeof(TelemetryEvent), typeof(CancellationToken)]); + + report.Expect( + "AcceptAsync returns ValueTask", + nameof(ValueTask), + acceptAsync?.ReturnType.Name ?? "no AcceptAsync(TelemetryEvent, CancellationToken) method", + acceptAsync?.ReturnType == typeof(ValueTask), + "Queuing a reading almost always completes synchronously, and a fleet of devices calls this constantly. Return the task type that allocates nothing when the call completes synchronously."); + } +} \ No newline at end of file diff --git a/9. Channels/1. Start/TelemetryPipeline/Steps/Step2QueueIntoBoundedChannel.cs b/9. Channels/1. Start/TelemetryPipeline/Steps/Step2QueueIntoBoundedChannel.cs new file mode 100644 index 0000000..548216f --- /dev/null +++ b/9. Channels/1. Start/TelemetryPipeline/Steps/Step2QueueIntoBoundedChannel.cs @@ -0,0 +1,241 @@ +using System.Reflection; +using System.Threading.Channels; +using TelemetryPipeline.Components.Pages; + +namespace TelemetryPipeline; + +public sealed class Step2QueueIntoBoundedChannel : WorkshopStep +{ + const int _maximumWrites = 10_000; + const int _deviceCount = 16; + + const BindingFlags _allFields = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; + + const string _channelHint = "The queue has to be thread safe, have a capacity, and be awaitable, so a full queue can make a device wait and an empty one can make a consumer wait without polling. " + + "System.Threading.Channels has exactly that type, and it ships with .NET."; + + const string _listHint = "List is not thread safe, and nothing limits how large it can grow. Replace it with the channel, rather than keeping both."; + + const string _capacityHint = "The queue made a device wait before the whole burst fit. Pick a capacity large enough to hold the whole 400 reading burst, so a burst is absorbed without anyone waiting."; + + const string _unboundedHint = "Nothing limits how large the queue can grow, so a big enough burst is an out of memory exception. Create the queue with a capacity, and make a full queue wait for room."; + + const string _droppedHint = "The queue stayed at its capacity while every write still completed right away, so readings were thrown away with no exception and no log line. " + + "Choose the full mode that makes the producer wait instead of losing readings."; + + const string _skippedHint = "No write ever waited for room, so there was no waiting write to check. Make a full queue wait first."; + + const string _lostReadingsHint = "Readings went missing, or a write threw, when many devices posted at the same time. " + + "Let the thread safe queue take the readings, update the counter atomically, and do not promise the channel a single writer when many devices write."; + + static readonly TimeSpan _writeTimeout = TimeSpan.FromSeconds(2); + + public override int Number => 2; + + public override string Scenario => "Queue into a bounded channel"; + + public override string Title => "The queue, Accepted, Processed and QueueDepth"; + + public override string Story => "The endpoint answers right away now, but it is answering into a List. Every browser session shares this one singleton, so many devices add to that list at once while the drain loop removes from it on another thread. " + + "List is not thread safe: readings go missing, or the list throws. Nothing limits how large it can grow, so a big enough burst is an out of memory exception. " + + "And nothing can wait on it, which is why the drain loop has to poll."; + + public override string SeeItInTheApp => "This one hides from a single click. Once Step 1 passes, press Receive 400 events on the Ingest page and Accepted still reads 400 of 400, because the page posts one reading at a time. " + + "The list only breaks when many devices post at the same moment, which is exactly what this step does: 16 of them, at the same instant."; + + public override string FileToChange => "Services/TelemetryIngestService.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Add a using for the namespace that holds Channel.", + "Replace the List with a queue that is thread safe, has a capacity you choose, and can be awaited. Add a capacity constant large enough to hold the whole 400 reading burst.", + "Decide what a full queue should do to the device that is writing, and pick the option that makes the producer wait instead of losing readings.", + "Be honest in the options about how many producers and consumers there really are. Many devices write, and you are about to run more than one consumer.", + "Change AcceptAsync so it writes the reading to the queue, passes its CancellationToken along, and counts the reading as accepted only once it is queued.", + "Make the counters safe for a producer and several consumers to touch at once, and make sure the Blazor page reads the latest value rather than one cached in a register.", + "Report QueueDepth from the queue itself instead of a list count.", + "DrainAsync, Reset() and RunBurstAsync still use the list, so the project will not build until they stop. Step 3 rewrites DrainAsync and removes Reset(), so a quick change is fine for now.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "_pending is a List that the accept path writes while DrainAsync reads it. List is not thread safe, so items can be lost and the list can throw.", + "Nothing limits how large _pending can grow. A big enough burst is an out of memory exception.", + "_accepted++ and _processed++ are not atomic, and TelemetryIngestService is a singleton shared by every browser session.", + "Channel.CreateBounded takes an options object with a capacity, a full mode, and two promises about readers and writers. The promises are not enforced: break one and nothing throws, it corrupts.", + "Interlocked updates an int atomically, and Volatile reads one so the value is never stale.", + ]; + + public override string TimeoutHint => "Writing to the queue never finished. Check that AcceptAsync passes its CancellationToken to the write, so a device that gives up can stop waiting."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + // Part 1: the queue itself. Reflection lists the fields TelemetryIngestService declares. + var fields = typeof(TelemetryIngestService).GetFields(_allFields); + var channelField = fields.FirstOrDefault(static field => typeof(Channel).IsAssignableFrom(field.FieldType)); + var listField = fields.FirstOrDefault(static field => typeof(List).IsAssignableFrom(field.FieldType)); + + report.Log($"TelemetryIngestService declares these fields: {string.Join(", ", fields.Select(static field => field.Name))}"); + + report.Expect("TelemetryIngestService queues readings in a Channel", "a Channel field", channelField?.Name ?? "none", channelField is not null, _channelHint); + report.Expect("No List is left", "none", listField?.Name ?? "none", _listHint); + + // Part 2: fill a fresh queue until a write waits for room, or until 10,000 readings, 25 times the burst, fit without anyone waiting + var fullIngest = new TelemetryIngestService(new EventStore()); + using var waitingWriteCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + + report.Log($"Posting readings one at a time until a write waits for room, up to {_maximumWrites:N0}"); + + Task? waitingWrite = null; + var completedWrites = 0; + + while (waitingWrite is null && completedWrites < _maximumWrites) + { + var write = PostReading(fullIngest, completedWrites, waitingWriteCancellationTokenSource.Token); + + if (write.IsCompleted) + { + // Rethrows anything AcceptAsync threw + await write.ConfigureAwait(false); + completedWrites++; + } + else + { + waitingWrite = write; + } + } + + var queueDepth = fullIngest.QueueDepth; + + report.Log(waitingWrite is null + ? $"All {completedWrites:N0} writes completed right away, and QueueDepth reads {queueDepth:N0}" + : $"{completedWrites:N0} writes completed right away, then write {completedWrites + 1:N0} waited for room. QueueDepth reads {queueDepth:N0}"); + + report.Expect( + $"The queue holds the whole {IngestPageBase.EventCount} reading burst without making a device wait", + $"{IngestPageBase.EventCount} of {IngestPageBase.EventCount} completed right away", + $"{Math.Min(completedWrites, IngestPageBase.EventCount)} of {IngestPageBase.EventCount} completed right away", + completedWrites >= IngestPageBase.EventCount, + _capacityHint); + + report.Expect( + "A full queue makes the next device wait for room, instead of growing forever or dropping a reading", + "a write waits for room", + waitingWrite is null ? $"{_maximumWrites:N0} writes completed right away, {queueDepth:N0} still queued" : $"write {completedWrites + 1:N0} waited for room", + waitingWrite is not null, + queueDepth < completedWrites ? _droppedHint : _unboundedHint); + + if (waitingWrite is null) + { + report.Expect("When the queue is full, QueueDepth reads its capacity", "the capacity", "Skipped: no write waited for room", false, _skippedHint); + report.Expect("A waiting write stops when the device gives up and cancels it", "cancelled", "Skipped: no write waited for room", false, _skippedHint); + } + else + { + report.Expect("When the queue is full, QueueDepth reads its capacity", completedWrites, queueDepth, "QueueDepth has to come from the queue itself, so it reads how many readings are waiting in it right now."); + + // Part 3: a device that gives up cancels its request. The write waiting for room has to stop, and must not be counted. + report.Log("The device waiting for room gives up and cancels its write"); + await waitingWriteCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + var cancelledWrite = await Outcome(waitingWrite, token).ConfigureAwait(false); + report.Log($"The waiting write: {cancelledWrite}"); + + report.Expect("A waiting write stops when the device gives up and cancels it", "cancelled", cancelledWrite, "Pass the CancellationToken AcceptAsync receives to the write, so a device that gives up stops waiting for room."); + report.Expect("The cancelled reading is not counted as accepted", completedWrites, fullIngest.Accepted, "Count a reading as accepted only after the write to the queue has completed."); + } + + // Part 4: 16 devices wait at a Barrier, then each posts its share of the burst at the same instant + var sharedIngest = new TelemetryIngestService(new EventStore()); + var readingsPerDevice = IngestPageBase.EventCount / _deviceCount; + var writes = new Task[_deviceCount * readingsPerDevice]; + + using var sharedWriteCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + using var barrier = new Barrier(_deviceCount); + + report.Log($"{_deviceCount} devices are each posting {readingsPerDevice} readings at the same instant"); + + var devices = Enumerable.Range(0, _deviceCount) + .Select(device => new Thread(() => + { + barrier.SignalAndWait(); + + // PostReading never throws. Anything AcceptAsync throws is kept in the Task, so it cannot end the app from this thread. + for (var reading = 0; reading < readingsPerDevice; reading++) + { + var eventId = (device * readingsPerDevice) + reading; + writes[eventId] = PostReading(sharedIngest, eventId, sharedWriteCancellationTokenSource.Token); + } + })) + .ToList(); + + devices.ForEach(static device => device.Start()); + devices.ForEach(static device => device.Join()); + + await Task.WhenAny(Task.WhenAll(writes), Task.Delay(_writeTimeout, token)).ConfigureAwait(false); + await sharedWriteCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + var failedWrites = writes.Where(static write => !write.IsCompletedSuccessfully).ToList(); + + foreach (var failure in failedWrites.Select(static write => write.IsCanceled ? "cancelled while waiting for room" : write.Exception?.InnerException?.GetType().Name ?? "still waiting").GroupBy(static failure => failure)) + { + report.Log($"{failure.Count()} writes did not complete: {failure.Key}"); + } + + report.Log($"Accepted reads {sharedIngest.Accepted}, and QueueDepth reads {sharedIngest.QueueDepth}"); + + // A write that never faulted and did not complete was waiting for room, which is a capacity problem rather than a lost reading + var waitedForRoom = failedWrites.Any(static write => !write.IsFaulted); + + report.Expect( + $"{_deviceCount} devices posting at the same instant have all {IngestPageBase.EventCount} readings accepted", + $"{IngestPageBase.EventCount} accepted, {IngestPageBase.EventCount} queued", + $"{sharedIngest.Accepted} accepted, {sharedIngest.QueueDepth} queued", + sharedIngest.Accepted == IngestPageBase.EventCount && sharedIngest.QueueDepth == IngestPageBase.EventCount && failedWrites.Count is 0, + waitedForRoom ? _capacityHint : _lostReadingsHint); + + // Part 5: a lost increment is rare with only 400 readings, so the code is checked too. The page reads these counters while consumers update them. + var acceptAsync = typeof(TelemetryIngestService).GetMethod(nameof(TelemetryIngestService.AcceptAsync), [typeof(TelemetryEvent), typeof(CancellationToken)]); + var acceptCounter = acceptAsync is null ? null : CodeInspector.GetCalledMethods(acceptAsync).FirstOrDefault(static call => call.StartsWith("Interlocked.", StringComparison.Ordinal)); + + report.Expect( + "AcceptAsync counts accepted readings with Interlocked", + "Interlocked", + acceptCounter ?? "no Interlocked call", + acceptCounter is not null, + "_accepted++ is a read, an add, and a write, and many devices call AcceptAsync at once. Update the counter atomically."); + + string[] counters = [nameof(TelemetryIngestService.Accepted), nameof(TelemetryIngestService.Processed)]; + var volatileCounters = counters + .Where(static counter => typeof(TelemetryIngestService).GetProperty(counter)?.GetMethod is { } getter && CodeInspector.GetCalledMethods(getter).Contains("Volatile.Read")) + .ToList(); + + report.Expect( + "Accepted and Processed read their counters with Volatile.Read", + string.Join(", ", counters), + volatileCounters.Count is 0 ? "neither" : string.Join(", ", volatileCounters), + volatileCounters.Count == counters.Length, + "An int read can be stale. The Blazor page reads these counters while consumers on other threads update them, so read the latest value rather than one cached in a register."); + } + + // Waits a short while for a write that should stop, and describes how it ended + static async Task Outcome(Task write, CancellationToken token) + { + try + { + await write.WaitAsync(_writeTimeout, token).ConfigureAwait(false); + return "completed"; + } + catch (OperationCanceledException) when (!token.IsCancellationRequested) + { + return "cancelled"; + } + catch (TimeoutException) + { + return $"was still waiting after {_writeTimeout.TotalSeconds} seconds"; + } + } +} \ No newline at end of file diff --git a/9. Channels/1. Start/TelemetryPipeline/Steps/Step3DrainWithSeveralConsumers.cs b/9. Channels/1. Start/TelemetryPipeline/Steps/Step3DrainWithSeveralConsumers.cs new file mode 100644 index 0000000..0897bb3 --- /dev/null +++ b/9. Channels/1. Start/TelemetryPipeline/Steps/Step3DrainWithSeveralConsumers.cs @@ -0,0 +1,193 @@ +using System.Diagnostics; +using System.Threading.Channels; + +namespace TelemetryPipeline; + +public sealed class Step3DrainWithSeveralConsumers : WorkshopStep +{ + const int _burstSize = 80; + const int _secondBurstSize = 10; + + const string _oneConsumerHint = "One consumer writing one reading at a time clears 25 readings per second, so 80 readings take 3.2 seconds. " + + "Write a consumer that reads the queue until it is complete, saves each reading to EventStore and counts it as processed. Then have DrainAsync run several of them over the same reader, and wait for all of them."; + + const string _returnedEarlyHint = "DrainAsync returned while readings were still waiting. A consumer has to keep reading until the queue is complete, not until it happens to be empty."; + + const string _countedTwiceHint = "More readings were counted as processed than were accepted. Count each reading once, after it reaches the store."; + + const string _cumulativeHint = "The consumers keep draining earlier bursts in the background, so the pipeline outlives any single burst and its counters are cumulative. " + + "Delete Reset() and the call to it at the top of RunBurstAsync."; + + const string _closedHint = "The second burst found the queue closed. Completing the queue closes it for good, so nothing that runs during a burst may complete it. That belongs on the shutdown path, in Step 4."; + + static readonly TimeSpan _idleTime = TimeSpan.FromMilliseconds(200); + static readonly TimeSpan _drainDeadline = TimeSpan.FromSeconds(1.5); + static readonly TimeSpan _drainPatience = TimeSpan.FromSeconds(5); + static readonly TimeSpan _stopTimeout = TimeSpan.FromSeconds(2); + static readonly TimeSpan _pollingInterval = TimeSpan.FromMilliseconds(20); + + public override int Number => 3; + + public override string Scenario => "Drain with several consumers"; + + public override string Title => "DrainAsync() and its consumers"; + + public override string Story => "Readings are queued now, and every device gets its answer right away. But the only thing reading that queue is a loop that polls. " + + "When the queue is empty it sleeps for 25 milliseconds and looks again, forever, whether or not there is anything to do. " + + "And one consumer writing at 40 milliseconds per reading clears 25 readings per second, so a 400 reading burst takes 16 seconds to reach the store. " + + "That is the latency you just took away from the devices, moved somewhere nobody is watching."; + + public override string SeeItInTheApp => "Restart the app, receive 400 events on the Ingest page, and click Refresh counters once a second. " + + "Written to store should climb while the waiting count falls, and reach 400 written with 0 waiting about two seconds after the burst. " + + "The counters keep counting for the life of the app, so restart it before you measure a fresh burst."; + + public override string FileToChange => "Services/TelemetryIngestService.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Write a private consumer method that reads the queue until it is complete and saves each reading to EventStore.", + "Count each reading as processed once it reaches the store, safely, because several consumers update that counter at once.", + "Change DrainAsync so it runs several consumers over the same reader and waits for all of them. Add a consumer count constant. 8 is a good starting point.", + "Pass DrainAsync's CancellationToken on to every consumer, so cancelling it stops them.", + "Delete the polling loop and the Task.Delay inside it.", + "Delete Reset() and the call to Reset() at the top of RunBurstAsync. The consumers keep draining earlier bursts in the background, so the counters are cumulative now.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "DrainAsync polls. When the queue is empty it sleeps for 25 milliseconds and looks again, forever, whether or not there is anything to do. This periodic polling adds latency and wakes the loop even when no work exists.", + "A channel's reader can hand you every reading as an IAsyncEnumerable. It waits without holding a thread while the queue is empty, and it ends once the queue is complete and drained.", + "One consumer at 40 milliseconds per write can only clear 25 readings per second. Several consumers can share one reader, and each reading is handed to exactly one of them.", + "Task.WhenAll waits for several tasks at once.", + ]; + + public override string TimeoutHint => "DrainAsync never handed control back. Check that it starts every consumer and awaits them, rather than spinning in a loop that never awaits."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + var ingest = new TelemetryIngestService(new EventStore()); + using var drainCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + + // Part 1: TelemetryProcessor starts the consumers with the app, long before the first reading arrives + report.Log("Starting DrainAsync on a fresh service with an empty queue"); + + var drain = ingest.DrainAsync(drainCancellationTokenSource.Token); + + await Task.Delay(_idleTime, token).ConfigureAwait(false); + + // Rethrows anything DrainAsync threw + if (drain.IsFaulted) + await drain.ConfigureAwait(false); + + report.Expect( + "DrainAsync keeps waiting while the queue is empty", + "still running", + drain.IsCompleted ? "returned" : "still running", + "DrainAsync returned as soon as it found the queue empty. Consumers start with the app, before the first reading arrives, so they have to keep waiting for readings until the queue is complete."); + + // Part 2: one consumer at 40 milliseconds per write needs 3.2 seconds for 80 readings. Eight consumers need 0.4 seconds. + var stopwatch = Stopwatch.StartNew(); + + await ingest.RunBurstAsync(_burstSize, token).ConfigureAwait(false); + report.Log($"Received a burst of {_burstSize} readings in {stopwatch.Elapsed.TotalSeconds:F2}s. Waiting for the consumers to write them to the store"); + + await WaitForProcessed(ingest, drain, _burstSize, _drainPatience, token).ConfigureAwait(false); + stopwatch.Stop(); + + var processed = ingest.Processed; + var elapsed = stopwatch.Elapsed; + + report.Log($"{processed} of {_burstSize} readings were written after {elapsed.TotalSeconds:F2}s, and QueueDepth reads {ingest.QueueDepth}{(drain.IsCompleted ? ". DrainAsync has returned" : string.Empty)}"); + + report.Expect( + $"All {_burstSize} readings are written within {_drainDeadline.TotalSeconds} seconds", + $"{_burstSize} written within {_drainDeadline.TotalSeconds}s", + $"{processed} written after {elapsed.TotalSeconds:F2}s", + processed == _burstSize && elapsed <= _drainDeadline, + processed > _burstSize ? _countedTwiceHint : drain.IsCompleted ? _returnedEarlyHint : _oneConsumerHint); + + report.Expect("QueueDepth is back to 0", 0, ingest.QueueDepth, "Readings are still waiting in the queue. Every consumer has to keep reading until the queue is complete, not until it happens to be empty."); + + // Part 3: the pipeline outlives any single burst, so a second burst arrives while the consumers are still running + var expectedTotal = _burstSize + _secondBurstSize; + var secondBurstHint = _cumulativeHint; + string secondBurst; + + report.Log($"Receiving a second burst of {_secondBurstSize} readings on the same service"); + + try + { + await ingest.RunBurstAsync(_secondBurstSize, token).ConfigureAwait(false); + await WaitForProcessed(ingest, drain, expectedTotal, _stopTimeout, token).ConfigureAwait(false); + + secondBurst = $"{ingest.Accepted} accepted, {ingest.Processed} written"; + } + catch (ChannelClosedException) + { + secondBurst = $"threw {nameof(ChannelClosedException)}"; + secondBurstHint = _closedHint; + } + + report.Log($"After the second burst: {secondBurst}"); + report.Expect("A second burst adds to the counters while the consumers keep running", $"{expectedTotal} accepted, {expectedTotal} written", secondBurst, secondBurstHint); + + // Part 4: polling and a lost increment are hard to see from outside, so the code is checked too + var calls = CodeInspector.GetCalledMethods(typeof(TelemetryIngestService)); + var delays = calls.Count(static call => call is "Task.Delay"); + + report.Expect( + "No Task.Delay is left in TelemetryIngestService", + "none", + delays is 0 ? "none" : $"{delays} calls", + "Waiting for the next reading should be an await on the queue, not a sleep in a loop. Delete the polling loop and the Task.Delay inside it."); + + var acceptAsync = typeof(TelemetryIngestService).GetMethod(nameof(TelemetryIngestService.AcceptAsync), [typeof(TelemetryEvent), typeof(CancellationToken)]); + var acceptCounterUpdates = acceptAsync is null ? 0 : CodeInspector.GetCalledMethods(acceptAsync).Count(IsCounterUpdate); + var consumerCounterUpdates = calls.Count(IsCounterUpdate) - acceptCounterUpdates; + + report.Expect( + "The consumers count processed readings with Interlocked", + "Interlocked", + consumerCounterUpdates > 0 ? "Interlocked" : "no Interlocked call outside AcceptAsync", + "_processed++ is a read, an add, and a write, and several consumers update it at once. Update the counter atomically."); + + // Part 5: cancelling DrainAsync's token has to stop every consumer + report.Log("Cancelling DrainAsync's CancellationToken"); + await drainCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + string stopped; + + try + { + await drain.WaitAsync(_stopTimeout, token).ConfigureAwait(false); + stopped = "stopped"; + } + catch (OperationCanceledException) when (drainCancellationTokenSource.IsCancellationRequested && !token.IsCancellationRequested) + { + stopped = "stopped"; + } + catch (TimeoutException) + { + stopped = $"still running after {_stopTimeout.TotalSeconds} seconds"; + } + + report.Log($"DrainAsync: {stopped}"); + report.Expect("DrainAsync stops when its CancellationToken is cancelled", "stopped", stopped, "Pass the CancellationToken DrainAsync receives on to every consumer, and from there to the read and to the write to the store."); + } + + static bool IsCounterUpdate(string call) => call is "Interlocked.Increment" or "Interlocked.Add"; + + // Checks the counter the way the Ingest page's Refresh counters button does, until it reaches the target, DrainAsync returns, or patience runs out + static async Task WaitForProcessed(TelemetryIngestService ingest, Task drain, int target, TimeSpan patience, CancellationToken token) + { + var stopwatch = Stopwatch.StartNew(); + + while (ingest.Processed < target && !drain.IsCompleted && stopwatch.Elapsed < patience) + { + await Task.Delay(_pollingInterval, token).ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/9. Channels/1. Start/TelemetryPipeline/Steps/Step4DrainBacklogOnShutdown.cs b/9. Channels/1. Start/TelemetryPipeline/Steps/Step4DrainBacklogOnShutdown.cs new file mode 100644 index 0000000..c059d70 --- /dev/null +++ b/9. Channels/1. Start/TelemetryPipeline/Steps/Step4DrainBacklogOnShutdown.cs @@ -0,0 +1,142 @@ +using System.Diagnostics; +using System.Threading.Channels; + +namespace TelemetryPipeline; + +public sealed class Step4DrainBacklogOnShutdown : WorkshopStep +{ + const int _backlogSize = 40; + + const string _abandonedHint = "StopAsync returned before the backlog reached the store. The host cancels stoppingToken at shutdown, and cancelling the read loop abandons every reading still waiting in the queue. " + + "Stop passing stoppingToken into DrainAsync, and complete the queue in StopAsync instead, so the consumers finish the backlog and then stop on their own."; + + const string _neverCompletedHint = "StopAsync waited until the shutdown timeout ran out, because nothing told the consumers that no more readings are coming, so they kept waiting for the next one. " + + "Override StopAsync in TelemetryProcessor, and complete the queue before you call the base implementation."; + + const string _stillOpenHint = "A reading that arrives during shutdown was queued where no consumer will ever read it, and the device was told everything is fine. " + + "StopAsync has to complete the queue, so a late reading is refused with ChannelClosedException instead."; + + // Stands in for the host's shutdown timeout, so a pipeline that never finishes still lets this step end + static readonly TimeSpan _shutdownTimeout = TimeSpan.FromSeconds(5); + static readonly TimeSpan _writeTimeout = TimeSpan.FromSeconds(2); + + public override int Number => 4; + + public override string Scenario => "Drain the backlog on shutdown"; + + public override string Title => "CompleteWriting() and TelemetryProcessor"; + + public override string Story => "A deployment restarts the app while a burst is still waiting in the queue. Every one of those readings was accepted, and every device was told it was safe. " + + "Nothing ever tells DrainAsync that the producer is finished: the only thing that stops it is cancellation at shutdown, and cancelling the read loop throws the whole backlog away. " + + "The readings never reach the store, and nothing logs a thing."; + + public override string SeeItInTheApp => "Once Step 3 passes, click Receive 400 events on the Ingest page, and press Ctrl+C in the terminal straight away, while readings are still waiting in the queue. " + + "Today the app quits instantly and the backlog is gone. Once this step passes, the app pauses for roughly two seconds before it exits, because it is finishing the backlog first."; + + public override string FileToChange => "Services/TelemetryIngestService.cs, Services/TelemetryProcessor.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Implement CompleteWriting() in TelemetryIngestService so it marks the queue complete. Its stub throws NotImplementedException until you do.", + "Do not call CompleteWriting() from RunBurstAsync. Completing the queue closes it for good, and the next burst would throw ChannelClosedException.", + "In TelemetryProcessor, override StopAsync and complete the queue before you call the base implementation.", + "Stop passing stoppingToken into the read loop. Cancelling that loop is exactly what abandons the queued readings.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "Nothing ever tells DrainAsync that the producer is finished. The only thing that stops it today is cancellation at shutdown.", + "Once the writer is complete, the reader hands out what is still buffered and then ends on its own.", + "BackgroundService.StopAsync cancels stoppingToken and then waits for ExecuteAsync to return, for as long as the host's shutdown timeout allows.", + "More than one code path could reasonably close a channel. The writer has a completion method that returns false instead of throwing when the channel is already complete.", + ]; + + public override string TimeoutHint => "Shutting down TelemetryProcessor never finished. Check that StopAsync completes the queue before it waits for the read loop."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + // Part 1: CompleteWriting() on its own, on a fresh service. A stub that still throws NotImplementedException ends the step here. + var closedIngest = new TelemetryIngestService(new EventStore()); + + report.Log("Calling CompleteWriting() on a fresh service, then posting a reading"); + closedIngest.CompleteWriting(); + + var lateReading = await PostLateReading(closedIngest, token).ConfigureAwait(false); + + report.Expect( + "After CompleteWriting(), AcceptAsync refuses a reading with ChannelClosedException", + nameof(ChannelClosedException), + lateReading, + "CompleteWriting() has to mark the queue complete, so it refuses every later reading and the consumers stop once the backlog is written."); + + // Part 2: the app starts TelemetryProcessor, a burst is queued, and the app shuts down straight away + var ingest = new TelemetryIngestService(new EventStore()); + using var processor = new TelemetryProcessor(ingest); + + await processor.StartAsync(token).ConfigureAwait(false); + await ingest.RunBurstAsync(_backlogSize, token).ConfigureAwait(false); + + report.Log($"TelemetryProcessor is running. Stopping it straight after a burst of {_backlogSize}, with {ingest.QueueDepth} readings still waiting and {ingest.Processed} written"); + + using var shutdownCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + shutdownCancellationTokenSource.CancelAfter(_shutdownTimeout); + + var stopwatch = Stopwatch.StartNew(); + + try + { + await processor.StopAsync(shutdownCancellationTokenSource.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (shutdownCancellationTokenSource.IsCancellationRequested && !token.IsCancellationRequested) + { + // The shutdown timeout ran out, which the checks below report + } + + stopwatch.Stop(); + + var processedAtStop = ingest.Processed; + var readLoopFinished = processor.ExecuteTask?.IsCompleted is true; + + report.Log($"StopAsync returned after {stopwatch.Elapsed.TotalSeconds:F2}s with {processedAtStop} of {_backlogSize} readings written. The read loop {(readLoopFinished ? "has finished" : "is still running")}"); + + report.Expect( + $"StopAsync returns once the read loop finishes, within the {_shutdownTimeout.TotalSeconds} second shutdown timeout", + "True", + readLoopFinished.ToString(), + readLoopFinished, + _neverCompletedHint); + + report.Expect( + "Every reading still waiting at shutdown is written before StopAsync returns", + _backlogSize, + processedAtStop, + readLoopFinished ? _abandonedHint : _neverCompletedHint); + + // Part 3: a device posting during shutdown is refused, because the app can no longer promise to store its reading + var readingDuringShutdown = await PostLateReading(ingest, token).ConfigureAwait(false); + + report.Log($"A reading posted after StopAsync: {readingDuringShutdown}"); + report.Expect("After StopAsync, AcceptAsync refuses a reading with ChannelClosedException", nameof(ChannelClosedException), readingDuringShutdown, _stillOpenHint); + } + + // Posts one reading and describes how it ended + static async Task PostLateReading(TelemetryIngestService ingest, CancellationToken token) + { + try + { + await PostReading(ingest, 0, token).WaitAsync(_writeTimeout, token).ConfigureAwait(false); + return "accepted"; + } + catch (ChannelClosedException) + { + return nameof(ChannelClosedException); + } + catch (TimeoutException) + { + return $"still waiting after {_writeTimeout.TotalSeconds} seconds"; + } + } +} \ No newline at end of file diff --git a/9. Channels/1. Start/TelemetryPipeline/Steps/StepContext.cs b/9. Channels/1. Start/TelemetryPipeline/Steps/StepContext.cs new file mode 100644 index 0000000..99c9971 --- /dev/null +++ b/9. Channels/1. Start/TelemetryPipeline/Steps/StepContext.cs @@ -0,0 +1,4 @@ +namespace TelemetryPipeline; + +// Token is cancelled when the run is stopped, times out, or the app shuts down +public sealed record StepContext(StepReport Report, CancellationToken Token); \ No newline at end of file diff --git a/9. Channels/1. Start/TelemetryPipeline/Steps/WorkshopStep.cs b/9. Channels/1. Start/TelemetryPipeline/Steps/WorkshopStep.cs new file mode 100644 index 0000000..4f736cf --- /dev/null +++ b/9. Channels/1. Start/TelemetryPipeline/Steps/WorkshopStep.cs @@ -0,0 +1,47 @@ +namespace TelemetryPipeline; + +// Every step is one problem in TelemetryPipeline's ingest path. +// Its tab in the workshop guide tells you what to fix, and Run() drives your code the way the device fleet does, logs what happens, +// and records an expected result, with a hint, for everything that should be true once the problem is fixed. +public abstract class WorkshopStep +{ + public abstract int Number { get; } + + // What this step fixes, in a few words + public abstract string Scenario { get; } + + // The members this step exercises + public abstract string Title { get; } + + // What goes wrong in production, and why nobody noticed with one device at a time + public abstract string Story { get; } + + // How to watch this problem happen on the Ingest page + public abstract string SeeItInTheApp { get; } + + // Where the problem lives + public abstract string FileToChange { get; } + + // What this step asks you to change. Some of it cannot be measured from outside, so not every task has an expected result. + public abstract IReadOnlyList Tasks { get; } + + // Shown only when you ask for them + public abstract IReadOnlyList Clues { get; } + + // What is most likely waiting forever when this step does not finish in time + public abstract string TimeoutHint { get; } + + public string SourceFile => $"Steps/{GetType().Name}.cs"; + + public abstract Task Run(StepContext context); + + // A device posts one reading. AcceptAsync returns Task in the starter code and ValueTask once you change it, + // and awaiting it inside this async method works for both. The Task this returns is already complete + // when the reading was queued synchronously, and an exception from AcceptAsync is stored in it rather than thrown. + protected static async Task PostReading(TelemetryIngestService ingest, int eventId, CancellationToken token) + { + var reading = new TelemetryEvent(eventId, $"device-{eventId % 20}", eventId * 0.37, DateTimeOffset.UtcNow); + + await ingest.AcceptAsync(reading, token).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/9. Channels/1. Start/TelemetryPipeline/Verification/ActiveStepRun.cs b/9. Channels/1. Start/TelemetryPipeline/Verification/ActiveStepRun.cs new file mode 100644 index 0000000..d61b068 --- /dev/null +++ b/9. Channels/1. Start/TelemetryPipeline/Verification/ActiveStepRun.cs @@ -0,0 +1,3 @@ +namespace TelemetryPipeline; + +public sealed record ActiveStepRun(WorkshopStep Step, StepReport Report); \ No newline at end of file diff --git a/9. Channels/1. Start/TelemetryPipeline/Verification/CodeInspector.cs b/9. Channels/1. Start/TelemetryPipeline/Verification/CodeInspector.cs new file mode 100644 index 0000000..355f0f7 --- /dev/null +++ b/9. Channels/1. Start/TelemetryPipeline/Verification/CodeInspector.cs @@ -0,0 +1,104 @@ +using System.Reflection; +using System.Reflection.Emit; +using System.Runtime.CompilerServices; + +namespace TelemetryPipeline; + +// Workshop plumbing: lists the methods your compiled code calls, formatted as "Type.Method". +// The steps use it only for requirements that no behavior can show, such as reading a counter with Volatile.Read. +// It reads the IL, so it also sees calls inside lambdas, local functions and async state machines. +public static class CodeInspector +{ + const BindingFlags _declaredMembers = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + + // Every opcode, keyed by its value. Two-byte opcodes start with 0xFE. + static readonly Dictionary _opCodes = typeof(OpCodes) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Select(static field => (OpCode)(field.GetValue(null) ?? throw new InvalidOperationException($"OpCodes.{field.Name} has no value"))) + .ToDictionary(static opCode => opCode.Value); + + // Every call in a type and in the types nested inside it. A method called in two places appears twice. + public static IReadOnlyList GetCalledMethods(Type type) + { + var calls = new List(); + + AddCalls(type, calls); + + return calls; + } + + // Every call in one method, including the async state machine the compiler generated for it + public static IReadOnlyList GetCalledMethods(MethodInfo method) + { + var calls = new List(); + + AddCalls(method, calls); + + if (method.GetCustomAttribute() is { } stateMachine) + AddCalls(stateMachine.StateMachineType, calls); + + return calls; + } + + static void AddCalls(Type type, List calls) + { + foreach (var method in type.GetMethods(_declaredMembers)) + { + AddCalls(method, calls); + } + + foreach (var constructor in type.GetConstructors(_declaredMembers)) + { + AddCalls(constructor, calls); + } + + foreach (var nestedType in type.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic)) + { + AddCalls(nestedType, calls); + } + } + + static void AddCalls(MethodBase method, List calls) + { + if (method.GetMethodBody()?.GetILAsByteArray() is not { } il) + return; + + var typeArguments = method.DeclaringType is { IsGenericType: true } declaringType ? declaringType.GetGenericArguments() : null; + var methodArguments = method.IsGenericMethod ? method.GetGenericArguments() : null; + var position = 0; + + while (position < il.Length) + { + var opCode = il[position] is 0xFE + ? _opCodes[unchecked((short)(0xFE00 | il[position + 1]))] + : _opCodes[il[position]]; + + position += opCode.Size; + + if (opCode.OperandType is OperandType.InlineMethod) + { + try + { + var token = BitConverter.ToInt32(il, position); + + if (method.Module.ResolveMethod(token, typeArguments, methodArguments) is { DeclaringType: { } calledType } calledMethod) + calls.Add($"{calledType.Name}.{calledMethod.Name}"); + } + catch (ArgumentException) + { + // A token this module cannot resolve is not a call to anything the steps look for + } + } + + position += opCode.OperandType switch + { + OperandType.InlineNone => 0, + OperandType.ShortInlineBrTarget or OperandType.ShortInlineI or OperandType.ShortInlineVar => 1, + OperandType.InlineVar => 2, + OperandType.InlineI8 or OperandType.InlineR => 8, + OperandType.InlineSwitch => 4 + (BitConverter.ToInt32(il, position) * 4), + _ => 4, + }; + } + } +} \ No newline at end of file diff --git a/9. Channels/1. Start/TelemetryPipeline/Verification/StepCheck.cs b/9. Channels/1. Start/TelemetryPipeline/Verification/StepCheck.cs new file mode 100644 index 0000000..03ed2ed --- /dev/null +++ b/9. Channels/1. Start/TelemetryPipeline/Verification/StepCheck.cs @@ -0,0 +1,3 @@ +namespace TelemetryPipeline; + +public sealed record StepCheck(string Description, string Expected, string Actual, bool Passed, string Hint); \ No newline at end of file diff --git a/9. Channels/1. Start/TelemetryPipeline/Verification/StepLogEntry.cs b/9. Channels/1. Start/TelemetryPipeline/Verification/StepLogEntry.cs new file mode 100644 index 0000000..ddd3c51 --- /dev/null +++ b/9. Channels/1. Start/TelemetryPipeline/Verification/StepLogEntry.cs @@ -0,0 +1,3 @@ +namespace TelemetryPipeline; + +public sealed record StepLogEntry(TimeSpan Elapsed, int ThreadId, bool IsThreadPoolThread, string Message); \ No newline at end of file diff --git a/9. Channels/1. Start/TelemetryPipeline/Verification/StepReport.cs b/9. Channels/1. Start/TelemetryPipeline/Verification/StepReport.cs new file mode 100644 index 0000000..22357b3 --- /dev/null +++ b/9. Channels/1. Start/TelemetryPipeline/Verification/StepReport.cs @@ -0,0 +1,82 @@ +using System.Diagnostics; + +namespace TelemetryPipeline; + +// Collects what a step logged and every expected result it checked. +// Steps write to it from thread pool threads while the workshop guide reads it, so every member takes the lock. +public sealed class StepReport +{ + readonly Lock _lock = new(); + readonly Stopwatch _stopwatch = Stopwatch.StartNew(); + readonly List _entries = []; + readonly List _checks = []; + + bool _isComplete; + + public IReadOnlyList Entries + { + get + { + lock (_lock) + { + return [.. _entries]; + } + } + } + + public IReadOnlyList Checks + { + get + { + lock (_lock) + { + return [.. _checks]; + } + } + } + + public void Log(string message) + { + var threadId = Environment.CurrentManagedThreadId; + var isThreadPoolThread = Thread.CurrentThread.IsThreadPoolThread; + + lock (_lock) + { + if (!_isComplete) + _entries.Add(new StepLogEntry(_stopwatch.Elapsed, threadId, isThreadPoolThread, message)); + } + } + + // The hint is shown under the expected result only when it does not match + public void Expect(string description, T expected, T actual, string hint) + { + Expect(description, Format(expected), Format(actual), EqualityComparer.Default.Equals(expected, actual), hint); + } + + public void Expect(string description, string expected, string actual, bool passed, string hint) + { + lock (_lock) + { + if (!_isComplete) + _checks.Add(new StepCheck(description, expected, actual, passed, hint)); + } + } + + // Called when the run ends, including a run that timed out or was stopped while its step is still blocked somewhere. + // Anything that abandoned step writes later is ignored, so the result in the workshop guide can no longer change. + public void Complete() + { + lock (_lock) + { + _isComplete = true; + _stopwatch.Stop(); + } + } + + static string Format(T value) => value switch + { + null => "null", + int or long => $"{value:N0}", + _ => value.ToString() ?? "null", + }; +} \ No newline at end of file diff --git a/9. Channels/1. Start/TelemetryPipeline/Verification/StepState.cs b/9. Channels/1. Start/TelemetryPipeline/Verification/StepState.cs new file mode 100644 index 0000000..005f77c --- /dev/null +++ b/9. Channels/1. Start/TelemetryPipeline/Verification/StepState.cs @@ -0,0 +1,10 @@ +namespace TelemetryPipeline; + +// MissingMember is set for NotImplemented and ExceptionType for Crashed. Only names are kept: exception messages and stack traces stay in the terminal. +// CompletedAt is when the run that produced this state ended, so the workshop guide can show that a click really ran again. +public sealed record StepState( + StepStatus Status, + StepReport? Report, + string? MissingMember = null, + string? ExceptionType = null, + DateTimeOffset? CompletedAt = null); \ No newline at end of file diff --git a/9. Channels/1. Start/TelemetryPipeline/Verification/StepStatus.cs b/9. Channels/1. Start/TelemetryPipeline/Verification/StepStatus.cs new file mode 100644 index 0000000..18d9055 --- /dev/null +++ b/9. Channels/1. Start/TelemetryPipeline/Verification/StepStatus.cs @@ -0,0 +1,13 @@ +namespace TelemetryPipeline; + +public enum StepStatus +{ + NotRun, + Running, + Passed, + Failed, + NotImplemented, + TimedOut, + Crashed, + Stopped, +} \ No newline at end of file diff --git a/9. Channels/1. Start/TelemetryPipeline/Verification/StepVerifier.cs b/9. Channels/1. Start/TelemetryPipeline/Verification/StepVerifier.cs new file mode 100644 index 0000000..ae3f9c5 --- /dev/null +++ b/9. Channels/1. Start/TelemetryPipeline/Verification/StepVerifier.cs @@ -0,0 +1,266 @@ +namespace TelemetryPipeline; + +// Workshop plumbing: runs the steps against your services and keeps their results. +// Every step creates its own instances of the services, so a check never disturbs the singletons the Ingest page uses. +// Only one step runs at a time, so a step that measures how long something takes never competes with another run. +public sealed class StepVerifier(ILogger logger, IHostApplicationLifetime applicationLifetime) : BackgroundService +{ + public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); + + readonly Lock _lock = new(); + readonly StepState[] _states = [.. Enumerable.Repeat(new StepState(StepStatus.NotRun, null), 4)]; + + // Both start at 1: the app checks every step on startup, and nothing else may run before that finishes + int _isBusy = 1; + int _isCheckingAllSteps = 1; + + ActiveStepRun? _activeRun; + TaskCompletionSource? _stopRequested; + + public IReadOnlyList Steps { get; } = + [ + new Step1AnswerRightAway(), + new Step2QueueIntoBoundedChannel(), + new Step3DrainWithSeveralConsumers(), + new Step4DrainBacklogOnShutdown(), + ]; + + public bool IsBusy => Volatile.Read(ref _isBusy) is 1; + + public bool IsCheckingAllSteps => Volatile.Read(ref _isCheckingAllSteps) is 1; + + public ActiveStepRun? ActiveRun + { + get + { + lock (_lock) + { + return _activeRun; + } + } + } + + public WorkshopStep? FindStep(int number) => Steps.FirstOrDefault(step => step.Number == number); + + public StepState GetState(int number) + { + lock (_lock) + { + return _states[number - 1]; + } + } + + public bool IsUnlocked(int number) => number is 1 || GetState(number - 1).Status is StepStatus.Passed; + + // Re-runs every step, the same way startup does + public async Task CheckAllSteps() + { + if (Interlocked.CompareExchange(ref _isBusy, 1, 0) is not 0) + return; + + Volatile.Write(ref _isCheckingAllSteps, 1); + + try + { + await CheckSteps(applicationLifetime.ApplicationStopping).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isCheckingAllSteps, 0); + Volatile.Write(ref _isBusy, 0); + } + } + + public async Task RunStep(WorkshopStep step) + { + if (!IsUnlocked(step.Number) || Interlocked.CompareExchange(ref _isBusy, 1, 0) is not 0) + return GetState(step.Number); + + try + { + return await Run(step, applicationLifetime.ApplicationStopping).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isBusy, 0); + } + } + + public void StopRun() + { + lock (_lock) + { + _stopRequested?.TrySetResult(); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await CheckSteps(stoppingToken).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isCheckingAllSteps, 0); + Volatile.Write(ref _isBusy, 0); + } + } + + // Finds the member named by a stub's NotImplementedException. + // The stub message is checked first because it survives a rethrow that resets the stack trace. + static string? FindMissingMember(Exception exception) + { + const string stubMessageSuffix = " is not implemented yet"; + + for (var current = exception; current is not null; current = current.InnerException) + { + if (current is not NotImplementedException) + continue; + + var suffixIndex = current.Message.IndexOf(stubMessageSuffix, StringComparison.Ordinal); + + if (suffixIndex > 0) + return current.Message[..suffixIndex]; + + if (current.TargetSite is { DeclaringType: { } declaringType } method && declaringType.Assembly == typeof(StepVerifier).Assembly) + return method.IsSpecialName ? $"{declaringType.Name}.{method.Name.Replace("get_", string.Empty, StringComparison.Ordinal)}" : $"{declaringType.Name}.{method.Name}()"; + } + + return null; + } + + async Task CheckSteps(CancellationToken token) + { + try + { + // Results from before the latest change to the services are not trusted + lock (_lock) + { + Array.Fill(_states, new StepState(StepStatus.NotRun, null)); + } + + logger.LogInformation("Checking your services against every step. Open the app in your browser: the workshop guide beside the Ingest page shows what to fix next"); + + foreach (var step in Steps) + { + var state = await Run(step, token).ConfigureAwait(false); + + if (state.Status is not StepStatus.Passed) + { + logger.LogInformation("Stopped checking at Step {StepNumber}. Open it in the app to see what to change, with a hint for every result that did not match. Later steps unlock after it passes", step.Number); + return; + } + } + + logger.LogInformation("Every step passed. Pause here for the group review"); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The app is shutting down + } + catch (Exception e) + { + logger.LogError(e, "Checking the steps failed unexpectedly"); + } + } + + async Task Run(WorkshopStep step, CancellationToken token) + { + var report = new StepReport(); + var stopRequested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var previousState = GetState(step.Number); + + lock (_lock) + { + _activeRun = new ActiveStepRun(step, report); + _stopRequested = stopRequested; + _states[step.Number - 1] = new StepState(StepStatus.Running, report); + } + + // Cancelled when the run ends for any reason, so a step that is still working can stop + using var runCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + + var state = previousState; + + try + { + state = await Execute(step, report, stopRequested.Task, runCancellationTokenSource.Token, token).ConfigureAwait(false); + } + finally + { + await runCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + report.Complete(); + + lock (_lock) + { + _activeRun = null; + _stopRequested = null; + _states[step.Number - 1] = state with { CompletedAt = DateTimeOffset.Now }; + } + } + + return state; + } + + async Task Execute(WorkshopStep step, StepReport report, Task stopRequested, CancellationToken runToken, CancellationToken token) + { + try + { + // A dedicated thread runs the step until its first await. If your code blocks it forever, + // a thread pool thread is not lost for the rest of the app's life. + var stepTask = Task.Factory.StartNew(() => step.Run(new StepContext(report, runToken)), token, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); + + var finished = await Task.WhenAny(stepTask, stopRequested).WaitAsync(Timeout, token).ConfigureAwait(false); + + if (finished == stopRequested) + { + logger.LogInformation("Step {StepNumber} was stopped", step.Number); + return new StepState(StepStatus.Stopped, report); + } + + await stepTask.ConfigureAwait(false); + + var checks = report.Checks; + var passedChecks = checks.Count(static check => check.Passed); + var status = checks.Count > 0 && passedChecks == checks.Count ? StepStatus.Passed : StepStatus.Failed; + + logger.LogInformation("Step {StepNumber} {Result} ({PassedChecks}/{TotalChecks} checks)", step.Number, status is StepStatus.Passed ? "passed" : "failed", passedChecks, checks.Count); + + // The hints belong in the terminal too, next to anything the app logged while the step ran + foreach (var check in checks.Where(static check => !check.Passed)) + { + logger.LogWarning("Step {StepNumber}: {Description}. Expected {Expected}, actual {Actual}. Hint: {Hint}", step.Number, check.Description, check.Expected, check.Actual, check.Hint); + } + + return new StepState(status, report); + } + catch (TimeoutException) + { + logger.LogWarning("Step {StepNumber} did not finish within {Timeout}. {TimeoutHint}", step.Number, Timeout, step.TimeoutHint); + return new StepState(StepStatus.TimedOut, report); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + return new StepState(StepStatus.NotRun, null); + } + catch (Exception e) + { + return Classify(step, report, e); + } + } + + StepState Classify(WorkshopStep step, StepReport report, Exception exception) + { + if (FindMissingMember(exception) is { } missingMember) + { + // The stub's message is the hint, so it belongs in the terminal + logger.LogWarning(exception, "Step {StepNumber}: {MissingMember} still throws NotImplementedException", step.Number, missingMember); + return new StepState(StepStatus.NotImplemented, report, missingMember); + } + + logger.LogError(exception, "Step {StepNumber} threw an unexpected exception", step.Number); + return new StepState(StepStatus.Crashed, report, ExceptionType: exception.GetType().Name); + } +} \ No newline at end of file diff --git a/9. Channels/1. Start/TelemetryPipeline/wwwroot/app.css b/9. Channels/1. Start/TelemetryPipeline/wwwroot/app.css index c5a79b2..6445543 100644 --- a/9. Channels/1. Start/TelemetryPipeline/wwwroot/app.css +++ b/9. Channels/1. Start/TelemetryPipeline/wwwroot/app.css @@ -1,20 +1,37 @@ +/* TelemetryPipeline: an IoT fleet control room, with the workshop guide docked beside it as the on-call handover note on a yellow legal pad */ + :root { - --canvas: #eef3f1; - --panel: #ffffff; - --panel-strong: #112a2d; - --ink: #18211f; - --muted: #62706d; - --line: #d7e0dd; - --accent: #f46a1f; - --teal: #0f766e; - --good: #0f8a52; - --bad: #cf202f; - --warn: #c59d35; - --shadow: 0 18px 55px rgb(28 45 42 / 14%); - color-scheme: light; - font-family: "Aptos", "Avenir Next", "Segoe UI", sans-serif; - background: var(--canvas); - color: var(--ink); + --midnight: #070920; + --console-card: #1a1f52; + --console-line: rgb(150 160 255 / 18%); + --console-ink: #eceeff; + --console-muted: #a6aee0; + --cyan: #2ee6f6; + --cyan-bright: #7df3fb; + --violet: #9d74ff; + --signal-good: #3cf0a4; + --signal-bad: #ff5775; + --signal-warn: #ffbd4a; + --pad: #fff6ad; + --pad-slip: #fffdf1; + --pad-rule: rgb(84 132 204 / 26%); + --pad-margin: rgb(222 76 76 / 60%); + --pad-binding: #7a2a24; + --ink: #1b2240; + --ink-blue: #1f4bb0; + --ink-blue-dark: #173a8a; + --pencil: #5c5840; + --pencil-line: rgb(92 88 64 / 30%); + --red-pen: #c32a1f; + --hand: "Bradley Hand", "Segoe Print", "Comic Sans MS", cursive; + --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif; + --display: "Bahnschrift", "DIN Alternate", "Avenir Next Condensed", "Segoe UI", sans-serif; + --mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace; + --guide-width: 460px; + color-scheme: dark; + font-family: var(--sans); + background: var(--midnight); + color: var(--console-ink); } * { @@ -24,248 +41,1280 @@ body { margin: 0; overflow-x: clip; - background: - linear-gradient(135deg, rgb(15 118 110 / 14%), transparent 32%), - radial-gradient(circle at top right, rgb(244 106 31 / 16%), transparent 30%), - var(--canvas); + background: var(--midnight); } -.shell { - min-height: 100vh; +/* FocusOnNavigate moves focus to the page heading for screen readers; the heading is not interactive, so hide the ring */ +h1:focus { + outline: none; } -.page { - width: min(1120px, calc(100% - 32px)); - margin: 0 auto; - padding: 32px 0 56px; +code { + font-family: var(--mono); + font-size: 0.88em; } -.hero-panel { - display: grid; - grid-template-columns: 1fr auto; - gap: 28px; - align-items: end; - margin-bottom: 22px; - padding: 30px; - border: 1px solid rgb(36 35 31 / 10%); - border-radius: 8px; - background: linear-gradient(135deg, var(--panel-strong), #15463e 58%, #6f3b17); - box-shadow: var(--shadow); - color: #f8fffc; +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; } -.hero-copy { - max-width: 680px; +@keyframes spin { + to { + transform: rotate(360deg); + } } -.eyebrow { - margin: 0 0 6px; - font-size: 0.78rem; - font-weight: 600; - letter-spacing: 0.12em; - text-transform: uppercase; - color: rgb(248 255 252 / 72%); -} +/* The app and the guide share the screen */ -.hero-panel h1 { - margin: 0 0 10px; - font-size: clamp(1.8rem, 3vw, 2.5rem); - line-height: 1.1; +.workspace { + display: flex; + min-height: 100vh; } -.hero-summary { - margin: 0; - color: rgb(248 255 252 / 80%); +.app-pane { + flex: 1 1 auto; + min-width: 0; + background: + radial-gradient(circle at 12% -10%, rgb(157 116 255 / 28%), transparent 42%), + radial-gradient(circle at 95% 30%, rgb(46 230 246 / 14%), transparent 38%), + linear-gradient(rgb(150 160 255 / 5%) 1px, transparent 1px) 0 0 / 32px 32px, + linear-gradient(90deg, rgb(150 160 255 / 5%) 1px, transparent 1px) 0 0 / 32px 32px, + var(--midnight); } -.hero-actions { - display: grid; - gap: 10px; - justify-items: end; -} +/* Buttons */ button.action { + display: inline-flex; + align-items: center; + justify-content: center; padding: 12px 22px; border: 0; - border-radius: 6px; - background: var(--accent); - color: #fff; + border-radius: 10px; + background: var(--cyan); + box-shadow: 0 0 0 1px rgb(125 243 251 / 50%), 0 0 24px rgb(46 230 246 / 35%); + color: #031018; font: inherit; - font-weight: 600; + font-weight: 700; cursor: pointer; } button.action:hover:not(:disabled) { - background: #d4560f; + background: var(--cyan-bright); } button.action:disabled { - background: var(--muted); - cursor: progress; + background: #3b4175; + box-shadow: none; + color: #b9bfe6; + cursor: not-allowed; } button.action.secondary { - background: rgb(248 255 252 / 14%); - border: 1px solid rgb(248 255 252 / 30%); + border: 1px solid rgb(157 116 255 / 70%); + background: rgb(157 116 255 / 12%); + box-shadow: none; + color: var(--console-ink); } button.action.secondary:hover:not(:disabled) { - background: rgb(248 255 252 / 24%); + background: rgb(157 116 255 / 28%); +} + +button.action.small { + padding: 7px 14px; + font-size: 0.88rem; +} + +.spinner { + display: inline-block; + width: 14px; + height: 14px; + margin-right: 8px; + border: 2px solid rgb(185 191 230 / 35%); + border-top-color: #eceeff; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +/* The fleet control room */ + +.control-room { + container: control-room / inline-size; + width: min(1040px, calc(100% - 48px)); + margin: 0 auto; + padding: 18px 0 56px; +} + +.ops-bar { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px 20px; + padding: 6px 0 16px; + border-bottom: 1px solid var(--console-line); +} + +.ops-logo { + font-family: var(--display); + font-size: 1.2rem; + font-weight: 700; + letter-spacing: 0.02em; +} + +.ops-logo-mark { + color: var(--cyan); + text-shadow: 0 0 12px rgb(46 230 246 / 70%); +} + +.ops-status { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 4px 12px; + border: 1px solid rgb(157 116 255 / 45%); + border-radius: 999px; + color: #cdbdff; + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.ops-status-light { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--signal-good); + box-shadow: 0 0 10px var(--signal-good); +} + +.ops-hero { + position: relative; + margin: 22px 0 18px; + padding: 28px 34px 26px; + overflow: hidden; + border: 1px solid var(--console-line); + border-radius: 18px; + background: + radial-gradient(circle at 100% 0%, rgb(46 230 246 / 22%), transparent 40%), + radial-gradient(circle at 0% 120%, rgb(157 116 255 / 32%), transparent 48%), + linear-gradient(135deg, #171c4d, #0c0f2e); + box-shadow: 0 30px 70px rgb(0 0 0 / 40%); } -.panel { - margin-bottom: 16px; +/* A radar sweep over the fleet, drawn with gradients, on screens wide enough to leave it room */ +.ops-hero::after { + content: ""; + position: absolute; + top: 50%; + right: 36px; + display: none; + width: 200px; + aspect-ratio: 1; + border: 1px solid rgb(46 230 246 / 35%); + border-radius: 50%; + background: + radial-gradient(circle, var(--cyan) 0 3px, transparent 4px), + repeating-radial-gradient(circle, transparent 0 32px, rgb(46 230 246 / 22%) 32px 33px), + conic-gradient(from 0deg, rgb(46 230 246 / 45%), transparent 22%); + box-shadow: 0 0 40px rgb(46 230 246 / 15%); + translate: 0 -50%; + animation: spin 5s linear infinite; +} + +@container control-room (min-width: 900px) { + .ops-hero::after { + display: block; + } +} + +.ops-eyebrow { + margin: 0 0 10px; + color: var(--cyan); + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.ops-hero h1 { + max-width: 640px; + margin: 0 0 12px; + font-family: var(--display); + font-size: clamp(2rem, 4vw, 3rem); + font-weight: 700; + line-height: 1.05; + letter-spacing: -0.01em; +} + +.ops-summary { + max-width: 580px; + margin: 0 0 16px; + color: rgb(236 238 255 / 82%); + font-size: 1.04rem; + line-height: 1.5; +} + +.ops-limits { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin: 0 0 20px; +} + +.ops-limits div { + padding: 8px 14px; + border: 1px solid var(--console-line); + border-radius: 10px; + background: rgb(7 9 32 / 45%); +} + +.ops-limits dt { + color: var(--console-muted); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.ops-limits dd { + margin: 2px 0 0; + font-family: var(--mono); + font-weight: 700; +} + +.ops-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.ops-panel { + margin-bottom: 18px; padding: 22px 24px; - border: 1px solid var(--line); - border-radius: 8px; - background: var(--panel); - box-shadow: 0 10px 26px rgb(28 45 42 / 8%); + border: 1px solid var(--console-line); + border-radius: 14px; + background: rgb(21 26 69 / 88%); } -.panel h2 { +.ops-panel h2 { margin: 0 0 4px; - font-size: 1.15rem; + font-family: var(--display); + font-size: 1.2rem; + font-weight: 700; + letter-spacing: 0.02em; } -.panel-hint { +.ops-hint { margin: 0 0 16px; - color: var(--muted); - font-size: 0.92rem; + color: var(--console-muted); + font-size: 0.93rem; + line-height: 1.5; +} + +.ops-panel .ops-hint:last-child { + margin-bottom: 0; +} + +/* The pipeline diagram: devices, queue, consumers, store */ + +.pipeline { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(24px, 64px) minmax(0, 1fr) minmax(24px, 64px) minmax(0, 1fr) minmax(24px, 64px) minmax(0, 1fr); + align-items: center; +} + +.pipeline-node { + display: grid; + justify-items: center; + gap: 2px; + min-width: 0; + padding: 14px 8px; + border: 1px solid rgb(46 230 246 / 35%); + border-radius: 14px; + background: linear-gradient(180deg, #1d2360, #141945); + box-shadow: inset 0 0 18px rgb(46 230 246 / 8%); + text-align: center; } +.pipeline-node.busy { + border-color: rgb(255 189 74 / 70%); + box-shadow: inset 0 0 18px rgb(255 189 74 / 12%), 0 0 18px rgb(255 189 74 / 22%); +} + +.pipeline-node.done { + border-color: rgb(60 240 164 / 70%); + box-shadow: inset 0 0 18px rgb(60 240 164 / 12%), 0 0 18px rgb(60 240 164 / 22%); +} + +.node-glyph { + color: var(--cyan); + font-size: 1.3rem; + line-height: 1; + text-shadow: 0 0 10px rgb(46 230 246 / 70%); +} + +.pipeline-node.busy .node-glyph { + color: var(--signal-warn); + text-shadow: 0 0 10px rgb(255 189 74 / 70%); +} + +.pipeline-node.done .node-glyph { + color: var(--signal-good); + text-shadow: 0 0 10px rgb(60 240 164 / 70%); +} + +.node-name { + color: var(--console-muted); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.node-value { + max-width: 100%; + overflow: hidden; + font-family: var(--mono); + font-size: 1.25rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; +} + +.node-caption { + color: var(--console-muted); + font-size: 0.75rem; +} + +/* A link fills with the share of the burst that crossed it, and its dashes move while readings are on their way */ +.pipeline-link { + position: relative; + height: 6px; + margin: 0 -1px; + background: rgb(150 160 255 / 16%); +} + +.link-track { + position: absolute; + inset: 0 auto 0 0; + width: var(--fill, 0%); + background: linear-gradient(90deg, var(--violet), var(--cyan)); + box-shadow: 0 0 12px rgb(46 230 246 / 60%); +} + +.pipeline-link::after { + content: "▸"; + position: absolute; + top: 50%; + right: -4px; + color: var(--cyan); + font-size: 0.9rem; + line-height: 1; + transform: translateY(-50%); +} + +.pipeline-link.flowing .link-track { + width: 100%; + background: repeating-linear-gradient(90deg, var(--cyan) 0 10px, transparent 10px 18px); + background-size: 18px 100%; + animation: flow 0.6s linear infinite; +} + +@keyframes flow { + to { + background-position: 18px 0; + } +} + +/* Gauge-style counter cards */ + .result-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); - gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 14px; } .result-card { + --signal: var(--violet); display: grid; - gap: 4px; - padding: 14px 16px; - border: 1px solid var(--line); - border-left: 4px solid var(--muted); - border-radius: 6px; - background: #fbfdfc; + justify-items: center; + gap: 2px; + padding: 18px 16px 16px; + border: 1px solid var(--console-line); + border-top: 3px solid var(--signal); + border-radius: 14px; + background: linear-gradient(180deg, var(--console-card), #12163d); + text-align: center; } .result-card.ok { - border-left-color: var(--good); + --signal: var(--signal-good); + box-shadow: 0 0 26px rgb(60 240 164 / 20%); } .result-card.bad { - border-left-color: var(--bad); + --signal: var(--signal-bad); + box-shadow: 0 0 26px rgb(255 87 117 / 26%); } .result-card.pending { - border-left-color: var(--warn); + --signal: var(--signal-warn); + box-shadow: 0 0 26px rgb(255 189 74 / 20%); +} + +/* A half dial: the colored arc sweeps from left to right as the value approaches its limit */ +.gauge { + width: 150px; + height: 75px; + margin-bottom: -30px; + border-radius: 150px 150px 0 0; + background: conic-gradient(from 270deg at 50% 100%, var(--signal) calc(var(--gauge, 0) * 1.8deg), rgb(150 160 255 / 18%) 0 180deg, transparent 0); + filter: drop-shadow(0 0 6px var(--signal)); + -webkit-mask: radial-gradient(circle 75px at 50% 100%, transparent 62px, #000 63px); + mask: radial-gradient(circle 75px at 50% 100%, transparent 62px, #000 63px); } .result-label { - font-size: 0.78rem; - font-weight: 600; - letter-spacing: 0.06em; + order: -1; + margin-bottom: 10px; + color: var(--console-muted); + font-size: 0.74rem; + font-weight: 700; + letter-spacing: 0.1em; text-transform: uppercase; - color: var(--muted); } .result-value { - font-size: 1.5rem; + position: relative; + font-family: var(--mono); + font-size: 1.6rem; font-weight: 700; font-variant-numeric: tabular-nums; } .result-note { - font-size: 0.85rem; - color: var(--muted); + margin-top: 4px; + color: var(--console-muted); + font-size: 0.84rem; + line-height: 1.4; } .result-card.ok .result-note { - color: var(--good); + color: var(--signal-good); } .result-card.bad .result-note { - color: var(--bad); + color: #ff8da1; } -.timing { - font-variant-numeric: tabular-nums; - font-weight: 600; +.result-card.pending .result-note { + color: var(--signal-warn); } -.log { - max-height: 260px; +/* The workshop guide: the on-call handover, written on a yellow legal pad */ + +.guide { + position: sticky; + top: 0; + flex: 0 0 var(--guide-width); + height: 100vh; overflow-y: auto; + border-top: 14px solid var(--pad-binding); + background-color: var(--pad); + background-image: + linear-gradient(90deg, transparent 28px, var(--pad-margin) 28px 29px, transparent 29px 32px, var(--pad-margin) 32px 33px, transparent 33px), + repeating-linear-gradient(180deg, transparent 0 25px, var(--pad-rule) 25px 26px); + background-attachment: local; + box-shadow: -20px 0 50px rgb(0 0 0 / 45%); + color: var(--ink); + font-family: var(--sans); + font-size: 0.95rem; + color-scheme: light; +} + +.guide.collapsed { + flex-basis: 76px; + display: grid; + align-content: start; + justify-items: center; + gap: 14px; + padding: 14px 0; + background-image: repeating-linear-gradient(180deg, transparent 0 25px, var(--pad-rule) 25px 26px); +} + +.guide button.action { + padding: 9px 18px; + border: 0; + border-radius: 6px; + background: var(--ink-blue); + box-shadow: 0 2px 0 var(--ink-blue-dark); + color: #fff; +} + +.guide button.action:hover:not(:disabled) { + background: var(--ink-blue-dark); +} + +.guide button.action:disabled { + background: #8d99bd; + box-shadow: none; + color: #f4f5fb; +} + +.guide button.action.small { + padding: 6px 14px; + font-size: 0.86rem; +} + +.guide button.action.secondary { + border: 1.5px solid var(--ink-blue); + background: var(--pad-slip); + box-shadow: none; + color: var(--ink-blue); +} + +.guide button.action.secondary:hover:not(:disabled) { + background: #e7eeff; +} + +.guide .spinner { + border-color: rgb(255 255 255 / 40%); + border-top-color: #fff; +} + +.guide .banner .spinner { + border-color: rgb(31 75 176 / 25%); + border-top-color: var(--ink-blue); + vertical-align: -2px; +} + +.guide-header { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 16px 18px 12px 46px; + background: radial-gradient(circle, rgb(122 42 36 / 30%) 1.5px, transparent 2px) 0 3px / 12px 8px repeat-x; +} + +.guide-eyebrow { + margin: 4px 0 0; + color: var(--pencil); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.guide-title { margin: 0; - padding: 14px 16px; - border: 1px solid var(--line); + color: var(--ink-blue); + font-family: var(--hand); + font-size: 2rem; + font-weight: 700; + line-height: 1.2; +} + +.guide-subtitle { + margin: 2px 0 0; + color: var(--ink); + font-size: 0.92rem; + line-height: 1.45; +} + +.guide-toggle { + flex: none; + align-self: start; + display: grid; + justify-items: center; + gap: 8px; + min-width: 36px; + margin-top: 4px; + padding: 6px 8px; + border: 1.5px solid var(--ink-blue); border-radius: 6px; - background: #1e2227; - color: #d7dde6; - font-family: "Menlo", "Consolas", monospace; - font-size: 0.82rem; - line-height: 1.6; - white-space: pre-wrap; + background: var(--pad-slip); + color: var(--ink-blue); + font: inherit; + font-size: 1.1rem; + font-weight: 700; + cursor: pointer; +} + +.guide-toggle:hover { + background: #e7eeff; +} + +.guide-rail-label { + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + writing-mode: vertical-rl; +} + +.guide-rail { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; } -.status-tile { +.guide-rail-step { display: grid; - gap: 4px; - justify-items: end; - padding: 14px 18px; - border: 1px solid rgb(248 255 252 / 22%); + justify-items: center; + gap: 2px; + width: 52px; + padding: 6px 0; + border: 1.5px solid rgb(31 75 176 / 35%); border-radius: 8px; - background: rgb(248 255 252 / 10%); + background: var(--pad-slip); + color: var(--ink); + font: inherit; + cursor: pointer; +} + +.guide-rail-step:hover { + border-color: var(--ink-blue); } -.status-value { - font-size: 1.9rem; +.guide-rail-number { + color: var(--ink-blue); + font-weight: 700; +} + +.guide-progress { + display: grid; + gap: 10px; + padding: 4px 18px 16px 46px; + border-bottom: 2px solid var(--pencil-line); +} + +.progress-meter { + display: flex; + gap: 8px; +} + +.progress-segment { + position: relative; + flex: 1; + height: 18px; + border: 2px solid var(--ink-blue); + border-radius: 3px 5px 4px 6px; + background: var(--pad-slip); +} + +.progress-segment.done { + background: #3d9a45; + border-color: #2b7433; +} + +.progress-segment.done::after { + content: "✓"; + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: #fff; + font-size: 0.8rem; font-weight: 700; line-height: 1; - font-variant-numeric: tabular-nums; } -.status-label { - font-size: 0.82rem; - color: rgb(248 255 252 / 78%); +.guide-progress-row { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px; } -.spinner { +.guide-progress-count { + margin: 0; + color: var(--pencil); +} + +.guide-progress-count strong { + color: var(--ink); + font-size: 1.1rem; +} + +.guide-tabs { + display: grid; + gap: 2px; + padding: 10px 14px 10px 40px; + border-bottom: 2px solid var(--pencil-line); +} + +.guide-tab { + display: grid; + grid-template-columns: 30px 1fr auto; + gap: 10px; + align-items: center; + padding: 6px 8px; + border: 0; + border-radius: 4px 12px 6px 14px; + background: transparent; + color: var(--ink); + font: inherit; + font-size: 0.93rem; + text-align: left; + cursor: pointer; +} + +.guide-tab:hover { + background: rgb(255 255 255 / 45%); +} + +/* The selected tab is marked with a cyan highlighter, the one accent the note shares with the control room */ +.guide-tab.selected { + background: linear-gradient(100deg, rgb(94 222 232 / 20%), rgb(94 222 232 / 58%) 12%, rgb(94 222 232 / 58%) 88%, rgb(94 222 232 / 25%)); + font-weight: 700; +} + +.guide-tab-number { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border: 2px solid var(--ink-blue); + border-radius: 52% 46% 50% 44% / 46% 52% 44% 50%; + color: var(--ink-blue); + font-size: 0.85rem; + font-weight: 700; +} + +.guide-tab.selected .guide-tab-number { + background: var(--ink-blue); + color: #fff; +} + +.guide-body { + display: grid; + gap: 18px; + padding: 18px 18px 44px 46px; +} + +.guide-section { + min-width: 0; +} + +.guide-section h3, +.guide-section h4 { + margin: 0 0 6px; + color: var(--ink-blue); + font-family: var(--hand); + font-size: 1.4rem; + font-weight: 700; + line-height: 1.25; +} + +.guide-section p { + margin: 0 0 10px; + line-height: 1.6; +} + +.guide-section p:last-child { + margin-bottom: 0; +} + +.guide-note { + color: var(--pencil); + font-size: 0.88rem; +} + +.step-header { + display: grid; + gap: 10px; +} + +.step-eyebrow { + margin: 0; + color: var(--red-pen); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.step-title { + margin: 0 0 4px; + color: var(--ink-blue); + font-family: var(--hand); + font-size: 1.7rem; + font-weight: 700; + line-height: 1.2; +} + +.step-members { + color: var(--pencil); + overflow-wrap: anywhere; +} + +.step-actions { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.step-buttons { + display: flex; + gap: 8px; +} + +.how-it-works, +.task-list { + margin: 0 0 12px; + padding-left: 22px; + line-height: 1.6; +} + +.how-it-works li + li, +.task-list li + li { + margin-top: 6px; +} + +.how-it-works li::marker, +.task-list li::marker { + color: var(--ink-blue); + font-weight: 700; +} + +.how-it-works { + margin-bottom: 0; +} + +.facts { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 12px; + margin: 0 0 12px; + font-size: 0.92rem; +} + +.facts dt { + color: var(--pencil); + font-weight: 700; +} + +.facts dd { + margin: 0; + overflow-wrap: anywhere; +} + +/* Taped-on slips of paper: the see-it callout and the AI note */ +.see-it, +.ai-note { + padding: 10px 14px; + border-radius: 2px; + box-shadow: 1px 2px 0 rgb(27 34 64 / 10%); +} + +.see-it { + border-left: 4px solid var(--ink-blue); + background: #e4efff; +} + +.ai-note { + border-left: 4px solid #c2366e; + background: #ffdce8; + color: #5c1030; +} + +.clues { + padding: 10px 14px; + border: 1.5px dashed var(--pencil-line); + border-radius: 6px; + background: rgb(255 253 241 / 75%); +} + +.clues summary, +.guide-log summary { + color: var(--ink-blue); + font-weight: 700; + cursor: pointer; +} + +.clues ul { + margin: 10px 0 0; + padding-left: 20px; + line-height: 1.6; +} + +.guide-log { + padding: 10px 14px; + border: 1.5px solid var(--pencil-line); + border-radius: 6px; + background: rgb(255 253 241 / 85%); +} + +.guide-log-count { + margin-left: 6px; + color: var(--pencil); + font-weight: 400; + font-size: 0.85rem; +} + +/* Step results */ + +.badge { display: inline-block; - width: 14px; - height: 14px; - margin-right: 8px; - border: 2px solid rgb(98 112 109 / 35%); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; - vertical-align: -2px; + padding: 2px 10px; + border: 1px solid var(--pencil-line); + border-radius: 999px; + background: var(--pad-slip); + color: var(--pencil); + font-size: 0.78rem; + font-weight: 700; + white-space: nowrap; } -@keyframes spin { +.badge-compact { + padding: 0; + border: 0; + background: transparent; + font-size: 0.9rem; +} + +.badge-passed { + border-color: rgb(43 116 51 / 40%); + background: #d9f3cf; + color: #1d5e24; +} + +.badge-failed { + border-color: rgb(195 42 31 / 45%); + background: #ffe0da; + color: #9e1f16; +} + +.badge-running { + border-color: rgb(31 75 176 / 40%); + background: #dfe9fd; + color: var(--ink-blue-dark); +} + +.badge-missing { + border-color: rgb(186 96 0 / 45%); + background: #ffdca6; + color: #6a3500; +} + +.badge-locked { + background: #efe7b4; +} + +.badge-compact.badge-passed, +.badge-compact.badge-failed, +.badge-compact.badge-running, +.badge-compact.badge-missing, +.badge-compact.badge-locked { + background: transparent; +} + +/* Banners are sticky notes stuck to the pad */ +.banner { + display: grid; + justify-items: start; + gap: 6px; + padding: 12px 16px; + border: 1.5px solid var(--pencil-line); + border-radius: 3px; + background: var(--pad-slip); + box-shadow: 2px 3px 0 rgb(27 34 64 / 10%); + line-height: 1.5; +} + +.banner-passed { + border-color: rgb(43 116 51 / 50%); + background: #d9f3cf; + color: #1d5e24; +} + +.banner-failed { + border-color: rgb(195 42 31 / 60%); + background: #ffe0da; + color: #8e1b12; +} + +.banner-missing { + border-color: rgb(186 96 0 / 60%); + background: #ffdca6; + color: #5e2f00; +} + +.banner-running { + border-color: rgb(31 75 176 / 45%); + background: #dfe9fd; + color: var(--ink-blue-dark); +} + +.banner-locked { + border-style: dashed; + color: var(--pencil); +} + +.banner-note { + font-size: 0.83rem; + opacity: 0.85; +} + +/* @key recreates the banner for every new result, so this animation plays again after each click */ +.banner-fresh { + animation: banner-in 0.35s ease-out; +} + +@keyframes banner-in { + from { + opacity: 0; + transform: translateY(-6px); + } + to { - transform: rotate(360deg); + opacity: 1; + transform: none; } } -.warning-banner { - margin-bottom: 16px; - padding: 14px 18px; - border: 1px solid rgb(197 157 53 / 45%); - border-left: 4px solid var(--warn); - border-radius: 6px; - background: #fdf8e9; - color: #6b551a; +.link-button { + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + font-weight: 700; + text-align: left; + text-decoration: underline; + text-underline-offset: 3px; + cursor: pointer; +} + +.check-list { + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.check { + display: grid; + grid-template-columns: 24px 1fr; + gap: 8px; + padding: 10px 12px; + border: 1px solid var(--pencil-line); + border-radius: 4px; + background: var(--pad-slip); +} + +.check-failed { + border: 2px solid rgb(195 42 31 / 55%); + background: #fff7f4; +} + +.check-body { + min-width: 0; +} + +.guide-section .check-description { + margin: 0 0 6px; + font-weight: 600; + line-height: 1.4; +} + +.check-values { + display: grid; + grid-template-columns: max-content 1fr; + gap: 2px 10px; + margin: 0; + font-size: 0.86rem; +} + +.check-values dt { + color: var(--pencil); +} + +.check-values dd { + margin: 0; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} + +/* A hint is a margin note in red pen, pulled out beside the result it explains */ +.guide-section .check-hint { + margin: 10px 0 0 -32px; + padding: 8px 12px; + border-left: 3px solid var(--red-pen); + border-radius: 0 6px 6px 0; + background: #ffe6de; + box-shadow: 2px 2px 0 rgb(142 27 18 / 12%); + color: #7e170f; + font-size: 0.88rem; + line-height: 1.5; +} + +.table-scroll { + margin-top: 10px; + overflow-x: auto; +} + +.step-log { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; +} + +.step-log th, +.step-log td { + padding: 6px 8px; + border-bottom: 1px solid var(--pad-rule); + text-align: left; + vertical-align: top; +} + +.step-log th { + color: var(--pencil); +} + +.step-log td:nth-child(-n + 3) { + font-variant-numeric: tabular-nums; + white-space: nowrap; } -@media (max-width: 640px) { - .hero-panel { - grid-template-columns: 1fr; - align-items: start; +/* Nothing moves for anyone who asked for less motion */ + +@media (prefers-reduced-motion: reduce) { + .ops-hero::after, + .pipeline-link.flowing .link-track, + .banner-fresh { + animation: none; + } +} + +/* Narrow screens stack the guide under the app */ + +@media (max-width: 1080px) { + :root { + --guide-width: 380px; + } +} + +@media (max-width: 860px) { + .workspace { + flex-direction: column; } - .hero-actions, - .status-tile { + .guide, + .guide.collapsed { + position: static; + flex-basis: auto; + height: auto; + box-shadow: none; + } + + .guide.collapsed { + grid-auto-flow: column; + justify-content: start; + padding: 12px 16px; + } + + .guide.collapsed .guide-rail { + grid-auto-flow: column; + } + + .guide-rail-label { + writing-mode: horizontal-tb; + } + + .control-room { + width: calc(100% - 32px); + } + + .ops-hero { + padding: 26px 20px; + } +} + +/* When the control room is narrow, the pipeline runs top to bottom */ + +@container control-room (max-width: 720px) { + .pipeline { + grid-template-columns: minmax(0, 1fr); + } + + .pipeline-node { + grid-template-columns: 28px minmax(0, 1fr) auto; + grid-template-areas: + "glyph name value" + "glyph caption value"; + column-gap: 12px; + row-gap: 0; justify-items: start; + padding: 10px 14px; + text-align: left; + } + + .node-glyph { + grid-area: glyph; + align-self: center; + justify-self: center; + } + + .node-name { + grid-area: name; + } + + .node-caption { + grid-area: caption; + } + + .node-value { + grid-area: value; + align-self: center; + justify-self: end; + } + + .pipeline-link { + width: 6px; + height: 24px; + margin: -1px 0 -1px 25px; + } + + .link-track { + inset: 0 0 auto; + width: auto; + height: var(--fill, 0%); + background: linear-gradient(180deg, var(--violet), var(--cyan)); + } + + .pipeline-link::after { + content: "▾"; + top: auto; + right: 50%; + bottom: -7px; + transform: translateX(50%); + } + + .pipeline-link.flowing .link-track { + height: 100%; + background: repeating-linear-gradient(180deg, var(--cyan) 0 10px, transparent 10px 18px); + background-size: 100% 18px; + animation-name: flow-down; + } +} + +@keyframes flow-down { + to { + background-position: 0 18px; } } diff --git a/9. Channels/2. Finish/TelemetryPipeline/Components/Layout/MainLayout.razor b/9. Channels/2. Finish/TelemetryPipeline/Components/Layout/MainLayout.razor index 1c2a641..f745fc5 100644 --- a/9. Channels/2. Finish/TelemetryPipeline/Components/Layout/MainLayout.razor +++ b/9. Channels/2. Finish/TelemetryPipeline/Components/Layout/MainLayout.razor @@ -1,5 +1,9 @@ @inherits LayoutComponentBase -
- @Body -
+
+
+ @Body +
+ + +
diff --git a/9. Channels/2. Finish/TelemetryPipeline/Components/Layout/StepBadge.razor b/9. Channels/2. Finish/TelemetryPipeline/Components/Layout/StepBadge.razor new file mode 100644 index 0000000..352439e --- /dev/null +++ b/9. Channels/2. Finish/TelemetryPipeline/Components/Layout/StepBadge.razor @@ -0,0 +1,10 @@ +@inherits StepBadgeBase + +@if (IsCompact) +{ + @Label +} +else +{ + @Icon @Label +} diff --git a/9. Channels/2. Finish/TelemetryPipeline/Components/Layout/StepBadge.razor.cs b/9. Channels/2. Finish/TelemetryPipeline/Components/Layout/StepBadge.razor.cs new file mode 100644 index 0000000..e59465d --- /dev/null +++ b/9. Channels/2. Finish/TelemetryPipeline/Components/Layout/StepBadge.razor.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Components; + +namespace TelemetryPipeline.Components.Layout; + +public partial class StepBadgeBase : ComponentBase +{ + [Parameter, EditorRequired] + public StepStatus Status { get; set; } + + [Parameter] + public bool IsUnlocked { get; set; } = true; + + // Only the icon, for the collapsed guide + [Parameter] + public bool IsCompact { get; set; } + + public string Icon => Status is StepStatus.Running ? "⏳" : !IsUnlocked ? "🔒" : Status switch + { + StepStatus.Passed => "✅", + StepStatus.Failed or StepStatus.TimedOut or StepStatus.Crashed => "❌", + StepStatus.NotImplemented => "🧩", + _ => "•", + }; + + public string Label => Status is StepStatus.Running ? "Running" : !IsUnlocked ? "Locked" : Status switch + { + StepStatus.Passed => "Passed", + StepStatus.Failed => "Failed", + StepStatus.TimedOut => "Timed out", + StepStatus.Crashed => "Crashed", + StepStatus.NotImplemented => "Not implemented", + StepStatus.Stopped => "Stopped", + _ => "Not run", + }; + + public string CssClass => Status is StepStatus.Running ? "badge-running" : !IsUnlocked ? "badge-locked" : Status switch + { + StepStatus.Passed => "badge-passed", + StepStatus.Failed or StepStatus.TimedOut or StepStatus.Crashed => "badge-failed", + StepStatus.NotImplemented => "badge-missing", + _ => "badge-idle", + }; +} \ No newline at end of file diff --git a/9. Channels/2. Finish/TelemetryPipeline/Components/Layout/WorkshopGuide.razor b/9. Channels/2. Finish/TelemetryPipeline/Components/Layout/WorkshopGuide.razor new file mode 100644 index 0000000..b4effab --- /dev/null +++ b/9. Channels/2. Finish/TelemetryPipeline/Components/Layout/WorkshopGuide.razor @@ -0,0 +1,338 @@ +@inherits WorkshopGuideBase + + diff --git a/9. Channels/2. Finish/TelemetryPipeline/Components/Layout/WorkshopGuide.razor.cs b/9. Channels/2. Finish/TelemetryPipeline/Components/Layout/WorkshopGuide.razor.cs new file mode 100644 index 0000000..0b303ab --- /dev/null +++ b/9. Channels/2. Finish/TelemetryPipeline/Components/Layout/WorkshopGuide.razor.cs @@ -0,0 +1,179 @@ +using Microsoft.AspNetCore.Components; + +namespace TelemetryPipeline.Components.Layout; + +// Workshop plumbing: the guide docked beside the app. It shows your challenge, every step's result, and the step you are working on. +public partial class WorkshopGuideBase : ComponentBase, IAsyncDisposable +{ + // Selecting this shows your challenge instead of a step + public const int ChallengeTab = 0; + + // A run that fails quickly can finish faster than anyone can see a spinner + static readonly TimeSpan _minimumActivityIndicatorTime = TimeSpan.FromMilliseconds(600); + + readonly CancellationTokenSource _disposeCancellationTokenSource = new(); + + Task _polling = Task.CompletedTask; + bool _isStartingCheck; + int? _startingRunStepNumber; + + [Inject] + public required StepVerifier Verifier { get; init; } + + [Inject] + public required ILogger Logger { get; init; } + + public int SelectedTab { get; private set; } = ChallengeTab; + + public bool IsCollapsed { get; private set; } + + public string? ErrorMessage { get; private set; } + + public int PassedStepCount => Verifier.Steps.Count(step => Verifier.GetState(step.Number).Status is StepStatus.Passed); + + public bool IsCheckingSteps => _isStartingCheck || Verifier.IsCheckingAllSteps; + + // The first step that does not pass yet, which is the one to work on next + public WorkshopStep? StoppedAtStep => Verifier.Steps.FirstOrDefault(step => Verifier.GetState(step.Number).Status is not StepStatus.Passed); + + // The last step the check reaches: the first one that did not pass, or the last step when they all passed + public WorkshopStep LastCheckedStep => StoppedAtStep ?? Verifier.Steps[^1]; + + public WorkshopStep? SelectedStep => Verifier.FindStep(SelectedTab); + + // The run lives in the verifier, not in this guide, so a step that is still running shows up again after a reload + public ActiveStepRun? SelectedRun => Verifier.ActiveRun is { } run && run.Step.Number == SelectedTab ? run : null; + + public bool IsSelectedStepRunning => _startingRunStepNumber == SelectedTab || SelectedRun is not null; + + public async ValueTask DisposeAsync() + { + // Only stops refreshing the guide. A step that is running keeps running. + await _disposeCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + await _polling.ConfigureAwait(false); + + _disposeCancellationTokenSource.Dispose(); + + GC.SuppressFinalize(this); + } + + protected override void OnInitialized() + { + _polling = PollVerifier(_disposeCancellationTokenSource.Token); + } + + protected void SelectTab(int tab) + { + SelectedTab = tab; + IsCollapsed = false; + ErrorMessage = null; + } + + protected void ToggleCollapsed() => IsCollapsed = !IsCollapsed; + + protected async Task CheckAllStepsAsync() + { + var token = _disposeCancellationTokenSource.Token; + string? errorMessage = null; + + ErrorMessage = null; + _isStartingCheck = true; + + // Show the activity indicator before the check starts + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + try + { + // Keep the indicator on screen long enough to notice, even when Step 1 fails quickly + await Task.WhenAll(Verifier.CheckAllSteps(), Task.Delay(_minimumActivityIndicatorTime, token)).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The guide was closed while the steps were being checked + return; + } + catch (Exception e) + { + Logger.LogError(e, "Checking every step failed"); + + errorMessage = "Checking the steps failed. The terminal running the app has the details."; + } + + // The continuation is off Blazor's renderer, so every component state change goes back through it + await InvokeAsync(() => + { + ErrorMessage = errorMessage; + _isStartingCheck = false; + + StateHasChanged(); + }).ConfigureAwait(false); + } + + protected async Task RunSelectedStepAsync() + { + if (SelectedStep is not { } step) + return; + + var token = _disposeCancellationTokenSource.Token; + string? errorMessage = null; + + ErrorMessage = null; + _startingRunStepNumber = step.Number; + + // Show the activity indicator before the run starts + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + try + { + // Keep the indicator on screen long enough to notice, even when the step fails quickly + await Task.WhenAll(Verifier.RunStep(step), Task.Delay(_minimumActivityIndicatorTime, token)).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The guide was closed while the step was running + return; + } + catch (Exception e) + { + Logger.LogError(e, "Running Step {StepNumber} failed", step.Number); + + errorMessage = "The step could not be run. The terminal running the app has the details."; + } + + // The continuation is off Blazor's renderer, so every component state change goes back through it + await InvokeAsync(() => + { + ErrorMessage = errorMessage; + _startingRunStepNumber = null; + + StateHasChanged(); + }).ConfigureAwait(false); + } + + protected void StopRun() => Verifier.StopRun(); + + // Re-renders while the verifier is busy, plus once more when it finishes, so the live log and results appear without a refresh + async Task PollVerifier(CancellationToken token) + { + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100)); + var wasBusy = true; + + try + { + while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false)) + { + var isBusy = Verifier.IsBusy; + + if (isBusy || wasBusy) + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + + wasBusy = isBusy; + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // Expected when the guide is disposed + } + } +} \ No newline at end of file diff --git a/9. Channels/2. Finish/TelemetryPipeline/Components/Pages/Ingest.razor b/9. Channels/2. Finish/TelemetryPipeline/Components/Pages/Ingest.razor index 47877ba..a8687f6 100644 --- a/9. Channels/2. Finish/TelemetryPipeline/Components/Pages/Ingest.razor +++ b/9. Channels/2. Finish/TelemetryPipeline/Components/Pages/Ingest.razor @@ -1,17 +1,35 @@ @page "/" @inherits IngestPageBase -TelemetryPipeline - -
-
-
-

TelemetryPipeline

-

Device telemetry ingest

-

@EventCount.ToString("N0") devices post readings in a burst. Each reading has to reach the database, and each device gives up if the endpoint is slow.

-
+TelemetryPipeline · Ingest + +
+
+ + Fleet control room · Ingest endpoint +
+ +
+

Device fleet · live ingest

+

Device telemetry ingest

+

@EventCount.ToString("N0") devices post readings in a burst. Each reading has to reach the database, and each device gives up if the endpoint is slow.

+ +
+
+
Device timeout
+
20ms
+
+
+
Database write
+
40ms
+
+
+
Burst
+
@EventCount.ToString("N0") readings
+
+
-
+
-
+
@if (Stats is null && !IsBusy) { -
+

What to look for

-

Receive the burst and watch the average accept time. A device gives up after 20ms, so anything slower than that is a dropped reading in production.

-
+

Receive the burst and watch the average accept time. A device gives up after 20ms, so anything slower than that is a dropped reading in production.

+ } @if (Stats is not null) { -
+ // Each gauge is full at its limit: the whole burst, the 20ms a device waits, and 40ms per reading, the burst duration when every write happens inline + var acceptedGauge = Math.Min(100, Stats.Accepted * 100 / EventCount); + var acceptGauge = Math.Min(100, Stats.AverageAcceptMilliseconds * 100 / 20).ToString("F0"); + var durationGauge = Math.Min(100, Stats.TotalSeconds * 100 / (EventCount * 0.04)).ToString("F0"); + var writtenGauge = Math.Min(100, LiveProcessed * 100 / EventCount); + +

Ingest results

-

Accepting an event should be fast. Processing it can take as long as it needs, as long as nothing is lost.

+

Accepting an event should be fast. Processing it can take as long as it needs, as long as nothing is lost.

-
+
+ Accepted @Stats.Accepted.ToString("N0") of @EventCount.ToString("N0")
-
+
+ Average accept - @Stats.AverageAcceptMilliseconds.ToString("F1")ms + @Stats.AverageAcceptMilliseconds.ToString("F1")ms slowest @Stats.SlowestAcceptMilliseconds.ToString("F0")ms, devices retry past 20ms
-
+
+ Burst duration - @Stats.TotalSeconds.ToString("F2")s + @Stats.TotalSeconds.ToString("F2")s to accept every event
-
+
+ Written to store @LiveProcessed.ToString("N0") @LiveQueueDepth waiting in the queue
-
+
} - + +
+

Pipeline

+

Live counters from the ingest service, as of the last time the page refreshed.

+ +
+
+ + Devices + @EventCount.ToString("N0") + readings per burst +
+ + + +
+ + Queue + @LiveQueueDepth.ToString("N0") + waiting +
+ + + +
+ + Consumers + @(LiveQueueDepth > 0 ? "Draining" : "Idle") + background service +
+ + + +
+ + Store + @LiveProcessed.ToString("N0") + written +
+
+
+
diff --git a/9. Channels/2. Finish/TelemetryPipeline/Program.cs b/9. Channels/2. Finish/TelemetryPipeline/Program.cs index a97d715..43f5188 100644 --- a/9. Channels/2. Finish/TelemetryPipeline/Program.cs +++ b/9. Channels/2. Finish/TelemetryPipeline/Program.cs @@ -15,6 +15,11 @@ public static void Main(string[] args) builder.Services.AddSingleton(); builder.Services.AddHostedService(); + // Workshop plumbing: one StepVerifier holds every step's result. It is also a hosted service, + // so it checks your services against every step each time the app starts. + builder.Services.AddSingleton(); + builder.Services.AddHostedService(static serviceProvider => serviceProvider.GetRequiredService()); + var app = builder.Build(); if (!app.Environment.IsDevelopment()) diff --git a/9. Channels/2. Finish/TelemetryPipeline/Steps/Step1AnswerRightAway.cs b/9. Channels/2. Finish/TelemetryPipeline/Steps/Step1AnswerRightAway.cs new file mode 100644 index 0000000..7d01620 --- /dev/null +++ b/9. Channels/2. Finish/TelemetryPipeline/Steps/Step1AnswerRightAway.cs @@ -0,0 +1,89 @@ +namespace TelemetryPipeline; + +public sealed class Step1AnswerRightAway : WorkshopStep +{ + const int _readingCount = 20; + const double _maximumAverageAcceptMilliseconds = 5; + + const string _waitsForStoreHint = "Every accept waits for the 40 millisecond database write before it answers, so the device holds the connection open for the whole write and gives up after 20. " + + "Accepting a reading should only queue it. Move the EventStore.SaveAsync call out of the accept path entirely."; + + const string _countedAsWrittenHint = "AcceptAsync counts the reading as processed, so Written to store counts the same work as Accepted. " + + "Only a consumer writes readings to the store, so only a consumer should count them as processed. Nothing is consuming in this check."; + + public override int Number => 1; + + public override string Scenario => "Answer the device right away"; + + public override string Title => "AcceptAsync()"; + + public override string Story => "Every device in the fleet posts its readings to this endpoint, and every reading has to reach the database. The database takes 40 milliseconds per write. " + + "The devices do not care how long that write takes. They care how long the endpoint takes to answer, and they give up after 20 milliseconds. " + + "AcceptAsync does the write before it answers, so every reading becomes a retry, and the retries land on an endpoint that is already slow. " + + "There is already a background service that was supposed to do this work, and today it does nothing useful."; + + public override string SeeItInTheApp => "On the Ingest page, press Receive 400 events. The button spins for about 16 seconds. " + + "Average accept reads about 41ms on a red card, more than twice the 20ms a device waits. " + + "Written to store already reads 400 with nothing waiting in the queue, because every write happened inside the accept call. The background service did none of it."; + + public override string FileToChange => "Services/TelemetryIngestService.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Change AcceptAsync so it only queues the reading. Move the EventStore.SaveAsync call out of the accept path entirely.", + "Stop counting the reading as processed in AcceptAsync. Written to store should count readings that actually reached the store, and a consumer does that work.", + "Return the task type that allocates nothing when the call completes synchronously, because queuing a reading almost always does.", + "Keep adding the reading to _pending for now. Step 2 replaces the list with a real queue.", + "Leave EventStore alone. The 40 millisecond write is the one thing you cannot make faster.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "AcceptAsync awaits eventStore.SaveAsync(...) before it returns. The device holds the connection open for the entire database write.", + "_processed++ happens in the accept path, so Written to store is counting the same work as Accepted. The consumer is decoration.", + "Task allocates on every call. Its value type sibling in System.Threading.Tasks does not, when the method completes synchronously.", + ]; + + public override string TimeoutHint => "Accepting 20 readings never finished. Check that AcceptAsync no longer waits on anything that could take a long time."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + // Part 1: warm up on a throwaway service, so compiling AcceptAsync for the first time is not counted as accept time + await new TelemetryIngestService(new EventStore()).RunBurstAsync(1, token).ConfigureAwait(false); + + // Part 2: the same burst the Ingest page sends, only smaller, to a fresh service with no consumers running + var ingest = new TelemetryIngestService(new EventStore()); + report.Log($"Receiving a burst of {_readingCount} readings through RunBurstAsync, with no consumers running"); + + var stats = await ingest.RunBurstAsync(_readingCount, token).ConfigureAwait(false); + report.Log($"The burst took {stats.TotalSeconds:F2}s. Average accept {stats.AverageAcceptMilliseconds:F1}ms, slowest {stats.SlowestAcceptMilliseconds:F1}ms"); + + report.Expect( + $"Accepting a reading takes under {_maximumAverageAcceptMilliseconds}ms on average", + $"under {_maximumAverageAcceptMilliseconds}ms", + $"{stats.AverageAcceptMilliseconds:F1}ms", + stats.AverageAcceptMilliseconds < _maximumAverageAcceptMilliseconds, + _waitsForStoreHint); + + report.Expect($"Accepted reads {_readingCount}", _readingCount, ingest.Accepted, "Every reading AcceptAsync takes has to be counted in Accepted, once."); + + // Part 3: nothing is draining this service, so a reading that was only queued has not been written yet + report.Log($"Right after the burst, Processed reads {ingest.Processed} and QueueDepth reads {ingest.QueueDepth}"); + + report.Expect("Right after the burst, nothing has been written to the store yet", 0, ingest.Processed, _countedAsWrittenHint); + report.Expect($"Right after the burst, all {_readingCount} readings are waiting in the queue", _readingCount, ingest.QueueDepth, "Accepting a reading has to put it in the queue, where a consumer can pick it up later."); + + // Part 4: the return type. A ValueTask that completes synchronously allocates nothing, and this call is made constantly. + var acceptAsync = typeof(TelemetryIngestService).GetMethod(nameof(TelemetryIngestService.AcceptAsync), [typeof(TelemetryEvent), typeof(CancellationToken)]); + + report.Expect( + "AcceptAsync returns ValueTask", + nameof(ValueTask), + acceptAsync?.ReturnType.Name ?? "no AcceptAsync(TelemetryEvent, CancellationToken) method", + acceptAsync?.ReturnType == typeof(ValueTask), + "Queuing a reading almost always completes synchronously, and a fleet of devices calls this constantly. Return the task type that allocates nothing when the call completes synchronously."); + } +} \ No newline at end of file diff --git a/9. Channels/2. Finish/TelemetryPipeline/Steps/Step2QueueIntoBoundedChannel.cs b/9. Channels/2. Finish/TelemetryPipeline/Steps/Step2QueueIntoBoundedChannel.cs new file mode 100644 index 0000000..548216f --- /dev/null +++ b/9. Channels/2. Finish/TelemetryPipeline/Steps/Step2QueueIntoBoundedChannel.cs @@ -0,0 +1,241 @@ +using System.Reflection; +using System.Threading.Channels; +using TelemetryPipeline.Components.Pages; + +namespace TelemetryPipeline; + +public sealed class Step2QueueIntoBoundedChannel : WorkshopStep +{ + const int _maximumWrites = 10_000; + const int _deviceCount = 16; + + const BindingFlags _allFields = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; + + const string _channelHint = "The queue has to be thread safe, have a capacity, and be awaitable, so a full queue can make a device wait and an empty one can make a consumer wait without polling. " + + "System.Threading.Channels has exactly that type, and it ships with .NET."; + + const string _listHint = "List is not thread safe, and nothing limits how large it can grow. Replace it with the channel, rather than keeping both."; + + const string _capacityHint = "The queue made a device wait before the whole burst fit. Pick a capacity large enough to hold the whole 400 reading burst, so a burst is absorbed without anyone waiting."; + + const string _unboundedHint = "Nothing limits how large the queue can grow, so a big enough burst is an out of memory exception. Create the queue with a capacity, and make a full queue wait for room."; + + const string _droppedHint = "The queue stayed at its capacity while every write still completed right away, so readings were thrown away with no exception and no log line. " + + "Choose the full mode that makes the producer wait instead of losing readings."; + + const string _skippedHint = "No write ever waited for room, so there was no waiting write to check. Make a full queue wait first."; + + const string _lostReadingsHint = "Readings went missing, or a write threw, when many devices posted at the same time. " + + "Let the thread safe queue take the readings, update the counter atomically, and do not promise the channel a single writer when many devices write."; + + static readonly TimeSpan _writeTimeout = TimeSpan.FromSeconds(2); + + public override int Number => 2; + + public override string Scenario => "Queue into a bounded channel"; + + public override string Title => "The queue, Accepted, Processed and QueueDepth"; + + public override string Story => "The endpoint answers right away now, but it is answering into a List. Every browser session shares this one singleton, so many devices add to that list at once while the drain loop removes from it on another thread. " + + "List is not thread safe: readings go missing, or the list throws. Nothing limits how large it can grow, so a big enough burst is an out of memory exception. " + + "And nothing can wait on it, which is why the drain loop has to poll."; + + public override string SeeItInTheApp => "This one hides from a single click. Once Step 1 passes, press Receive 400 events on the Ingest page and Accepted still reads 400 of 400, because the page posts one reading at a time. " + + "The list only breaks when many devices post at the same moment, which is exactly what this step does: 16 of them, at the same instant."; + + public override string FileToChange => "Services/TelemetryIngestService.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Add a using for the namespace that holds Channel.", + "Replace the List with a queue that is thread safe, has a capacity you choose, and can be awaited. Add a capacity constant large enough to hold the whole 400 reading burst.", + "Decide what a full queue should do to the device that is writing, and pick the option that makes the producer wait instead of losing readings.", + "Be honest in the options about how many producers and consumers there really are. Many devices write, and you are about to run more than one consumer.", + "Change AcceptAsync so it writes the reading to the queue, passes its CancellationToken along, and counts the reading as accepted only once it is queued.", + "Make the counters safe for a producer and several consumers to touch at once, and make sure the Blazor page reads the latest value rather than one cached in a register.", + "Report QueueDepth from the queue itself instead of a list count.", + "DrainAsync, Reset() and RunBurstAsync still use the list, so the project will not build until they stop. Step 3 rewrites DrainAsync and removes Reset(), so a quick change is fine for now.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "_pending is a List that the accept path writes while DrainAsync reads it. List is not thread safe, so items can be lost and the list can throw.", + "Nothing limits how large _pending can grow. A big enough burst is an out of memory exception.", + "_accepted++ and _processed++ are not atomic, and TelemetryIngestService is a singleton shared by every browser session.", + "Channel.CreateBounded takes an options object with a capacity, a full mode, and two promises about readers and writers. The promises are not enforced: break one and nothing throws, it corrupts.", + "Interlocked updates an int atomically, and Volatile reads one so the value is never stale.", + ]; + + public override string TimeoutHint => "Writing to the queue never finished. Check that AcceptAsync passes its CancellationToken to the write, so a device that gives up can stop waiting."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + // Part 1: the queue itself. Reflection lists the fields TelemetryIngestService declares. + var fields = typeof(TelemetryIngestService).GetFields(_allFields); + var channelField = fields.FirstOrDefault(static field => typeof(Channel).IsAssignableFrom(field.FieldType)); + var listField = fields.FirstOrDefault(static field => typeof(List).IsAssignableFrom(field.FieldType)); + + report.Log($"TelemetryIngestService declares these fields: {string.Join(", ", fields.Select(static field => field.Name))}"); + + report.Expect("TelemetryIngestService queues readings in a Channel", "a Channel field", channelField?.Name ?? "none", channelField is not null, _channelHint); + report.Expect("No List is left", "none", listField?.Name ?? "none", _listHint); + + // Part 2: fill a fresh queue until a write waits for room, or until 10,000 readings, 25 times the burst, fit without anyone waiting + var fullIngest = new TelemetryIngestService(new EventStore()); + using var waitingWriteCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + + report.Log($"Posting readings one at a time until a write waits for room, up to {_maximumWrites:N0}"); + + Task? waitingWrite = null; + var completedWrites = 0; + + while (waitingWrite is null && completedWrites < _maximumWrites) + { + var write = PostReading(fullIngest, completedWrites, waitingWriteCancellationTokenSource.Token); + + if (write.IsCompleted) + { + // Rethrows anything AcceptAsync threw + await write.ConfigureAwait(false); + completedWrites++; + } + else + { + waitingWrite = write; + } + } + + var queueDepth = fullIngest.QueueDepth; + + report.Log(waitingWrite is null + ? $"All {completedWrites:N0} writes completed right away, and QueueDepth reads {queueDepth:N0}" + : $"{completedWrites:N0} writes completed right away, then write {completedWrites + 1:N0} waited for room. QueueDepth reads {queueDepth:N0}"); + + report.Expect( + $"The queue holds the whole {IngestPageBase.EventCount} reading burst without making a device wait", + $"{IngestPageBase.EventCount} of {IngestPageBase.EventCount} completed right away", + $"{Math.Min(completedWrites, IngestPageBase.EventCount)} of {IngestPageBase.EventCount} completed right away", + completedWrites >= IngestPageBase.EventCount, + _capacityHint); + + report.Expect( + "A full queue makes the next device wait for room, instead of growing forever or dropping a reading", + "a write waits for room", + waitingWrite is null ? $"{_maximumWrites:N0} writes completed right away, {queueDepth:N0} still queued" : $"write {completedWrites + 1:N0} waited for room", + waitingWrite is not null, + queueDepth < completedWrites ? _droppedHint : _unboundedHint); + + if (waitingWrite is null) + { + report.Expect("When the queue is full, QueueDepth reads its capacity", "the capacity", "Skipped: no write waited for room", false, _skippedHint); + report.Expect("A waiting write stops when the device gives up and cancels it", "cancelled", "Skipped: no write waited for room", false, _skippedHint); + } + else + { + report.Expect("When the queue is full, QueueDepth reads its capacity", completedWrites, queueDepth, "QueueDepth has to come from the queue itself, so it reads how many readings are waiting in it right now."); + + // Part 3: a device that gives up cancels its request. The write waiting for room has to stop, and must not be counted. + report.Log("The device waiting for room gives up and cancels its write"); + await waitingWriteCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + var cancelledWrite = await Outcome(waitingWrite, token).ConfigureAwait(false); + report.Log($"The waiting write: {cancelledWrite}"); + + report.Expect("A waiting write stops when the device gives up and cancels it", "cancelled", cancelledWrite, "Pass the CancellationToken AcceptAsync receives to the write, so a device that gives up stops waiting for room."); + report.Expect("The cancelled reading is not counted as accepted", completedWrites, fullIngest.Accepted, "Count a reading as accepted only after the write to the queue has completed."); + } + + // Part 4: 16 devices wait at a Barrier, then each posts its share of the burst at the same instant + var sharedIngest = new TelemetryIngestService(new EventStore()); + var readingsPerDevice = IngestPageBase.EventCount / _deviceCount; + var writes = new Task[_deviceCount * readingsPerDevice]; + + using var sharedWriteCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + using var barrier = new Barrier(_deviceCount); + + report.Log($"{_deviceCount} devices are each posting {readingsPerDevice} readings at the same instant"); + + var devices = Enumerable.Range(0, _deviceCount) + .Select(device => new Thread(() => + { + barrier.SignalAndWait(); + + // PostReading never throws. Anything AcceptAsync throws is kept in the Task, so it cannot end the app from this thread. + for (var reading = 0; reading < readingsPerDevice; reading++) + { + var eventId = (device * readingsPerDevice) + reading; + writes[eventId] = PostReading(sharedIngest, eventId, sharedWriteCancellationTokenSource.Token); + } + })) + .ToList(); + + devices.ForEach(static device => device.Start()); + devices.ForEach(static device => device.Join()); + + await Task.WhenAny(Task.WhenAll(writes), Task.Delay(_writeTimeout, token)).ConfigureAwait(false); + await sharedWriteCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + var failedWrites = writes.Where(static write => !write.IsCompletedSuccessfully).ToList(); + + foreach (var failure in failedWrites.Select(static write => write.IsCanceled ? "cancelled while waiting for room" : write.Exception?.InnerException?.GetType().Name ?? "still waiting").GroupBy(static failure => failure)) + { + report.Log($"{failure.Count()} writes did not complete: {failure.Key}"); + } + + report.Log($"Accepted reads {sharedIngest.Accepted}, and QueueDepth reads {sharedIngest.QueueDepth}"); + + // A write that never faulted and did not complete was waiting for room, which is a capacity problem rather than a lost reading + var waitedForRoom = failedWrites.Any(static write => !write.IsFaulted); + + report.Expect( + $"{_deviceCount} devices posting at the same instant have all {IngestPageBase.EventCount} readings accepted", + $"{IngestPageBase.EventCount} accepted, {IngestPageBase.EventCount} queued", + $"{sharedIngest.Accepted} accepted, {sharedIngest.QueueDepth} queued", + sharedIngest.Accepted == IngestPageBase.EventCount && sharedIngest.QueueDepth == IngestPageBase.EventCount && failedWrites.Count is 0, + waitedForRoom ? _capacityHint : _lostReadingsHint); + + // Part 5: a lost increment is rare with only 400 readings, so the code is checked too. The page reads these counters while consumers update them. + var acceptAsync = typeof(TelemetryIngestService).GetMethod(nameof(TelemetryIngestService.AcceptAsync), [typeof(TelemetryEvent), typeof(CancellationToken)]); + var acceptCounter = acceptAsync is null ? null : CodeInspector.GetCalledMethods(acceptAsync).FirstOrDefault(static call => call.StartsWith("Interlocked.", StringComparison.Ordinal)); + + report.Expect( + "AcceptAsync counts accepted readings with Interlocked", + "Interlocked", + acceptCounter ?? "no Interlocked call", + acceptCounter is not null, + "_accepted++ is a read, an add, and a write, and many devices call AcceptAsync at once. Update the counter atomically."); + + string[] counters = [nameof(TelemetryIngestService.Accepted), nameof(TelemetryIngestService.Processed)]; + var volatileCounters = counters + .Where(static counter => typeof(TelemetryIngestService).GetProperty(counter)?.GetMethod is { } getter && CodeInspector.GetCalledMethods(getter).Contains("Volatile.Read")) + .ToList(); + + report.Expect( + "Accepted and Processed read their counters with Volatile.Read", + string.Join(", ", counters), + volatileCounters.Count is 0 ? "neither" : string.Join(", ", volatileCounters), + volatileCounters.Count == counters.Length, + "An int read can be stale. The Blazor page reads these counters while consumers on other threads update them, so read the latest value rather than one cached in a register."); + } + + // Waits a short while for a write that should stop, and describes how it ended + static async Task Outcome(Task write, CancellationToken token) + { + try + { + await write.WaitAsync(_writeTimeout, token).ConfigureAwait(false); + return "completed"; + } + catch (OperationCanceledException) when (!token.IsCancellationRequested) + { + return "cancelled"; + } + catch (TimeoutException) + { + return $"was still waiting after {_writeTimeout.TotalSeconds} seconds"; + } + } +} \ No newline at end of file diff --git a/9. Channels/2. Finish/TelemetryPipeline/Steps/Step3DrainWithSeveralConsumers.cs b/9. Channels/2. Finish/TelemetryPipeline/Steps/Step3DrainWithSeveralConsumers.cs new file mode 100644 index 0000000..0897bb3 --- /dev/null +++ b/9. Channels/2. Finish/TelemetryPipeline/Steps/Step3DrainWithSeveralConsumers.cs @@ -0,0 +1,193 @@ +using System.Diagnostics; +using System.Threading.Channels; + +namespace TelemetryPipeline; + +public sealed class Step3DrainWithSeveralConsumers : WorkshopStep +{ + const int _burstSize = 80; + const int _secondBurstSize = 10; + + const string _oneConsumerHint = "One consumer writing one reading at a time clears 25 readings per second, so 80 readings take 3.2 seconds. " + + "Write a consumer that reads the queue until it is complete, saves each reading to EventStore and counts it as processed. Then have DrainAsync run several of them over the same reader, and wait for all of them."; + + const string _returnedEarlyHint = "DrainAsync returned while readings were still waiting. A consumer has to keep reading until the queue is complete, not until it happens to be empty."; + + const string _countedTwiceHint = "More readings were counted as processed than were accepted. Count each reading once, after it reaches the store."; + + const string _cumulativeHint = "The consumers keep draining earlier bursts in the background, so the pipeline outlives any single burst and its counters are cumulative. " + + "Delete Reset() and the call to it at the top of RunBurstAsync."; + + const string _closedHint = "The second burst found the queue closed. Completing the queue closes it for good, so nothing that runs during a burst may complete it. That belongs on the shutdown path, in Step 4."; + + static readonly TimeSpan _idleTime = TimeSpan.FromMilliseconds(200); + static readonly TimeSpan _drainDeadline = TimeSpan.FromSeconds(1.5); + static readonly TimeSpan _drainPatience = TimeSpan.FromSeconds(5); + static readonly TimeSpan _stopTimeout = TimeSpan.FromSeconds(2); + static readonly TimeSpan _pollingInterval = TimeSpan.FromMilliseconds(20); + + public override int Number => 3; + + public override string Scenario => "Drain with several consumers"; + + public override string Title => "DrainAsync() and its consumers"; + + public override string Story => "Readings are queued now, and every device gets its answer right away. But the only thing reading that queue is a loop that polls. " + + "When the queue is empty it sleeps for 25 milliseconds and looks again, forever, whether or not there is anything to do. " + + "And one consumer writing at 40 milliseconds per reading clears 25 readings per second, so a 400 reading burst takes 16 seconds to reach the store. " + + "That is the latency you just took away from the devices, moved somewhere nobody is watching."; + + public override string SeeItInTheApp => "Restart the app, receive 400 events on the Ingest page, and click Refresh counters once a second. " + + "Written to store should climb while the waiting count falls, and reach 400 written with 0 waiting about two seconds after the burst. " + + "The counters keep counting for the life of the app, so restart it before you measure a fresh burst."; + + public override string FileToChange => "Services/TelemetryIngestService.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Write a private consumer method that reads the queue until it is complete and saves each reading to EventStore.", + "Count each reading as processed once it reaches the store, safely, because several consumers update that counter at once.", + "Change DrainAsync so it runs several consumers over the same reader and waits for all of them. Add a consumer count constant. 8 is a good starting point.", + "Pass DrainAsync's CancellationToken on to every consumer, so cancelling it stops them.", + "Delete the polling loop and the Task.Delay inside it.", + "Delete Reset() and the call to Reset() at the top of RunBurstAsync. The consumers keep draining earlier bursts in the background, so the counters are cumulative now.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "DrainAsync polls. When the queue is empty it sleeps for 25 milliseconds and looks again, forever, whether or not there is anything to do. This periodic polling adds latency and wakes the loop even when no work exists.", + "A channel's reader can hand you every reading as an IAsyncEnumerable. It waits without holding a thread while the queue is empty, and it ends once the queue is complete and drained.", + "One consumer at 40 milliseconds per write can only clear 25 readings per second. Several consumers can share one reader, and each reading is handed to exactly one of them.", + "Task.WhenAll waits for several tasks at once.", + ]; + + public override string TimeoutHint => "DrainAsync never handed control back. Check that it starts every consumer and awaits them, rather than spinning in a loop that never awaits."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + var ingest = new TelemetryIngestService(new EventStore()); + using var drainCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + + // Part 1: TelemetryProcessor starts the consumers with the app, long before the first reading arrives + report.Log("Starting DrainAsync on a fresh service with an empty queue"); + + var drain = ingest.DrainAsync(drainCancellationTokenSource.Token); + + await Task.Delay(_idleTime, token).ConfigureAwait(false); + + // Rethrows anything DrainAsync threw + if (drain.IsFaulted) + await drain.ConfigureAwait(false); + + report.Expect( + "DrainAsync keeps waiting while the queue is empty", + "still running", + drain.IsCompleted ? "returned" : "still running", + "DrainAsync returned as soon as it found the queue empty. Consumers start with the app, before the first reading arrives, so they have to keep waiting for readings until the queue is complete."); + + // Part 2: one consumer at 40 milliseconds per write needs 3.2 seconds for 80 readings. Eight consumers need 0.4 seconds. + var stopwatch = Stopwatch.StartNew(); + + await ingest.RunBurstAsync(_burstSize, token).ConfigureAwait(false); + report.Log($"Received a burst of {_burstSize} readings in {stopwatch.Elapsed.TotalSeconds:F2}s. Waiting for the consumers to write them to the store"); + + await WaitForProcessed(ingest, drain, _burstSize, _drainPatience, token).ConfigureAwait(false); + stopwatch.Stop(); + + var processed = ingest.Processed; + var elapsed = stopwatch.Elapsed; + + report.Log($"{processed} of {_burstSize} readings were written after {elapsed.TotalSeconds:F2}s, and QueueDepth reads {ingest.QueueDepth}{(drain.IsCompleted ? ". DrainAsync has returned" : string.Empty)}"); + + report.Expect( + $"All {_burstSize} readings are written within {_drainDeadline.TotalSeconds} seconds", + $"{_burstSize} written within {_drainDeadline.TotalSeconds}s", + $"{processed} written after {elapsed.TotalSeconds:F2}s", + processed == _burstSize && elapsed <= _drainDeadline, + processed > _burstSize ? _countedTwiceHint : drain.IsCompleted ? _returnedEarlyHint : _oneConsumerHint); + + report.Expect("QueueDepth is back to 0", 0, ingest.QueueDepth, "Readings are still waiting in the queue. Every consumer has to keep reading until the queue is complete, not until it happens to be empty."); + + // Part 3: the pipeline outlives any single burst, so a second burst arrives while the consumers are still running + var expectedTotal = _burstSize + _secondBurstSize; + var secondBurstHint = _cumulativeHint; + string secondBurst; + + report.Log($"Receiving a second burst of {_secondBurstSize} readings on the same service"); + + try + { + await ingest.RunBurstAsync(_secondBurstSize, token).ConfigureAwait(false); + await WaitForProcessed(ingest, drain, expectedTotal, _stopTimeout, token).ConfigureAwait(false); + + secondBurst = $"{ingest.Accepted} accepted, {ingest.Processed} written"; + } + catch (ChannelClosedException) + { + secondBurst = $"threw {nameof(ChannelClosedException)}"; + secondBurstHint = _closedHint; + } + + report.Log($"After the second burst: {secondBurst}"); + report.Expect("A second burst adds to the counters while the consumers keep running", $"{expectedTotal} accepted, {expectedTotal} written", secondBurst, secondBurstHint); + + // Part 4: polling and a lost increment are hard to see from outside, so the code is checked too + var calls = CodeInspector.GetCalledMethods(typeof(TelemetryIngestService)); + var delays = calls.Count(static call => call is "Task.Delay"); + + report.Expect( + "No Task.Delay is left in TelemetryIngestService", + "none", + delays is 0 ? "none" : $"{delays} calls", + "Waiting for the next reading should be an await on the queue, not a sleep in a loop. Delete the polling loop and the Task.Delay inside it."); + + var acceptAsync = typeof(TelemetryIngestService).GetMethod(nameof(TelemetryIngestService.AcceptAsync), [typeof(TelemetryEvent), typeof(CancellationToken)]); + var acceptCounterUpdates = acceptAsync is null ? 0 : CodeInspector.GetCalledMethods(acceptAsync).Count(IsCounterUpdate); + var consumerCounterUpdates = calls.Count(IsCounterUpdate) - acceptCounterUpdates; + + report.Expect( + "The consumers count processed readings with Interlocked", + "Interlocked", + consumerCounterUpdates > 0 ? "Interlocked" : "no Interlocked call outside AcceptAsync", + "_processed++ is a read, an add, and a write, and several consumers update it at once. Update the counter atomically."); + + // Part 5: cancelling DrainAsync's token has to stop every consumer + report.Log("Cancelling DrainAsync's CancellationToken"); + await drainCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + string stopped; + + try + { + await drain.WaitAsync(_stopTimeout, token).ConfigureAwait(false); + stopped = "stopped"; + } + catch (OperationCanceledException) when (drainCancellationTokenSource.IsCancellationRequested && !token.IsCancellationRequested) + { + stopped = "stopped"; + } + catch (TimeoutException) + { + stopped = $"still running after {_stopTimeout.TotalSeconds} seconds"; + } + + report.Log($"DrainAsync: {stopped}"); + report.Expect("DrainAsync stops when its CancellationToken is cancelled", "stopped", stopped, "Pass the CancellationToken DrainAsync receives on to every consumer, and from there to the read and to the write to the store."); + } + + static bool IsCounterUpdate(string call) => call is "Interlocked.Increment" or "Interlocked.Add"; + + // Checks the counter the way the Ingest page's Refresh counters button does, until it reaches the target, DrainAsync returns, or patience runs out + static async Task WaitForProcessed(TelemetryIngestService ingest, Task drain, int target, TimeSpan patience, CancellationToken token) + { + var stopwatch = Stopwatch.StartNew(); + + while (ingest.Processed < target && !drain.IsCompleted && stopwatch.Elapsed < patience) + { + await Task.Delay(_pollingInterval, token).ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/9. Channels/2. Finish/TelemetryPipeline/Steps/Step4DrainBacklogOnShutdown.cs b/9. Channels/2. Finish/TelemetryPipeline/Steps/Step4DrainBacklogOnShutdown.cs new file mode 100644 index 0000000..c059d70 --- /dev/null +++ b/9. Channels/2. Finish/TelemetryPipeline/Steps/Step4DrainBacklogOnShutdown.cs @@ -0,0 +1,142 @@ +using System.Diagnostics; +using System.Threading.Channels; + +namespace TelemetryPipeline; + +public sealed class Step4DrainBacklogOnShutdown : WorkshopStep +{ + const int _backlogSize = 40; + + const string _abandonedHint = "StopAsync returned before the backlog reached the store. The host cancels stoppingToken at shutdown, and cancelling the read loop abandons every reading still waiting in the queue. " + + "Stop passing stoppingToken into DrainAsync, and complete the queue in StopAsync instead, so the consumers finish the backlog and then stop on their own."; + + const string _neverCompletedHint = "StopAsync waited until the shutdown timeout ran out, because nothing told the consumers that no more readings are coming, so they kept waiting for the next one. " + + "Override StopAsync in TelemetryProcessor, and complete the queue before you call the base implementation."; + + const string _stillOpenHint = "A reading that arrives during shutdown was queued where no consumer will ever read it, and the device was told everything is fine. " + + "StopAsync has to complete the queue, so a late reading is refused with ChannelClosedException instead."; + + // Stands in for the host's shutdown timeout, so a pipeline that never finishes still lets this step end + static readonly TimeSpan _shutdownTimeout = TimeSpan.FromSeconds(5); + static readonly TimeSpan _writeTimeout = TimeSpan.FromSeconds(2); + + public override int Number => 4; + + public override string Scenario => "Drain the backlog on shutdown"; + + public override string Title => "CompleteWriting() and TelemetryProcessor"; + + public override string Story => "A deployment restarts the app while a burst is still waiting in the queue. Every one of those readings was accepted, and every device was told it was safe. " + + "Nothing ever tells DrainAsync that the producer is finished: the only thing that stops it is cancellation at shutdown, and cancelling the read loop throws the whole backlog away. " + + "The readings never reach the store, and nothing logs a thing."; + + public override string SeeItInTheApp => "Once Step 3 passes, click Receive 400 events on the Ingest page, and press Ctrl+C in the terminal straight away, while readings are still waiting in the queue. " + + "Today the app quits instantly and the backlog is gone. Once this step passes, the app pauses for roughly two seconds before it exits, because it is finishing the backlog first."; + + public override string FileToChange => "Services/TelemetryIngestService.cs, Services/TelemetryProcessor.cs"; + + public override IReadOnlyList Tasks { get; } = + [ + "Implement CompleteWriting() in TelemetryIngestService so it marks the queue complete. Its stub throws NotImplementedException until you do.", + "Do not call CompleteWriting() from RunBurstAsync. Completing the queue closes it for good, and the next burst would throw ChannelClosedException.", + "In TelemetryProcessor, override StopAsync and complete the queue before you call the base implementation.", + "Stop passing stoppingToken into the read loop. Cancelling that loop is exactly what abandons the queued readings.", + ]; + + public override IReadOnlyList Clues { get; } = + [ + "Nothing ever tells DrainAsync that the producer is finished. The only thing that stops it today is cancellation at shutdown.", + "Once the writer is complete, the reader hands out what is still buffered and then ends on its own.", + "BackgroundService.StopAsync cancels stoppingToken and then waits for ExecuteAsync to return, for as long as the host's shutdown timeout allows.", + "More than one code path could reasonably close a channel. The writer has a completion method that returns false instead of throwing when the channel is already complete.", + ]; + + public override string TimeoutHint => "Shutting down TelemetryProcessor never finished. Check that StopAsync completes the queue before it waits for the read loop."; + + public override async Task Run(StepContext context) + { + var report = context.Report; + var token = context.Token; + + // Part 1: CompleteWriting() on its own, on a fresh service. A stub that still throws NotImplementedException ends the step here. + var closedIngest = new TelemetryIngestService(new EventStore()); + + report.Log("Calling CompleteWriting() on a fresh service, then posting a reading"); + closedIngest.CompleteWriting(); + + var lateReading = await PostLateReading(closedIngest, token).ConfigureAwait(false); + + report.Expect( + "After CompleteWriting(), AcceptAsync refuses a reading with ChannelClosedException", + nameof(ChannelClosedException), + lateReading, + "CompleteWriting() has to mark the queue complete, so it refuses every later reading and the consumers stop once the backlog is written."); + + // Part 2: the app starts TelemetryProcessor, a burst is queued, and the app shuts down straight away + var ingest = new TelemetryIngestService(new EventStore()); + using var processor = new TelemetryProcessor(ingest); + + await processor.StartAsync(token).ConfigureAwait(false); + await ingest.RunBurstAsync(_backlogSize, token).ConfigureAwait(false); + + report.Log($"TelemetryProcessor is running. Stopping it straight after a burst of {_backlogSize}, with {ingest.QueueDepth} readings still waiting and {ingest.Processed} written"); + + using var shutdownCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + shutdownCancellationTokenSource.CancelAfter(_shutdownTimeout); + + var stopwatch = Stopwatch.StartNew(); + + try + { + await processor.StopAsync(shutdownCancellationTokenSource.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (shutdownCancellationTokenSource.IsCancellationRequested && !token.IsCancellationRequested) + { + // The shutdown timeout ran out, which the checks below report + } + + stopwatch.Stop(); + + var processedAtStop = ingest.Processed; + var readLoopFinished = processor.ExecuteTask?.IsCompleted is true; + + report.Log($"StopAsync returned after {stopwatch.Elapsed.TotalSeconds:F2}s with {processedAtStop} of {_backlogSize} readings written. The read loop {(readLoopFinished ? "has finished" : "is still running")}"); + + report.Expect( + $"StopAsync returns once the read loop finishes, within the {_shutdownTimeout.TotalSeconds} second shutdown timeout", + "True", + readLoopFinished.ToString(), + readLoopFinished, + _neverCompletedHint); + + report.Expect( + "Every reading still waiting at shutdown is written before StopAsync returns", + _backlogSize, + processedAtStop, + readLoopFinished ? _abandonedHint : _neverCompletedHint); + + // Part 3: a device posting during shutdown is refused, because the app can no longer promise to store its reading + var readingDuringShutdown = await PostLateReading(ingest, token).ConfigureAwait(false); + + report.Log($"A reading posted after StopAsync: {readingDuringShutdown}"); + report.Expect("After StopAsync, AcceptAsync refuses a reading with ChannelClosedException", nameof(ChannelClosedException), readingDuringShutdown, _stillOpenHint); + } + + // Posts one reading and describes how it ended + static async Task PostLateReading(TelemetryIngestService ingest, CancellationToken token) + { + try + { + await PostReading(ingest, 0, token).WaitAsync(_writeTimeout, token).ConfigureAwait(false); + return "accepted"; + } + catch (ChannelClosedException) + { + return nameof(ChannelClosedException); + } + catch (TimeoutException) + { + return $"still waiting after {_writeTimeout.TotalSeconds} seconds"; + } + } +} \ No newline at end of file diff --git a/9. Channels/2. Finish/TelemetryPipeline/Steps/StepContext.cs b/9. Channels/2. Finish/TelemetryPipeline/Steps/StepContext.cs new file mode 100644 index 0000000..99c9971 --- /dev/null +++ b/9. Channels/2. Finish/TelemetryPipeline/Steps/StepContext.cs @@ -0,0 +1,4 @@ +namespace TelemetryPipeline; + +// Token is cancelled when the run is stopped, times out, or the app shuts down +public sealed record StepContext(StepReport Report, CancellationToken Token); \ No newline at end of file diff --git a/9. Channels/2. Finish/TelemetryPipeline/Steps/WorkshopStep.cs b/9. Channels/2. Finish/TelemetryPipeline/Steps/WorkshopStep.cs new file mode 100644 index 0000000..4f736cf --- /dev/null +++ b/9. Channels/2. Finish/TelemetryPipeline/Steps/WorkshopStep.cs @@ -0,0 +1,47 @@ +namespace TelemetryPipeline; + +// Every step is one problem in TelemetryPipeline's ingest path. +// Its tab in the workshop guide tells you what to fix, and Run() drives your code the way the device fleet does, logs what happens, +// and records an expected result, with a hint, for everything that should be true once the problem is fixed. +public abstract class WorkshopStep +{ + public abstract int Number { get; } + + // What this step fixes, in a few words + public abstract string Scenario { get; } + + // The members this step exercises + public abstract string Title { get; } + + // What goes wrong in production, and why nobody noticed with one device at a time + public abstract string Story { get; } + + // How to watch this problem happen on the Ingest page + public abstract string SeeItInTheApp { get; } + + // Where the problem lives + public abstract string FileToChange { get; } + + // What this step asks you to change. Some of it cannot be measured from outside, so not every task has an expected result. + public abstract IReadOnlyList Tasks { get; } + + // Shown only when you ask for them + public abstract IReadOnlyList Clues { get; } + + // What is most likely waiting forever when this step does not finish in time + public abstract string TimeoutHint { get; } + + public string SourceFile => $"Steps/{GetType().Name}.cs"; + + public abstract Task Run(StepContext context); + + // A device posts one reading. AcceptAsync returns Task in the starter code and ValueTask once you change it, + // and awaiting it inside this async method works for both. The Task this returns is already complete + // when the reading was queued synchronously, and an exception from AcceptAsync is stored in it rather than thrown. + protected static async Task PostReading(TelemetryIngestService ingest, int eventId, CancellationToken token) + { + var reading = new TelemetryEvent(eventId, $"device-{eventId % 20}", eventId * 0.37, DateTimeOffset.UtcNow); + + await ingest.AcceptAsync(reading, token).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/9. Channels/2. Finish/TelemetryPipeline/Verification/ActiveStepRun.cs b/9. Channels/2. Finish/TelemetryPipeline/Verification/ActiveStepRun.cs new file mode 100644 index 0000000..d61b068 --- /dev/null +++ b/9. Channels/2. Finish/TelemetryPipeline/Verification/ActiveStepRun.cs @@ -0,0 +1,3 @@ +namespace TelemetryPipeline; + +public sealed record ActiveStepRun(WorkshopStep Step, StepReport Report); \ No newline at end of file diff --git a/9. Channels/2. Finish/TelemetryPipeline/Verification/CodeInspector.cs b/9. Channels/2. Finish/TelemetryPipeline/Verification/CodeInspector.cs new file mode 100644 index 0000000..355f0f7 --- /dev/null +++ b/9. Channels/2. Finish/TelemetryPipeline/Verification/CodeInspector.cs @@ -0,0 +1,104 @@ +using System.Reflection; +using System.Reflection.Emit; +using System.Runtime.CompilerServices; + +namespace TelemetryPipeline; + +// Workshop plumbing: lists the methods your compiled code calls, formatted as "Type.Method". +// The steps use it only for requirements that no behavior can show, such as reading a counter with Volatile.Read. +// It reads the IL, so it also sees calls inside lambdas, local functions and async state machines. +public static class CodeInspector +{ + const BindingFlags _declaredMembers = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + + // Every opcode, keyed by its value. Two-byte opcodes start with 0xFE. + static readonly Dictionary _opCodes = typeof(OpCodes) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Select(static field => (OpCode)(field.GetValue(null) ?? throw new InvalidOperationException($"OpCodes.{field.Name} has no value"))) + .ToDictionary(static opCode => opCode.Value); + + // Every call in a type and in the types nested inside it. A method called in two places appears twice. + public static IReadOnlyList GetCalledMethods(Type type) + { + var calls = new List(); + + AddCalls(type, calls); + + return calls; + } + + // Every call in one method, including the async state machine the compiler generated for it + public static IReadOnlyList GetCalledMethods(MethodInfo method) + { + var calls = new List(); + + AddCalls(method, calls); + + if (method.GetCustomAttribute() is { } stateMachine) + AddCalls(stateMachine.StateMachineType, calls); + + return calls; + } + + static void AddCalls(Type type, List calls) + { + foreach (var method in type.GetMethods(_declaredMembers)) + { + AddCalls(method, calls); + } + + foreach (var constructor in type.GetConstructors(_declaredMembers)) + { + AddCalls(constructor, calls); + } + + foreach (var nestedType in type.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic)) + { + AddCalls(nestedType, calls); + } + } + + static void AddCalls(MethodBase method, List calls) + { + if (method.GetMethodBody()?.GetILAsByteArray() is not { } il) + return; + + var typeArguments = method.DeclaringType is { IsGenericType: true } declaringType ? declaringType.GetGenericArguments() : null; + var methodArguments = method.IsGenericMethod ? method.GetGenericArguments() : null; + var position = 0; + + while (position < il.Length) + { + var opCode = il[position] is 0xFE + ? _opCodes[unchecked((short)(0xFE00 | il[position + 1]))] + : _opCodes[il[position]]; + + position += opCode.Size; + + if (opCode.OperandType is OperandType.InlineMethod) + { + try + { + var token = BitConverter.ToInt32(il, position); + + if (method.Module.ResolveMethod(token, typeArguments, methodArguments) is { DeclaringType: { } calledType } calledMethod) + calls.Add($"{calledType.Name}.{calledMethod.Name}"); + } + catch (ArgumentException) + { + // A token this module cannot resolve is not a call to anything the steps look for + } + } + + position += opCode.OperandType switch + { + OperandType.InlineNone => 0, + OperandType.ShortInlineBrTarget or OperandType.ShortInlineI or OperandType.ShortInlineVar => 1, + OperandType.InlineVar => 2, + OperandType.InlineI8 or OperandType.InlineR => 8, + OperandType.InlineSwitch => 4 + (BitConverter.ToInt32(il, position) * 4), + _ => 4, + }; + } + } +} \ No newline at end of file diff --git a/9. Channels/2. Finish/TelemetryPipeline/Verification/StepCheck.cs b/9. Channels/2. Finish/TelemetryPipeline/Verification/StepCheck.cs new file mode 100644 index 0000000..03ed2ed --- /dev/null +++ b/9. Channels/2. Finish/TelemetryPipeline/Verification/StepCheck.cs @@ -0,0 +1,3 @@ +namespace TelemetryPipeline; + +public sealed record StepCheck(string Description, string Expected, string Actual, bool Passed, string Hint); \ No newline at end of file diff --git a/9. Channels/2. Finish/TelemetryPipeline/Verification/StepLogEntry.cs b/9. Channels/2. Finish/TelemetryPipeline/Verification/StepLogEntry.cs new file mode 100644 index 0000000..ddd3c51 --- /dev/null +++ b/9. Channels/2. Finish/TelemetryPipeline/Verification/StepLogEntry.cs @@ -0,0 +1,3 @@ +namespace TelemetryPipeline; + +public sealed record StepLogEntry(TimeSpan Elapsed, int ThreadId, bool IsThreadPoolThread, string Message); \ No newline at end of file diff --git a/9. Channels/2. Finish/TelemetryPipeline/Verification/StepReport.cs b/9. Channels/2. Finish/TelemetryPipeline/Verification/StepReport.cs new file mode 100644 index 0000000..22357b3 --- /dev/null +++ b/9. Channels/2. Finish/TelemetryPipeline/Verification/StepReport.cs @@ -0,0 +1,82 @@ +using System.Diagnostics; + +namespace TelemetryPipeline; + +// Collects what a step logged and every expected result it checked. +// Steps write to it from thread pool threads while the workshop guide reads it, so every member takes the lock. +public sealed class StepReport +{ + readonly Lock _lock = new(); + readonly Stopwatch _stopwatch = Stopwatch.StartNew(); + readonly List _entries = []; + readonly List _checks = []; + + bool _isComplete; + + public IReadOnlyList Entries + { + get + { + lock (_lock) + { + return [.. _entries]; + } + } + } + + public IReadOnlyList Checks + { + get + { + lock (_lock) + { + return [.. _checks]; + } + } + } + + public void Log(string message) + { + var threadId = Environment.CurrentManagedThreadId; + var isThreadPoolThread = Thread.CurrentThread.IsThreadPoolThread; + + lock (_lock) + { + if (!_isComplete) + _entries.Add(new StepLogEntry(_stopwatch.Elapsed, threadId, isThreadPoolThread, message)); + } + } + + // The hint is shown under the expected result only when it does not match + public void Expect(string description, T expected, T actual, string hint) + { + Expect(description, Format(expected), Format(actual), EqualityComparer.Default.Equals(expected, actual), hint); + } + + public void Expect(string description, string expected, string actual, bool passed, string hint) + { + lock (_lock) + { + if (!_isComplete) + _checks.Add(new StepCheck(description, expected, actual, passed, hint)); + } + } + + // Called when the run ends, including a run that timed out or was stopped while its step is still blocked somewhere. + // Anything that abandoned step writes later is ignored, so the result in the workshop guide can no longer change. + public void Complete() + { + lock (_lock) + { + _isComplete = true; + _stopwatch.Stop(); + } + } + + static string Format(T value) => value switch + { + null => "null", + int or long => $"{value:N0}", + _ => value.ToString() ?? "null", + }; +} \ No newline at end of file diff --git a/9. Channels/2. Finish/TelemetryPipeline/Verification/StepState.cs b/9. Channels/2. Finish/TelemetryPipeline/Verification/StepState.cs new file mode 100644 index 0000000..005f77c --- /dev/null +++ b/9. Channels/2. Finish/TelemetryPipeline/Verification/StepState.cs @@ -0,0 +1,10 @@ +namespace TelemetryPipeline; + +// MissingMember is set for NotImplemented and ExceptionType for Crashed. Only names are kept: exception messages and stack traces stay in the terminal. +// CompletedAt is when the run that produced this state ended, so the workshop guide can show that a click really ran again. +public sealed record StepState( + StepStatus Status, + StepReport? Report, + string? MissingMember = null, + string? ExceptionType = null, + DateTimeOffset? CompletedAt = null); \ No newline at end of file diff --git a/9. Channels/2. Finish/TelemetryPipeline/Verification/StepStatus.cs b/9. Channels/2. Finish/TelemetryPipeline/Verification/StepStatus.cs new file mode 100644 index 0000000..18d9055 --- /dev/null +++ b/9. Channels/2. Finish/TelemetryPipeline/Verification/StepStatus.cs @@ -0,0 +1,13 @@ +namespace TelemetryPipeline; + +public enum StepStatus +{ + NotRun, + Running, + Passed, + Failed, + NotImplemented, + TimedOut, + Crashed, + Stopped, +} \ No newline at end of file diff --git a/9. Channels/2. Finish/TelemetryPipeline/Verification/StepVerifier.cs b/9. Channels/2. Finish/TelemetryPipeline/Verification/StepVerifier.cs new file mode 100644 index 0000000..ae3f9c5 --- /dev/null +++ b/9. Channels/2. Finish/TelemetryPipeline/Verification/StepVerifier.cs @@ -0,0 +1,266 @@ +namespace TelemetryPipeline; + +// Workshop plumbing: runs the steps against your services and keeps their results. +// Every step creates its own instances of the services, so a check never disturbs the singletons the Ingest page uses. +// Only one step runs at a time, so a step that measures how long something takes never competes with another run. +public sealed class StepVerifier(ILogger logger, IHostApplicationLifetime applicationLifetime) : BackgroundService +{ + public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); + + readonly Lock _lock = new(); + readonly StepState[] _states = [.. Enumerable.Repeat(new StepState(StepStatus.NotRun, null), 4)]; + + // Both start at 1: the app checks every step on startup, and nothing else may run before that finishes + int _isBusy = 1; + int _isCheckingAllSteps = 1; + + ActiveStepRun? _activeRun; + TaskCompletionSource? _stopRequested; + + public IReadOnlyList Steps { get; } = + [ + new Step1AnswerRightAway(), + new Step2QueueIntoBoundedChannel(), + new Step3DrainWithSeveralConsumers(), + new Step4DrainBacklogOnShutdown(), + ]; + + public bool IsBusy => Volatile.Read(ref _isBusy) is 1; + + public bool IsCheckingAllSteps => Volatile.Read(ref _isCheckingAllSteps) is 1; + + public ActiveStepRun? ActiveRun + { + get + { + lock (_lock) + { + return _activeRun; + } + } + } + + public WorkshopStep? FindStep(int number) => Steps.FirstOrDefault(step => step.Number == number); + + public StepState GetState(int number) + { + lock (_lock) + { + return _states[number - 1]; + } + } + + public bool IsUnlocked(int number) => number is 1 || GetState(number - 1).Status is StepStatus.Passed; + + // Re-runs every step, the same way startup does + public async Task CheckAllSteps() + { + if (Interlocked.CompareExchange(ref _isBusy, 1, 0) is not 0) + return; + + Volatile.Write(ref _isCheckingAllSteps, 1); + + try + { + await CheckSteps(applicationLifetime.ApplicationStopping).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isCheckingAllSteps, 0); + Volatile.Write(ref _isBusy, 0); + } + } + + public async Task RunStep(WorkshopStep step) + { + if (!IsUnlocked(step.Number) || Interlocked.CompareExchange(ref _isBusy, 1, 0) is not 0) + return GetState(step.Number); + + try + { + return await Run(step, applicationLifetime.ApplicationStopping).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isBusy, 0); + } + } + + public void StopRun() + { + lock (_lock) + { + _stopRequested?.TrySetResult(); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await CheckSteps(stoppingToken).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref _isCheckingAllSteps, 0); + Volatile.Write(ref _isBusy, 0); + } + } + + // Finds the member named by a stub's NotImplementedException. + // The stub message is checked first because it survives a rethrow that resets the stack trace. + static string? FindMissingMember(Exception exception) + { + const string stubMessageSuffix = " is not implemented yet"; + + for (var current = exception; current is not null; current = current.InnerException) + { + if (current is not NotImplementedException) + continue; + + var suffixIndex = current.Message.IndexOf(stubMessageSuffix, StringComparison.Ordinal); + + if (suffixIndex > 0) + return current.Message[..suffixIndex]; + + if (current.TargetSite is { DeclaringType: { } declaringType } method && declaringType.Assembly == typeof(StepVerifier).Assembly) + return method.IsSpecialName ? $"{declaringType.Name}.{method.Name.Replace("get_", string.Empty, StringComparison.Ordinal)}" : $"{declaringType.Name}.{method.Name}()"; + } + + return null; + } + + async Task CheckSteps(CancellationToken token) + { + try + { + // Results from before the latest change to the services are not trusted + lock (_lock) + { + Array.Fill(_states, new StepState(StepStatus.NotRun, null)); + } + + logger.LogInformation("Checking your services against every step. Open the app in your browser: the workshop guide beside the Ingest page shows what to fix next"); + + foreach (var step in Steps) + { + var state = await Run(step, token).ConfigureAwait(false); + + if (state.Status is not StepStatus.Passed) + { + logger.LogInformation("Stopped checking at Step {StepNumber}. Open it in the app to see what to change, with a hint for every result that did not match. Later steps unlock after it passes", step.Number); + return; + } + } + + logger.LogInformation("Every step passed. Pause here for the group review"); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // The app is shutting down + } + catch (Exception e) + { + logger.LogError(e, "Checking the steps failed unexpectedly"); + } + } + + async Task Run(WorkshopStep step, CancellationToken token) + { + var report = new StepReport(); + var stopRequested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var previousState = GetState(step.Number); + + lock (_lock) + { + _activeRun = new ActiveStepRun(step, report); + _stopRequested = stopRequested; + _states[step.Number - 1] = new StepState(StepStatus.Running, report); + } + + // Cancelled when the run ends for any reason, so a step that is still working can stop + using var runCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + + var state = previousState; + + try + { + state = await Execute(step, report, stopRequested.Task, runCancellationTokenSource.Token, token).ConfigureAwait(false); + } + finally + { + await runCancellationTokenSource.CancelAsync().ConfigureAwait(false); + + report.Complete(); + + lock (_lock) + { + _activeRun = null; + _stopRequested = null; + _states[step.Number - 1] = state with { CompletedAt = DateTimeOffset.Now }; + } + } + + return state; + } + + async Task Execute(WorkshopStep step, StepReport report, Task stopRequested, CancellationToken runToken, CancellationToken token) + { + try + { + // A dedicated thread runs the step until its first await. If your code blocks it forever, + // a thread pool thread is not lost for the rest of the app's life. + var stepTask = Task.Factory.StartNew(() => step.Run(new StepContext(report, runToken)), token, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); + + var finished = await Task.WhenAny(stepTask, stopRequested).WaitAsync(Timeout, token).ConfigureAwait(false); + + if (finished == stopRequested) + { + logger.LogInformation("Step {StepNumber} was stopped", step.Number); + return new StepState(StepStatus.Stopped, report); + } + + await stepTask.ConfigureAwait(false); + + var checks = report.Checks; + var passedChecks = checks.Count(static check => check.Passed); + var status = checks.Count > 0 && passedChecks == checks.Count ? StepStatus.Passed : StepStatus.Failed; + + logger.LogInformation("Step {StepNumber} {Result} ({PassedChecks}/{TotalChecks} checks)", step.Number, status is StepStatus.Passed ? "passed" : "failed", passedChecks, checks.Count); + + // The hints belong in the terminal too, next to anything the app logged while the step ran + foreach (var check in checks.Where(static check => !check.Passed)) + { + logger.LogWarning("Step {StepNumber}: {Description}. Expected {Expected}, actual {Actual}. Hint: {Hint}", step.Number, check.Description, check.Expected, check.Actual, check.Hint); + } + + return new StepState(status, report); + } + catch (TimeoutException) + { + logger.LogWarning("Step {StepNumber} did not finish within {Timeout}. {TimeoutHint}", step.Number, Timeout, step.TimeoutHint); + return new StepState(StepStatus.TimedOut, report); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + return new StepState(StepStatus.NotRun, null); + } + catch (Exception e) + { + return Classify(step, report, e); + } + } + + StepState Classify(WorkshopStep step, StepReport report, Exception exception) + { + if (FindMissingMember(exception) is { } missingMember) + { + // The stub's message is the hint, so it belongs in the terminal + logger.LogWarning(exception, "Step {StepNumber}: {MissingMember} still throws NotImplementedException", step.Number, missingMember); + return new StepState(StepStatus.NotImplemented, report, missingMember); + } + + logger.LogError(exception, "Step {StepNumber} threw an unexpected exception", step.Number); + return new StepState(StepStatus.Crashed, report, ExceptionType: exception.GetType().Name); + } +} \ No newline at end of file diff --git a/9. Channels/2. Finish/TelemetryPipeline/wwwroot/app.css b/9. Channels/2. Finish/TelemetryPipeline/wwwroot/app.css index c5a79b2..6445543 100644 --- a/9. Channels/2. Finish/TelemetryPipeline/wwwroot/app.css +++ b/9. Channels/2. Finish/TelemetryPipeline/wwwroot/app.css @@ -1,20 +1,37 @@ +/* TelemetryPipeline: an IoT fleet control room, with the workshop guide docked beside it as the on-call handover note on a yellow legal pad */ + :root { - --canvas: #eef3f1; - --panel: #ffffff; - --panel-strong: #112a2d; - --ink: #18211f; - --muted: #62706d; - --line: #d7e0dd; - --accent: #f46a1f; - --teal: #0f766e; - --good: #0f8a52; - --bad: #cf202f; - --warn: #c59d35; - --shadow: 0 18px 55px rgb(28 45 42 / 14%); - color-scheme: light; - font-family: "Aptos", "Avenir Next", "Segoe UI", sans-serif; - background: var(--canvas); - color: var(--ink); + --midnight: #070920; + --console-card: #1a1f52; + --console-line: rgb(150 160 255 / 18%); + --console-ink: #eceeff; + --console-muted: #a6aee0; + --cyan: #2ee6f6; + --cyan-bright: #7df3fb; + --violet: #9d74ff; + --signal-good: #3cf0a4; + --signal-bad: #ff5775; + --signal-warn: #ffbd4a; + --pad: #fff6ad; + --pad-slip: #fffdf1; + --pad-rule: rgb(84 132 204 / 26%); + --pad-margin: rgb(222 76 76 / 60%); + --pad-binding: #7a2a24; + --ink: #1b2240; + --ink-blue: #1f4bb0; + --ink-blue-dark: #173a8a; + --pencil: #5c5840; + --pencil-line: rgb(92 88 64 / 30%); + --red-pen: #c32a1f; + --hand: "Bradley Hand", "Segoe Print", "Comic Sans MS", cursive; + --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif; + --display: "Bahnschrift", "DIN Alternate", "Avenir Next Condensed", "Segoe UI", sans-serif; + --mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace; + --guide-width: 460px; + color-scheme: dark; + font-family: var(--sans); + background: var(--midnight); + color: var(--console-ink); } * { @@ -24,248 +41,1280 @@ body { margin: 0; overflow-x: clip; - background: - linear-gradient(135deg, rgb(15 118 110 / 14%), transparent 32%), - radial-gradient(circle at top right, rgb(244 106 31 / 16%), transparent 30%), - var(--canvas); + background: var(--midnight); } -.shell { - min-height: 100vh; +/* FocusOnNavigate moves focus to the page heading for screen readers; the heading is not interactive, so hide the ring */ +h1:focus { + outline: none; } -.page { - width: min(1120px, calc(100% - 32px)); - margin: 0 auto; - padding: 32px 0 56px; +code { + font-family: var(--mono); + font-size: 0.88em; } -.hero-panel { - display: grid; - grid-template-columns: 1fr auto; - gap: 28px; - align-items: end; - margin-bottom: 22px; - padding: 30px; - border: 1px solid rgb(36 35 31 / 10%); - border-radius: 8px; - background: linear-gradient(135deg, var(--panel-strong), #15463e 58%, #6f3b17); - box-shadow: var(--shadow); - color: #f8fffc; +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; } -.hero-copy { - max-width: 680px; +@keyframes spin { + to { + transform: rotate(360deg); + } } -.eyebrow { - margin: 0 0 6px; - font-size: 0.78rem; - font-weight: 600; - letter-spacing: 0.12em; - text-transform: uppercase; - color: rgb(248 255 252 / 72%); -} +/* The app and the guide share the screen */ -.hero-panel h1 { - margin: 0 0 10px; - font-size: clamp(1.8rem, 3vw, 2.5rem); - line-height: 1.1; +.workspace { + display: flex; + min-height: 100vh; } -.hero-summary { - margin: 0; - color: rgb(248 255 252 / 80%); +.app-pane { + flex: 1 1 auto; + min-width: 0; + background: + radial-gradient(circle at 12% -10%, rgb(157 116 255 / 28%), transparent 42%), + radial-gradient(circle at 95% 30%, rgb(46 230 246 / 14%), transparent 38%), + linear-gradient(rgb(150 160 255 / 5%) 1px, transparent 1px) 0 0 / 32px 32px, + linear-gradient(90deg, rgb(150 160 255 / 5%) 1px, transparent 1px) 0 0 / 32px 32px, + var(--midnight); } -.hero-actions { - display: grid; - gap: 10px; - justify-items: end; -} +/* Buttons */ button.action { + display: inline-flex; + align-items: center; + justify-content: center; padding: 12px 22px; border: 0; - border-radius: 6px; - background: var(--accent); - color: #fff; + border-radius: 10px; + background: var(--cyan); + box-shadow: 0 0 0 1px rgb(125 243 251 / 50%), 0 0 24px rgb(46 230 246 / 35%); + color: #031018; font: inherit; - font-weight: 600; + font-weight: 700; cursor: pointer; } button.action:hover:not(:disabled) { - background: #d4560f; + background: var(--cyan-bright); } button.action:disabled { - background: var(--muted); - cursor: progress; + background: #3b4175; + box-shadow: none; + color: #b9bfe6; + cursor: not-allowed; } button.action.secondary { - background: rgb(248 255 252 / 14%); - border: 1px solid rgb(248 255 252 / 30%); + border: 1px solid rgb(157 116 255 / 70%); + background: rgb(157 116 255 / 12%); + box-shadow: none; + color: var(--console-ink); } button.action.secondary:hover:not(:disabled) { - background: rgb(248 255 252 / 24%); + background: rgb(157 116 255 / 28%); +} + +button.action.small { + padding: 7px 14px; + font-size: 0.88rem; +} + +.spinner { + display: inline-block; + width: 14px; + height: 14px; + margin-right: 8px; + border: 2px solid rgb(185 191 230 / 35%); + border-top-color: #eceeff; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +/* The fleet control room */ + +.control-room { + container: control-room / inline-size; + width: min(1040px, calc(100% - 48px)); + margin: 0 auto; + padding: 18px 0 56px; +} + +.ops-bar { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px 20px; + padding: 6px 0 16px; + border-bottom: 1px solid var(--console-line); +} + +.ops-logo { + font-family: var(--display); + font-size: 1.2rem; + font-weight: 700; + letter-spacing: 0.02em; +} + +.ops-logo-mark { + color: var(--cyan); + text-shadow: 0 0 12px rgb(46 230 246 / 70%); +} + +.ops-status { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 4px 12px; + border: 1px solid rgb(157 116 255 / 45%); + border-radius: 999px; + color: #cdbdff; + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.ops-status-light { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--signal-good); + box-shadow: 0 0 10px var(--signal-good); +} + +.ops-hero { + position: relative; + margin: 22px 0 18px; + padding: 28px 34px 26px; + overflow: hidden; + border: 1px solid var(--console-line); + border-radius: 18px; + background: + radial-gradient(circle at 100% 0%, rgb(46 230 246 / 22%), transparent 40%), + radial-gradient(circle at 0% 120%, rgb(157 116 255 / 32%), transparent 48%), + linear-gradient(135deg, #171c4d, #0c0f2e); + box-shadow: 0 30px 70px rgb(0 0 0 / 40%); } -.panel { - margin-bottom: 16px; +/* A radar sweep over the fleet, drawn with gradients, on screens wide enough to leave it room */ +.ops-hero::after { + content: ""; + position: absolute; + top: 50%; + right: 36px; + display: none; + width: 200px; + aspect-ratio: 1; + border: 1px solid rgb(46 230 246 / 35%); + border-radius: 50%; + background: + radial-gradient(circle, var(--cyan) 0 3px, transparent 4px), + repeating-radial-gradient(circle, transparent 0 32px, rgb(46 230 246 / 22%) 32px 33px), + conic-gradient(from 0deg, rgb(46 230 246 / 45%), transparent 22%); + box-shadow: 0 0 40px rgb(46 230 246 / 15%); + translate: 0 -50%; + animation: spin 5s linear infinite; +} + +@container control-room (min-width: 900px) { + .ops-hero::after { + display: block; + } +} + +.ops-eyebrow { + margin: 0 0 10px; + color: var(--cyan); + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.ops-hero h1 { + max-width: 640px; + margin: 0 0 12px; + font-family: var(--display); + font-size: clamp(2rem, 4vw, 3rem); + font-weight: 700; + line-height: 1.05; + letter-spacing: -0.01em; +} + +.ops-summary { + max-width: 580px; + margin: 0 0 16px; + color: rgb(236 238 255 / 82%); + font-size: 1.04rem; + line-height: 1.5; +} + +.ops-limits { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin: 0 0 20px; +} + +.ops-limits div { + padding: 8px 14px; + border: 1px solid var(--console-line); + border-radius: 10px; + background: rgb(7 9 32 / 45%); +} + +.ops-limits dt { + color: var(--console-muted); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.ops-limits dd { + margin: 2px 0 0; + font-family: var(--mono); + font-weight: 700; +} + +.ops-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.ops-panel { + margin-bottom: 18px; padding: 22px 24px; - border: 1px solid var(--line); - border-radius: 8px; - background: var(--panel); - box-shadow: 0 10px 26px rgb(28 45 42 / 8%); + border: 1px solid var(--console-line); + border-radius: 14px; + background: rgb(21 26 69 / 88%); } -.panel h2 { +.ops-panel h2 { margin: 0 0 4px; - font-size: 1.15rem; + font-family: var(--display); + font-size: 1.2rem; + font-weight: 700; + letter-spacing: 0.02em; } -.panel-hint { +.ops-hint { margin: 0 0 16px; - color: var(--muted); - font-size: 0.92rem; + color: var(--console-muted); + font-size: 0.93rem; + line-height: 1.5; +} + +.ops-panel .ops-hint:last-child { + margin-bottom: 0; +} + +/* The pipeline diagram: devices, queue, consumers, store */ + +.pipeline { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(24px, 64px) minmax(0, 1fr) minmax(24px, 64px) minmax(0, 1fr) minmax(24px, 64px) minmax(0, 1fr); + align-items: center; +} + +.pipeline-node { + display: grid; + justify-items: center; + gap: 2px; + min-width: 0; + padding: 14px 8px; + border: 1px solid rgb(46 230 246 / 35%); + border-radius: 14px; + background: linear-gradient(180deg, #1d2360, #141945); + box-shadow: inset 0 0 18px rgb(46 230 246 / 8%); + text-align: center; } +.pipeline-node.busy { + border-color: rgb(255 189 74 / 70%); + box-shadow: inset 0 0 18px rgb(255 189 74 / 12%), 0 0 18px rgb(255 189 74 / 22%); +} + +.pipeline-node.done { + border-color: rgb(60 240 164 / 70%); + box-shadow: inset 0 0 18px rgb(60 240 164 / 12%), 0 0 18px rgb(60 240 164 / 22%); +} + +.node-glyph { + color: var(--cyan); + font-size: 1.3rem; + line-height: 1; + text-shadow: 0 0 10px rgb(46 230 246 / 70%); +} + +.pipeline-node.busy .node-glyph { + color: var(--signal-warn); + text-shadow: 0 0 10px rgb(255 189 74 / 70%); +} + +.pipeline-node.done .node-glyph { + color: var(--signal-good); + text-shadow: 0 0 10px rgb(60 240 164 / 70%); +} + +.node-name { + color: var(--console-muted); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.node-value { + max-width: 100%; + overflow: hidden; + font-family: var(--mono); + font-size: 1.25rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; +} + +.node-caption { + color: var(--console-muted); + font-size: 0.75rem; +} + +/* A link fills with the share of the burst that crossed it, and its dashes move while readings are on their way */ +.pipeline-link { + position: relative; + height: 6px; + margin: 0 -1px; + background: rgb(150 160 255 / 16%); +} + +.link-track { + position: absolute; + inset: 0 auto 0 0; + width: var(--fill, 0%); + background: linear-gradient(90deg, var(--violet), var(--cyan)); + box-shadow: 0 0 12px rgb(46 230 246 / 60%); +} + +.pipeline-link::after { + content: "▸"; + position: absolute; + top: 50%; + right: -4px; + color: var(--cyan); + font-size: 0.9rem; + line-height: 1; + transform: translateY(-50%); +} + +.pipeline-link.flowing .link-track { + width: 100%; + background: repeating-linear-gradient(90deg, var(--cyan) 0 10px, transparent 10px 18px); + background-size: 18px 100%; + animation: flow 0.6s linear infinite; +} + +@keyframes flow { + to { + background-position: 18px 0; + } +} + +/* Gauge-style counter cards */ + .result-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); - gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 14px; } .result-card { + --signal: var(--violet); display: grid; - gap: 4px; - padding: 14px 16px; - border: 1px solid var(--line); - border-left: 4px solid var(--muted); - border-radius: 6px; - background: #fbfdfc; + justify-items: center; + gap: 2px; + padding: 18px 16px 16px; + border: 1px solid var(--console-line); + border-top: 3px solid var(--signal); + border-radius: 14px; + background: linear-gradient(180deg, var(--console-card), #12163d); + text-align: center; } .result-card.ok { - border-left-color: var(--good); + --signal: var(--signal-good); + box-shadow: 0 0 26px rgb(60 240 164 / 20%); } .result-card.bad { - border-left-color: var(--bad); + --signal: var(--signal-bad); + box-shadow: 0 0 26px rgb(255 87 117 / 26%); } .result-card.pending { - border-left-color: var(--warn); + --signal: var(--signal-warn); + box-shadow: 0 0 26px rgb(255 189 74 / 20%); +} + +/* A half dial: the colored arc sweeps from left to right as the value approaches its limit */ +.gauge { + width: 150px; + height: 75px; + margin-bottom: -30px; + border-radius: 150px 150px 0 0; + background: conic-gradient(from 270deg at 50% 100%, var(--signal) calc(var(--gauge, 0) * 1.8deg), rgb(150 160 255 / 18%) 0 180deg, transparent 0); + filter: drop-shadow(0 0 6px var(--signal)); + -webkit-mask: radial-gradient(circle 75px at 50% 100%, transparent 62px, #000 63px); + mask: radial-gradient(circle 75px at 50% 100%, transparent 62px, #000 63px); } .result-label { - font-size: 0.78rem; - font-weight: 600; - letter-spacing: 0.06em; + order: -1; + margin-bottom: 10px; + color: var(--console-muted); + font-size: 0.74rem; + font-weight: 700; + letter-spacing: 0.1em; text-transform: uppercase; - color: var(--muted); } .result-value { - font-size: 1.5rem; + position: relative; + font-family: var(--mono); + font-size: 1.6rem; font-weight: 700; font-variant-numeric: tabular-nums; } .result-note { - font-size: 0.85rem; - color: var(--muted); + margin-top: 4px; + color: var(--console-muted); + font-size: 0.84rem; + line-height: 1.4; } .result-card.ok .result-note { - color: var(--good); + color: var(--signal-good); } .result-card.bad .result-note { - color: var(--bad); + color: #ff8da1; } -.timing { - font-variant-numeric: tabular-nums; - font-weight: 600; +.result-card.pending .result-note { + color: var(--signal-warn); } -.log { - max-height: 260px; +/* The workshop guide: the on-call handover, written on a yellow legal pad */ + +.guide { + position: sticky; + top: 0; + flex: 0 0 var(--guide-width); + height: 100vh; overflow-y: auto; + border-top: 14px solid var(--pad-binding); + background-color: var(--pad); + background-image: + linear-gradient(90deg, transparent 28px, var(--pad-margin) 28px 29px, transparent 29px 32px, var(--pad-margin) 32px 33px, transparent 33px), + repeating-linear-gradient(180deg, transparent 0 25px, var(--pad-rule) 25px 26px); + background-attachment: local; + box-shadow: -20px 0 50px rgb(0 0 0 / 45%); + color: var(--ink); + font-family: var(--sans); + font-size: 0.95rem; + color-scheme: light; +} + +.guide.collapsed { + flex-basis: 76px; + display: grid; + align-content: start; + justify-items: center; + gap: 14px; + padding: 14px 0; + background-image: repeating-linear-gradient(180deg, transparent 0 25px, var(--pad-rule) 25px 26px); +} + +.guide button.action { + padding: 9px 18px; + border: 0; + border-radius: 6px; + background: var(--ink-blue); + box-shadow: 0 2px 0 var(--ink-blue-dark); + color: #fff; +} + +.guide button.action:hover:not(:disabled) { + background: var(--ink-blue-dark); +} + +.guide button.action:disabled { + background: #8d99bd; + box-shadow: none; + color: #f4f5fb; +} + +.guide button.action.small { + padding: 6px 14px; + font-size: 0.86rem; +} + +.guide button.action.secondary { + border: 1.5px solid var(--ink-blue); + background: var(--pad-slip); + box-shadow: none; + color: var(--ink-blue); +} + +.guide button.action.secondary:hover:not(:disabled) { + background: #e7eeff; +} + +.guide .spinner { + border-color: rgb(255 255 255 / 40%); + border-top-color: #fff; +} + +.guide .banner .spinner { + border-color: rgb(31 75 176 / 25%); + border-top-color: var(--ink-blue); + vertical-align: -2px; +} + +.guide-header { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 16px 18px 12px 46px; + background: radial-gradient(circle, rgb(122 42 36 / 30%) 1.5px, transparent 2px) 0 3px / 12px 8px repeat-x; +} + +.guide-eyebrow { + margin: 4px 0 0; + color: var(--pencil); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.guide-title { margin: 0; - padding: 14px 16px; - border: 1px solid var(--line); + color: var(--ink-blue); + font-family: var(--hand); + font-size: 2rem; + font-weight: 700; + line-height: 1.2; +} + +.guide-subtitle { + margin: 2px 0 0; + color: var(--ink); + font-size: 0.92rem; + line-height: 1.45; +} + +.guide-toggle { + flex: none; + align-self: start; + display: grid; + justify-items: center; + gap: 8px; + min-width: 36px; + margin-top: 4px; + padding: 6px 8px; + border: 1.5px solid var(--ink-blue); border-radius: 6px; - background: #1e2227; - color: #d7dde6; - font-family: "Menlo", "Consolas", monospace; - font-size: 0.82rem; - line-height: 1.6; - white-space: pre-wrap; + background: var(--pad-slip); + color: var(--ink-blue); + font: inherit; + font-size: 1.1rem; + font-weight: 700; + cursor: pointer; +} + +.guide-toggle:hover { + background: #e7eeff; +} + +.guide-rail-label { + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + writing-mode: vertical-rl; +} + +.guide-rail { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; } -.status-tile { +.guide-rail-step { display: grid; - gap: 4px; - justify-items: end; - padding: 14px 18px; - border: 1px solid rgb(248 255 252 / 22%); + justify-items: center; + gap: 2px; + width: 52px; + padding: 6px 0; + border: 1.5px solid rgb(31 75 176 / 35%); border-radius: 8px; - background: rgb(248 255 252 / 10%); + background: var(--pad-slip); + color: var(--ink); + font: inherit; + cursor: pointer; +} + +.guide-rail-step:hover { + border-color: var(--ink-blue); } -.status-value { - font-size: 1.9rem; +.guide-rail-number { + color: var(--ink-blue); + font-weight: 700; +} + +.guide-progress { + display: grid; + gap: 10px; + padding: 4px 18px 16px 46px; + border-bottom: 2px solid var(--pencil-line); +} + +.progress-meter { + display: flex; + gap: 8px; +} + +.progress-segment { + position: relative; + flex: 1; + height: 18px; + border: 2px solid var(--ink-blue); + border-radius: 3px 5px 4px 6px; + background: var(--pad-slip); +} + +.progress-segment.done { + background: #3d9a45; + border-color: #2b7433; +} + +.progress-segment.done::after { + content: "✓"; + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: #fff; + font-size: 0.8rem; font-weight: 700; line-height: 1; - font-variant-numeric: tabular-nums; } -.status-label { - font-size: 0.82rem; - color: rgb(248 255 252 / 78%); +.guide-progress-row { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px; } -.spinner { +.guide-progress-count { + margin: 0; + color: var(--pencil); +} + +.guide-progress-count strong { + color: var(--ink); + font-size: 1.1rem; +} + +.guide-tabs { + display: grid; + gap: 2px; + padding: 10px 14px 10px 40px; + border-bottom: 2px solid var(--pencil-line); +} + +.guide-tab { + display: grid; + grid-template-columns: 30px 1fr auto; + gap: 10px; + align-items: center; + padding: 6px 8px; + border: 0; + border-radius: 4px 12px 6px 14px; + background: transparent; + color: var(--ink); + font: inherit; + font-size: 0.93rem; + text-align: left; + cursor: pointer; +} + +.guide-tab:hover { + background: rgb(255 255 255 / 45%); +} + +/* The selected tab is marked with a cyan highlighter, the one accent the note shares with the control room */ +.guide-tab.selected { + background: linear-gradient(100deg, rgb(94 222 232 / 20%), rgb(94 222 232 / 58%) 12%, rgb(94 222 232 / 58%) 88%, rgb(94 222 232 / 25%)); + font-weight: 700; +} + +.guide-tab-number { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border: 2px solid var(--ink-blue); + border-radius: 52% 46% 50% 44% / 46% 52% 44% 50%; + color: var(--ink-blue); + font-size: 0.85rem; + font-weight: 700; +} + +.guide-tab.selected .guide-tab-number { + background: var(--ink-blue); + color: #fff; +} + +.guide-body { + display: grid; + gap: 18px; + padding: 18px 18px 44px 46px; +} + +.guide-section { + min-width: 0; +} + +.guide-section h3, +.guide-section h4 { + margin: 0 0 6px; + color: var(--ink-blue); + font-family: var(--hand); + font-size: 1.4rem; + font-weight: 700; + line-height: 1.25; +} + +.guide-section p { + margin: 0 0 10px; + line-height: 1.6; +} + +.guide-section p:last-child { + margin-bottom: 0; +} + +.guide-note { + color: var(--pencil); + font-size: 0.88rem; +} + +.step-header { + display: grid; + gap: 10px; +} + +.step-eyebrow { + margin: 0; + color: var(--red-pen); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.step-title { + margin: 0 0 4px; + color: var(--ink-blue); + font-family: var(--hand); + font-size: 1.7rem; + font-weight: 700; + line-height: 1.2; +} + +.step-members { + color: var(--pencil); + overflow-wrap: anywhere; +} + +.step-actions { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.step-buttons { + display: flex; + gap: 8px; +} + +.how-it-works, +.task-list { + margin: 0 0 12px; + padding-left: 22px; + line-height: 1.6; +} + +.how-it-works li + li, +.task-list li + li { + margin-top: 6px; +} + +.how-it-works li::marker, +.task-list li::marker { + color: var(--ink-blue); + font-weight: 700; +} + +.how-it-works { + margin-bottom: 0; +} + +.facts { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 12px; + margin: 0 0 12px; + font-size: 0.92rem; +} + +.facts dt { + color: var(--pencil); + font-weight: 700; +} + +.facts dd { + margin: 0; + overflow-wrap: anywhere; +} + +/* Taped-on slips of paper: the see-it callout and the AI note */ +.see-it, +.ai-note { + padding: 10px 14px; + border-radius: 2px; + box-shadow: 1px 2px 0 rgb(27 34 64 / 10%); +} + +.see-it { + border-left: 4px solid var(--ink-blue); + background: #e4efff; +} + +.ai-note { + border-left: 4px solid #c2366e; + background: #ffdce8; + color: #5c1030; +} + +.clues { + padding: 10px 14px; + border: 1.5px dashed var(--pencil-line); + border-radius: 6px; + background: rgb(255 253 241 / 75%); +} + +.clues summary, +.guide-log summary { + color: var(--ink-blue); + font-weight: 700; + cursor: pointer; +} + +.clues ul { + margin: 10px 0 0; + padding-left: 20px; + line-height: 1.6; +} + +.guide-log { + padding: 10px 14px; + border: 1.5px solid var(--pencil-line); + border-radius: 6px; + background: rgb(255 253 241 / 85%); +} + +.guide-log-count { + margin-left: 6px; + color: var(--pencil); + font-weight: 400; + font-size: 0.85rem; +} + +/* Step results */ + +.badge { display: inline-block; - width: 14px; - height: 14px; - margin-right: 8px; - border: 2px solid rgb(98 112 109 / 35%); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; - vertical-align: -2px; + padding: 2px 10px; + border: 1px solid var(--pencil-line); + border-radius: 999px; + background: var(--pad-slip); + color: var(--pencil); + font-size: 0.78rem; + font-weight: 700; + white-space: nowrap; } -@keyframes spin { +.badge-compact { + padding: 0; + border: 0; + background: transparent; + font-size: 0.9rem; +} + +.badge-passed { + border-color: rgb(43 116 51 / 40%); + background: #d9f3cf; + color: #1d5e24; +} + +.badge-failed { + border-color: rgb(195 42 31 / 45%); + background: #ffe0da; + color: #9e1f16; +} + +.badge-running { + border-color: rgb(31 75 176 / 40%); + background: #dfe9fd; + color: var(--ink-blue-dark); +} + +.badge-missing { + border-color: rgb(186 96 0 / 45%); + background: #ffdca6; + color: #6a3500; +} + +.badge-locked { + background: #efe7b4; +} + +.badge-compact.badge-passed, +.badge-compact.badge-failed, +.badge-compact.badge-running, +.badge-compact.badge-missing, +.badge-compact.badge-locked { + background: transparent; +} + +/* Banners are sticky notes stuck to the pad */ +.banner { + display: grid; + justify-items: start; + gap: 6px; + padding: 12px 16px; + border: 1.5px solid var(--pencil-line); + border-radius: 3px; + background: var(--pad-slip); + box-shadow: 2px 3px 0 rgb(27 34 64 / 10%); + line-height: 1.5; +} + +.banner-passed { + border-color: rgb(43 116 51 / 50%); + background: #d9f3cf; + color: #1d5e24; +} + +.banner-failed { + border-color: rgb(195 42 31 / 60%); + background: #ffe0da; + color: #8e1b12; +} + +.banner-missing { + border-color: rgb(186 96 0 / 60%); + background: #ffdca6; + color: #5e2f00; +} + +.banner-running { + border-color: rgb(31 75 176 / 45%); + background: #dfe9fd; + color: var(--ink-blue-dark); +} + +.banner-locked { + border-style: dashed; + color: var(--pencil); +} + +.banner-note { + font-size: 0.83rem; + opacity: 0.85; +} + +/* @key recreates the banner for every new result, so this animation plays again after each click */ +.banner-fresh { + animation: banner-in 0.35s ease-out; +} + +@keyframes banner-in { + from { + opacity: 0; + transform: translateY(-6px); + } + to { - transform: rotate(360deg); + opacity: 1; + transform: none; } } -.warning-banner { - margin-bottom: 16px; - padding: 14px 18px; - border: 1px solid rgb(197 157 53 / 45%); - border-left: 4px solid var(--warn); - border-radius: 6px; - background: #fdf8e9; - color: #6b551a; +.link-button { + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + font-weight: 700; + text-align: left; + text-decoration: underline; + text-underline-offset: 3px; + cursor: pointer; +} + +.check-list { + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.check { + display: grid; + grid-template-columns: 24px 1fr; + gap: 8px; + padding: 10px 12px; + border: 1px solid var(--pencil-line); + border-radius: 4px; + background: var(--pad-slip); +} + +.check-failed { + border: 2px solid rgb(195 42 31 / 55%); + background: #fff7f4; +} + +.check-body { + min-width: 0; +} + +.guide-section .check-description { + margin: 0 0 6px; + font-weight: 600; + line-height: 1.4; +} + +.check-values { + display: grid; + grid-template-columns: max-content 1fr; + gap: 2px 10px; + margin: 0; + font-size: 0.86rem; +} + +.check-values dt { + color: var(--pencil); +} + +.check-values dd { + margin: 0; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} + +/* A hint is a margin note in red pen, pulled out beside the result it explains */ +.guide-section .check-hint { + margin: 10px 0 0 -32px; + padding: 8px 12px; + border-left: 3px solid var(--red-pen); + border-radius: 0 6px 6px 0; + background: #ffe6de; + box-shadow: 2px 2px 0 rgb(142 27 18 / 12%); + color: #7e170f; + font-size: 0.88rem; + line-height: 1.5; +} + +.table-scroll { + margin-top: 10px; + overflow-x: auto; +} + +.step-log { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; +} + +.step-log th, +.step-log td { + padding: 6px 8px; + border-bottom: 1px solid var(--pad-rule); + text-align: left; + vertical-align: top; +} + +.step-log th { + color: var(--pencil); +} + +.step-log td:nth-child(-n + 3) { + font-variant-numeric: tabular-nums; + white-space: nowrap; } -@media (max-width: 640px) { - .hero-panel { - grid-template-columns: 1fr; - align-items: start; +/* Nothing moves for anyone who asked for less motion */ + +@media (prefers-reduced-motion: reduce) { + .ops-hero::after, + .pipeline-link.flowing .link-track, + .banner-fresh { + animation: none; + } +} + +/* Narrow screens stack the guide under the app */ + +@media (max-width: 1080px) { + :root { + --guide-width: 380px; + } +} + +@media (max-width: 860px) { + .workspace { + flex-direction: column; } - .hero-actions, - .status-tile { + .guide, + .guide.collapsed { + position: static; + flex-basis: auto; + height: auto; + box-shadow: none; + } + + .guide.collapsed { + grid-auto-flow: column; + justify-content: start; + padding: 12px 16px; + } + + .guide.collapsed .guide-rail { + grid-auto-flow: column; + } + + .guide-rail-label { + writing-mode: horizontal-tb; + } + + .control-room { + width: calc(100% - 32px); + } + + .ops-hero { + padding: 26px 20px; + } +} + +/* When the control room is narrow, the pipeline runs top to bottom */ + +@container control-room (max-width: 720px) { + .pipeline { + grid-template-columns: minmax(0, 1fr); + } + + .pipeline-node { + grid-template-columns: 28px minmax(0, 1fr) auto; + grid-template-areas: + "glyph name value" + "glyph caption value"; + column-gap: 12px; + row-gap: 0; justify-items: start; + padding: 10px 14px; + text-align: left; + } + + .node-glyph { + grid-area: glyph; + align-self: center; + justify-self: center; + } + + .node-name { + grid-area: name; + } + + .node-caption { + grid-area: caption; + } + + .node-value { + grid-area: value; + align-self: center; + justify-self: end; + } + + .pipeline-link { + width: 6px; + height: 24px; + margin: -1px 0 -1px 25px; + } + + .link-track { + inset: 0 0 auto; + width: auto; + height: var(--fill, 0%); + background: linear-gradient(180deg, var(--violet), var(--cyan)); + } + + .pipeline-link::after { + content: "▾"; + top: auto; + right: 50%; + bottom: -7px; + transform: translateX(50%); + } + + .pipeline-link.flowing .link-track { + height: 100%; + background: repeating-linear-gradient(180deg, var(--cyan) 0 10px, transparent 10px 18px); + background-size: 100% 18px; + animation-name: flow-down; + } +} + +@keyframes flow-down { + to { + background-position: 0 18px; } } diff --git a/9. Channels/Channels.pptx b/9. Channels/Channels.pptx index afc755d..0de9c57 100644 Binary files a/9. Channels/Channels.pptx and b/9. Channels/Channels.pptx differ diff --git a/9. Channels/README.md b/9. Channels/README.md index 6a65c8a..7a975df 100644 --- a/9. Channels/README.md +++ b/9. Channels/README.md @@ -9,22 +9,28 @@ The **1. Start** folder contains the intentionally imperfect code you will edit. 1. Using File Explorer on Windows or Finder on macOS, navigate to **BecomeAnExpertWithAsyncAwait/9. Channels/1. Start**. 2. Open **TelemetryPipeline.slnx** in your IDE. 3. Build the project once so you can confirm the starter compiles. -4. Run the project and open [http://localhost:5013](http://localhost:5013). +4. Run the project and open it in your browser. + +The starter app runs at [http://localhost:5013](http://localhost:5013). The finished app runs at [http://localhost:5014](http://localhost:5014), so you can run both at the same time and compare them later. + +The app opens on the **Ingest** page, with the **workshop guide** docked beside it. Every step in the guide is one problem in the ingest path, and every time the app starts it checks your code against them. Right now the guide says it stopped at Step 1, and the terminal running the app shows the same result, with a hint. ## 2. Inspect the Starting Code -1. Open **TelemetryPipeline/Services/TelemetryIngestService.cs** and find each `// ToDo Refactor` comment. This is the only file you need to change. -2. Open **TelemetryPipeline/Services/TelemetryProcessor.cs**. It is a `BackgroundService` that calls `DrainAsync` for the life of the app. This is the consumer half of the pipeline. +1. Open **TelemetryPipeline/Services/TelemetryIngestService.cs** and find each `// ToDo Refactor (Step N)` comment. The step number tells you which step checks that line. This is where most of your changes go, including `CompleteWriting()`, a stub that throws `NotImplementedException` until Step 4. +2. Open **TelemetryPipeline/Services/TelemetryProcessor.cs**. It is a `BackgroundService` that calls `DrainAsync` for the life of the app. This is the consumer half of the pipeline, and the only other file you change, in Step 4. 3. Open **TelemetryPipeline/Services/EventStore.cs** and notice that `SaveAsync` takes 40 milliseconds. That is the write you cannot make faster. 4. Open **TelemetryPipeline/Components/Pages/Ingest.razor.cs** and notice that the page runs a burst of 400 events and reads its counters straight off the singleton service. +5. Open the **TelemetryPipeline/Steps** folder. Each `Step*.cs` file drives fresh copies of your services the way the device fleet does, with comments that explain what each expected result checks and why. These files are your guide. +6. Skim **TelemetryPipeline/Verification** and **TelemetryPipeline/Components/Layout/WorkshopGuide.razor**. They are the workshop plumbing that runs the steps and draws the guide. You do not need to change them. -Run the app and click **Receive 400 events**. Here is what you will see: +Now click **Receive 400 events** on the Ingest page. Here is what you will see: 1. The button spins for about 16.56 seconds before any results appear. 2. **Accepted** reads 400 of 400. Nothing is lost, so the bug is not correctness. It is time. 3. **Average accept** reads about 41.4ms, and the card is red. A device gives up after 20ms, so in production every one of these readings becomes a retry. 4. **Burst duration** reads about 16.56s. Nothing is blocked. `SaveAsync` is an awaited delay, so no thread is held and other callers are still served. What that number measures is per-caller latency multiplied by the burst: every one of those 400 devices waited more than twice its 20ms timeout, so every one of them retries, and the retries land on an endpoint whose latency is already the sum of everything downstream of it. -5. **Written to store** already reads 400, with nothing meaningful waiting in the queue. You will see 0 or 1, because the write happened inside the accept call. The background service did none of it. +5. **Written to store** already reads 400, and the queue shows 0 or 1 waiting, because the write happened inside the accept call. The background service did none of it. Pay attention to these clues: @@ -36,45 +42,54 @@ Pay attention to these clues: 6. Nothing ever tells `DrainAsync` that the producer is finished. The only thing that stops it is cancellation at shutdown. 7. Nothing limits how large `_pending` can grow. A big enough burst is an out of memory exception. +The app walks you through the challenge one step at a time: + +1. Step 1 answers the device right away, Step 2 queues into a bounded channel, Step 3 drains it with several consumers, and Step 4 drains the backlog on shutdown. +2. A step unlocks only after the step before it passes. Select a step in the guide to read the story behind the problem, how to see it on the Ingest page, which file to change, and a task list for that step. If you get stuck, it has clues. +3. Each step drives your code the way the device fleet does and shows a checklist of every expected result next to what actually happened. Every result that does not match comes with a hint, and the same hint is printed in the terminal running the app. +4. Every time the app starts, it checks your code against every step, so the guide always reflects the code you have now. +5. Stop and run the app again after each change. If your IDE applied the change with Hot Reload, click **Run every step** at the top of the guide instead. + ## 3. Challenge: Drain the Pipeline Recommended time: 20 minutes. > **Note:** Please avoid letting AI Agents solve the challenges for you. You're smart. You got this. Use them to understand the existing code, clarify producer and consumer concepts, interpret errors, and ask questions that help you decide what to change. The goal is to practice the reasoning yourself. -Refactor **Services/TelemetryIngestService.cs** so that accepting an event only queues it, and the background service does the database writes. +Refactor **Services/TelemetryIngestService.cs** so that accepting an event only queues it, and the background service does the database writes. Then change **Services/TelemetryProcessor.cs** so shutting down writes the backlog instead of throwing it away. -Requirements: +Requirements, in the order the steps check them: -1. Add a `using` for the namespace that holds `Channel`. -2. Replace the `List` with a queue that is thread safe, has a capacity you choose, and can be awaited. Add a capacity constant large enough to hold the whole 400 event burst. -3. Decide what a full queue should do to the device that is writing, and pick the option that makes the producer wait instead of losing readings. -4. Be honest in the options about how many producers and consumers there really are. Many devices write, and you are about to run more than one consumer. -5. Change `AcceptAsync` so it only queues the event. Move the `EventStore.SaveAsync` call out of the accept path entirely. -6. Return the task type that allocates nothing when the call completes synchronously, because the queue write almost always does. +1. Change `AcceptAsync` so it only queues the event. Move the `EventStore.SaveAsync` call out of the accept path entirely, and stop counting the event as processed there. +2. Return the task type that allocates nothing when the call completes synchronously, because the queue write almost always does. +3. Add a `using` for the namespace that holds `Channel`. +4. Replace the `List` with a queue that is thread safe, has a capacity you choose, and can be awaited. Add a capacity constant large enough to hold the whole 400 event burst. Pass the accept call's `CancellationToken` to the queue write, and count a reading as accepted only once it is queued. +5. Decide what a full queue should do to the device that is writing, and pick the option that makes the producer wait instead of losing readings. +6. Be honest in the options about how many producers and consumers there really are. Many devices write, and you are about to run more than one consumer. 7. Make the counters safe for a producer and several consumers to touch at once, and make sure the Blazor page reads the latest value rather than one cached in a register. 8. Report `QueueDepth` from the queue itself instead of a list count. -9. Write a private consumer method that reads the queue until it is complete and saves each event to `EventStore`. -10. Change `DrainAsync` so it runs several consumers over the same reader and waits for all of them. Add a consumer count constant. 8 is a good starting point. -11. Add a public method that marks the queue complete. Do not call it from `RunBurstAsync`. Completing the queue closes it for good, and the next burst would throw `ChannelClosedException`. It belongs on the shutdown path, which is next. -12. Wire that method into **Services/TelemetryProcessor.cs** so shutting the app down drains the backlog instead of throwing it away. Override `StopAsync`, complete the queue before you call the base implementation, and stop passing `stoppingToken` into the read loop. Cancelling that loop is exactly what abandons the queued readings. -13. Delete the polling loop, the `Task.Delay` inside it, the `List`, the `Reset()` method, and the call to `Reset()` at the top of `RunBurstAsync`. +9. Write a private consumer method that reads the queue until it is complete, saves each event to `EventStore`, and counts it as processed once it reaches the store. +10. Change `DrainAsync` so it runs several consumers over the same reader, passes its `CancellationToken` to each of them, and waits for all of them. Add a consumer count constant. 8 is a good starting point. +11. Delete the polling loop, the `Task.Delay` inside it, the `Reset()` method, and the call to `Reset()` at the top of `RunBurstAsync`. +12. Implement `CompleteWriting()`, the public method whose stub throws `NotImplementedException`, so it marks the queue complete. Do not call it from `RunBurstAsync`. Completing the queue closes it for good, and the next burst would throw `ChannelClosedException`. It belongs on the shutdown path, which is next. +13. Wire that method into **Services/TelemetryProcessor.cs** so shutting the app down drains the backlog instead of throwing it away. Override `StopAsync`, complete the queue before you call the base implementation, and stop passing `stoppingToken` into the read loop. Cancelling that loop is exactly what abandons the queued readings. -Acceptance checks. Restart the app, then click **Receive 400 events** once: +Acceptance checks. Restart the app, then click **Receive 400 events** on the Ingest page once: 1. **TelemetryPipeline.slnx** builds. -2. **Accepted** reads 400 of 400. -3. **Average accept** reads 0.0ms, and the card is green instead of red. -4. **Burst duration** reads 0.00s. The button no longer spins. -5. **Written to store** starts near zero, with the rest of the burst waiting in the queue. -6. Click **Refresh counters** once a second. The written count climbs, the waiting count falls, and about two seconds after the burst it reads 400 written with 0 waiting. -7. Click **Receive 400 events** and then press Ctrl+C in the terminal straight away, while the queue still has items waiting. The app pauses for roughly two seconds before it exits, because it is finishing the backlog first. It shuts down instead of hanging, and it does not quit instantly either. -8. There is no `List` and no `Task.Delay` left in the service. -9. Your code is ready to compare with **2. Finish/TelemetryPipeline**. +2. The workshop guide shows 4 of 4 steps pass after the app starts. +3. **Accepted** reads 400 of 400. +4. **Average accept** reads 0.0ms, and the card is green instead of red. +5. **Burst duration** reads 0.00s. The button no longer spins. +6. **Written to store** starts near zero, with the rest of the burst waiting in the queue. +7. Click **Refresh counters** once a second. The written count climbs, the waiting count falls, and about two seconds after the burst it reads 400 written with 0 waiting. +8. Click **Receive 400 events** and then press Ctrl+C in the terminal straight away, while the queue still has items waiting. The app pauses for roughly two seconds before it exits, because it is finishing the backlog first. It shuts down instead of hanging, and it does not quit instantly either. +9. There is no `List` and no `Task.Delay` left in the service. +10. Your code is ready to compare with **2. Finish/TelemetryPipeline**. The counters keep counting for the life of the app once the consumers are running, so restart the app before you measure a fresh burst. -If you finish early, drop the capacity constant from 500 to 50 and run the burst again. The burst no longer fits in the buffer, the write starts waiting for room, and the average accept time climbs off zero. That is backpressure, and no code in the app measured anything or decided to slow down. Then change the full queue behavior from waiting to dropping the oldest queued item, run it once more, and watch the written count settle below 400 with no exception and no log line. +If you finish early, drop the capacity constant from 500 to 50, restart the app, and run the burst again. The burst no longer fits in the buffer, the write starts waiting for room, and the average accept time climbs off zero. That is backpressure, and no code in the app measured anything or decided to slow down. Then change the full queue behavior from waiting to dropping the oldest queued item, run it once more, and watch the written count settle below 400 with no exception and no log line. Step 2 fails while you experiment, because its checks expect a queue that holds the whole burst and waits when it is full, so put both back when you are done. The Your challenge tab in the workshop guide has this experiment too. ## 4. Review the Solution diff --git a/9. Channels/SOLUTION.md b/9. Channels/SOLUTION.md index 3c6118c..d4febcd 100644 --- a/9. Channels/SOLUTION.md +++ b/9. Channels/SOLUTION.md @@ -2,9 +2,13 @@ Use this during the guided walkthrough after the challenge and group review in [README.md](README.md). -The completed project is **2. Finish/TelemetryPipeline**. Every real change happens in `Services/TelemetryIngestService.cs`. `EventStore`, `Program.cs`, `Models/TelemetryEvent.cs`, and the Blazor page do not change at all, `TelemetryProcessor` picks up a comment, and the only other difference is the port in `Properties/launchSettings.json`, so the finished copy can run beside your own. That is the point of this pattern: the producer and the consumer never had to learn anything about each other. +The completed project is **2. Finish/TelemetryPipeline**. Almost every change happens in `Services/TelemetryIngestService.cs`. `TelemetryProcessor` gets the small shutdown fix in section 9, and the only other difference is the port in `Properties/launchSettings.json`, so the finished copy can run beside your own. `EventStore`, `Program.cs`, `Models/TelemetryEvent.cs`, and the Blazor page do not change at all. That is the point of this pattern: the producer and the consumer never had to learn anything about each other. -## 1. Reference the Channels Namespace +One member is already there in the starter. `CompleteWriting()` exists in **1. Start** as a stub that throws `NotImplementedException` with a hint, because Step 4 needs a named method to call. Everything else you write from scratch. + +Each section below names the workshop step it makes pass, so you can follow along in the workshop guide. The walkthrough builds the channel before it touches `AcceptAsync`, because the new accept path writes into that channel, so the Step 1 fix appears in section 4. **1. Start** and **2. Finish** share the same workshop plumbing: the `Steps` and `Verification` folders, and the layout with its workshop guide. + +## 1. Reference the Channels Namespace (Step 2) `Channel`, `Channel`, `ChannelReader`, `ChannelWriter`, and `BoundedChannelOptions` all live in one namespace: @@ -15,7 +19,7 @@ using System.Threading.Channels; `System.Threading.Channels` ships in the shared framework, so there is no package to install. -## 2. Create the Bounded Channel +## 2. Create the Bounded Channel (Step 2) The `List` goes away and a bounded channel takes its place: @@ -45,7 +49,9 @@ Three decisions are packed into those options. `SingleReader` and `SingleWriter` are promises you make to the runtime so it can pick a cheaper implementation. They are not enforced. Both are `false` here because every browser session can post events and eight consumers read the same channel. A broken promise here does not throw, it corrupts, so leave these `false` unless the code structurally guarantees otherwise. -## 3. Make the Counters Thread Safe +Step 2 checks all three decisions against a fresh service. It posts readings one at a time until a write stops completing synchronously. With a capacity of 500 and `Wait`, all 500 go straight in, so the 400 reading burst fits, write 501 waits for room, `QueueDepth` reads 500, and cancelling that waiting write stops it without counting it as accepted. An unbounded queue, or a drop mode, takes 10,000 readings without anyone ever waiting, and the step says which of the two it found. + +## 3. Make the Counters Thread Safe (Step 2) The counters are now touched by the producer and by eight consumers at the same time: @@ -62,9 +68,11 @@ The counters are now touched by the producer and by eight consumers at the same `_accepted++` is a read, an add, and a write, which is why the increments in the next two steps use `Interlocked`. `Volatile.Read` on the way out is the matching half: it stops the compiler and the CPU from handing the page a value cached in a register, and it guarantees the page never reads a value older than the last increment it can observe. It does not freeze the value. The consumers are still running, so what the page renders is a snapshot that is already slightly stale, and for a counter that is fine. +Step 2 lines 16 devices up behind a `Barrier` and has each post 25 readings at the same instant. With Step 1 fixed but the `List` and `++` still in place, one run of that check accepted 373 of 400 and queued only 361. A lost increment is still rare with only 400 readings, so the step also reads the compiled code to confirm that `AcceptAsync` calls `Interlocked` and that both getters call `Volatile.Read`. + `QueueDepth` no longer counts a list. `Reader.Count` is the channel's current buffered depth. It is a snapshot too, and it is the single most useful number to put on a real dashboard, because a queue depth that keeps climbing tells you which half of your pipeline is losing. -## 4. Accept Without Touching the Database +## 4. Accept Without Touching the Database (Step 1) This is the fix the whole section is about: @@ -84,9 +92,11 @@ The call to `eventStore.SaveAsync(...)` is gone from the accept path. Accepting That `await` is also the only place backpressure can appear. With a capacity of 500 and a burst of 400 it never suspends, which is exactly why the measured average accept time is 0.0ms. Shrink the capacity below the burst size and this same line starts waiting for room, with no other change to the code. -`Interlocked.Increment` replaces `_accepted++` because more than one caller can be in this method at once. +`Interlocked.Increment` replaces `_accepted++` because more than one caller can be in this method at once. The starter's `_processed++` is gone from this method too. Only a consumer writes to the store, so only a consumer counts a reading as processed. -## 5. Write the Consumer +Step 1 sends a burst of 20 readings to a fresh service with no consumers running. The starter averages 41.0ms per accept and already reports 20 written. The fix averages well under the 5ms the step allows, reports 0 written and 20 waiting in the queue, and returns `ValueTask`. + +## 5. Write the Consumer (Step 3) The polling loop is replaced by an `await foreach`: @@ -108,7 +118,7 @@ The polling loop is replaced by an `await foreach`: Notice what this method does not contain. There is no lock, no `Interlocked` around the queue itself, no check to see whether the producer is still running, and no `List` that two threads mutate at once. The channel is thread safe by construction. -## 6. Run Several Consumers Over One Reader +## 6. Run Several Consumers Over One Reader (Step 3) One consumer at 40 milliseconds per write can only clear 25 events per second. Start eight of them: @@ -130,11 +140,25 @@ Every consumer reads the same `ChannelReader`, and each event is `_consumerCount` is your degree of parallelism, and it is the honest place to set that limit, because it is also how much concurrent load you are putting on the database. Eight consumers doing 50 writes each at 40 milliseconds is 2 seconds of work, and the measured drain is 2.11 seconds. +Step 3 measures the same thing on a smaller burst. It starts `DrainAsync` on an empty queue, confirms it keeps waiting, sends 80 readings, and allows 1.5 seconds for all of them to reach the store. Eight consumers finished in 0.41 seconds. One consumer took 3.30 seconds and failed. The step then cancels the token it passed to `DrainAsync` and expects every consumer to stop, which is why `token` goes all the way down to `ReadAllAsync` and `SaveAsync`. + Say one thing out loud about `Task.WhenAll` here, because it is the trap in this shape. It does not fail fast. `EventStore.SaveAsync` in this sample cannot throw, but in your own pipeline it can. If one consumer throws, the other seven keep reading, `DrainAsync` does not complete, and nobody observes that fault until every consumer has finished, which for a pipeline that runs for the life of the process means shutdown. You would silently be down one eighth of your throughput. That is why a production consumer puts a `try` and `catch` inside the `await foreach`, around the work for a single event, so one poison event costs you one event instead of one consumer. Be clear about ordering too. Events leave the channel in the order they were written, but once eight consumers are running, completion order is whatever the store decides. If per device ordering matters in your own systems, give each device its own channel or key the work so one device always lands on the same consumer. -## 7. Complete the Writer +## 7. Complete the Writer (Step 4) + +The starter declares this method already, as a stub: + +```cs + // ToDo Refactor (Step 4): nothing tells the consumers that no more events are coming + public void CompleteWriting() + { + throw new NotImplementedException("TelemetryIngestService.CompleteWriting() is not implemented yet. Hint: mark the queue complete, so the consumers finish writing the backlog and then stop reading. Do not call it from RunBurstAsync: a completed queue is closed for good, and the next burst would throw. It belongs on the shutdown path, in TelemetryProcessor."); + } +``` + +Until it is implemented, Step 4 stops right away and shows that `TelemetryIngestService.CompleteWriting()` still throws `NotImplementedException`. The finished version is one line: ```cs // Marks the channel complete so DrainAsync finishes after the backlog is written @@ -147,9 +171,9 @@ Completing does two things. It rejects every later write, with `WriteAsync` thro `TryComplete()` is used instead of `Complete()` because more than one code path could reasonably close this channel. It returns `false` when the channel is already complete rather than throwing. -Nothing calls this during a burst, and that is deliberate. Call `CompleteWriting` from `RunBurstAsync` and the first burst still passes every acceptance check, but the second click of **Receive 400 events** throws, because the channel is closed for good. This is the method you call exactly once, when the app is going away, and that is the next step. +Nothing calls this during a burst, and that is deliberate. Call `CompleteWriting` from `RunBurstAsync` and the first burst still passes every acceptance check, but the second click of **Receive 400 events** throws, because the channel is closed for good. Step 3 sends a second burst to the same service for exactly that reason, and reports the `ChannelClosedException`. This is the method you call exactly once, when the app is going away, and that is the next step. -## 8. Report From the Channel +## 8. Report From the Channel (Step 3) `RunBurstAsync` keeps its shape, and the first thing to point at is what is missing: @@ -161,7 +185,7 @@ Nothing calls this during a burst, and that is deliberate. Call `CompleteWriting var slowestAccept = 0d; ``` -The call to `Reset()` is gone, and so is the method. Clearing the counters mid flight would now be wrong, because the consumers are still draining the previous burst in the background. The pipeline outlives any single button click, so the counters are cumulative. Restart the app when you want a clean measurement. +The call to `Reset()` is gone, and so is the method. Clearing the counters mid flight would now be wrong, because the consumers are still draining the previous burst in the background. The pipeline outlives any single button click, so the counters are cumulative. Restart the app when you want a clean measurement. Step 3's second burst of 10 checks this too: after it, the service has to read 90 accepted and 90 written, not 10. The stats now come from the channel and the atomic counters instead of a list: @@ -178,9 +202,33 @@ The stats now come from the channel and the atomic counters instead of a list: `Processed` and `QueueDepth` go into the record at the moment the burst returns, which is almost immediately. The **Written to store** card does not read that snapshot though. `Ingest.razor` binds it to `LiveProcessed` and `LiveQueueDepth`, and `Ingest.razor.cs` defines those as `Ingest.Processed` and `Ingest.QueueDepth`, so the card reads the singleton every time the page renders. That is why the card shows a full queue and an empty store the instant the burst returns, and why **Refresh counters** is what lets you watch it drain. -## 9. Drain the Backlog on Shutdown +## 9. Drain the Backlog on Shutdown (Step 4) + +The starter's `TelemetryProcessor` is the obvious version: -`TelemetryProcessor` is where completion finally gets used: +```cs +// The consumer half of the pipeline. +// ToDo Refactor (Step 4): when the app shuts down, nothing completes the queue first, +// so the readings still waiting in it never reach the store. +public sealed class TelemetryProcessor(TelemetryIngestService ingest) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + // ToDo Refactor (Step 4): the host cancels stoppingToken at shutdown, and cancelling + // the read loop abandons every reading that is still waiting in the queue + await ingest.DrainAsync(stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Expected on shutdown + } + } +} +``` + +The finished `TelemetryProcessor` is where completion finally gets used: ```cs namespace TelemetryPipeline; @@ -216,6 +264,8 @@ The obvious version of `ExecuteAsync` takes the `stoppingToken` and passes it in So the read loop does not take the token at all. Shutdown is signalled by completing the channel instead. `ReadAllAsync` drains what is buffered and then ends on its own, `Task.WhenAll` returns, `ExecuteAsync` returns, and `base.StopAsync` sees the task finish. With completion wired in, the same run writes 400 of 400 and shutdown takes about two seconds instead of being instant. +Step 4 runs that exact shutdown on fresh instances. It starts a `TelemetryProcessor`, sends a burst of 40, and calls `StopAsync` straight away with 32 readings still waiting. The starter's processor returns with 0 of 40 written. A processor that stops passing `stoppingToken` but never completes the channel waits out the step's 5 second shutdown timeout instead. The finished processor returns after 0.20 seconds with 40 of 40 written, and a reading posted afterwards is refused with `ChannelClosedException`. + Two things keep this honest. `base.StopAsync` bounds its wait with the host's shutdown timeout, so a backlog that cannot drain in time does not hang the process forever. And completing the channel means a request still trying to accept a reading during shutdown gets a `ChannelClosedException`, which is the correct answer: the app is going away and cannot promise to store it. The registration in `Program.cs` did not change either: @@ -236,7 +286,7 @@ From the **9. Channels** folder, run the finished project: dotnet run --project "2. Finish/TelemetryPipeline/TelemetryPipeline.csproj" ``` -Open [http://localhost:5014](http://localhost:5014) and click **Receive 400 events**. To watch your own refactor instead, run the project in **1. Start** and open [http://localhost:5013](http://localhost:5013). The two ports differ so both copies can run side by side. +Open [http://localhost:5014](http://localhost:5014). The workshop guide shows 4 of 4 steps pass once the checks that run after the app starts have finished. On the **Ingest** page beside it, click **Receive 400 events**. To watch your own refactor instead, run the project in **1. Start** and open [http://localhost:5013](http://localhost:5013). The two ports differ so both copies can run side by side. Three numbers are worth pointing at on screen. @@ -248,10 +298,12 @@ Third, **Written to store** starts near zero with the rest of the burst waiting ## 11. Feel the Backpressure -Change `_queueCapacity` from `500` to `50` and run the burst again. The burst no longer fits in the buffer, so `WriteAsync` starts waiting for room, and the average accept time climbs off zero. The producer has been throttled to the speed of the consumers, and no code in the app measured anything or decided to slow down. That single `await` did it. +Change `_queueCapacity` from `500` to `50`, restart the app, and run the burst again. The burst no longer fits in the buffer, so `WriteAsync` starts waiting for room, and the average accept time climbs off zero. The producer has been throttled to the speed of the consumers, and no code in the app measured anything or decided to slow down. That single `await` did it. Now change `FullMode` to `BoundedChannelFullMode.DropOldest` and run it once more. Every write completes immediately again, the accept time drops back toward zero, and the written count settles below 400. Those events are gone, with no exception and no log line to tell you. +The workshop guide stops at Step 2 during both experiments, and its hints name the problem each time: first that the queue cannot hold the burst, then that readings are being dropped. Put both settings back afterwards. + That is the right behavior for a live gauge where only the latest reading matters, and the wrong behavior for readings a customer is billed for. Choosing between `Wait`, `DropNewest`, `DropOldest`, and `DropWrite` is a business decision, not a performance decision, and it is worth a comment next to the option so the person who edits this file in six months knows why. ## 12. Compare Against Finish @@ -262,4 +314,4 @@ Compare your implementation with the completed files: 2. [2. Finish/TelemetryPipeline/Services/TelemetryProcessor.cs](2.%20Finish/TelemetryPipeline/Services/TelemetryProcessor.cs) 3. [2. Finish/TelemetryPipeline/Services/EventStore.cs](2.%20Finish/TelemetryPipeline/Services/EventStore.cs) -`EventStore.cs`, `Program.cs`, `Models/TelemetryEvent.cs`, and the Blazor page are identical between **1. Start** and **2. Finish**. The slow database write is still slow, the page still asks for 400 events, and the hosted service still calls `DrainAsync`. All that changed is where the work happens, and a channel is what made that change one file instead of a rewrite. +`EventStore.cs`, `Program.cs`, `Models/TelemetryEvent.cs`, and the Blazor page are identical between **1. Start** and **2. Finish**. The slow database write is still slow, the page still asks for 400 events, and the hosted service still calls `DrainAsync`. All that changed is where the work happens, and a channel is what made that change one service and a few lines of its hosted service instead of a rewrite. diff --git a/README.md b/README.md index 565ef8e..5b04646 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ Each hands-on section follows the same rhythm: Coding challenges run 20 to 45 minutes depending on how much code they ask you to write. The shorter investigation challenges in the .NET Internals section focus on runtime observations and discussion rather than large code changes. +Every challenge app walks you through its challenge one step at a time. Run the app and its **workshop guide** tells you which step to work on next, and unlocks each step when the one before it passes. Each step explains what to do and where to look, and shows what actually happened next to what was expected. Most steps also give you a hint for anything that does not match, with clues if you get stuck. + > **Note:** Please avoid letting AI Agents solve the challenges for you. You're smart. You got this. Use AI Agents to understand the existing code, clarify concepts, interpret errors, and ask questions that help you decide what to do next. The goal is to practice the reasoning yourself. ## Schedule @@ -53,7 +55,7 @@ Day 2 covers parallel programming: using many threads on purpose, and keeping sh | 13:00 - 13:30 | 30 min | 3. .NET Internals (Lecture) | | 13:30 - 13:50 | 20 min | 3. .NET Internals (Coding "Challenge") | | 13:50 - 14:10 | 20 min | 3. .NET Internals (Group Review) | -| 14:10 - 14:30 | 20 min | 4. Creating A Custom `Task` (Lecture) | +| 14:10 - 14:30 | 20 min | 4. Creating a Custom `Task` (Lecture) | | 14:30 - 15:15 | 45 min | 4. Creating a Custom `Task` (Coding Challenge) | | 15:15 - 15:30 | 15 min | Afternoon break | | 15:30 - 16:00 | 15 min | 4. Creating a Custom `Task` (Challenge, continued) |