Skip to content

Conversation

@egil
Copy link
Contributor

@egil egil commented Mar 14, 2025

This sample aims to showcase different ways to use JournaledGrains.

Features:

  • Uses Aspire with a mix of persistent storage and session storage. Grain state is persistent across runs, clustering is not.
  • Uses Blazor Interactive Server for frontend bits
  • Uses JournaledGrain with Log storage
  • Uses grain observers to auto update Blazor UI
  • Allow users to see previous versions of todo lists
  • Use state-based storage for todo list registry grain to showcase that

Todo:

  • JournaledGrain with Custom storage

@egil
Copy link
Contributor Author

egil commented Mar 14, 2025

@ReubenBond here is a draft of the sample. Still things I want to add, but would like to get input on how things are looking before I wonder off in a wrong direction.

Comment on lines 7 to 13
builder.AddKeyedAzureTableClient("clustering");
builder.AddKeyedAzureBlobClient("grain-state");
builder.UseOrleans(siloBuilder =>
{
siloBuilder.AddLogStorageBasedLogConsistencyProviderAsDefault();
siloBuilder.AddStateStorageBasedLogConsistencyProvider(name: Constants.StateStorageProviderName);
});
Copy link
Contributor Author

Choose a reason for hiding this comment

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

With this set up, the log consistency providers are using the grain state as storage, in this case, blob storage. When the log-based providers save state, does it just save the added/raised event(s), or does it save all in one go? That is, if there are 1000 events, will they all be serialized and save to blob storage or just the new events?

@egil egil marked this pull request as ready for review March 17, 2025 18:00
@egil egil requested a review from a team as a code owner March 17, 2025 18:00
@ReubenBond ReubenBond self-assigned this Mar 17, 2025
Copilot AI review requested due to automatic review settings January 21, 2026 21:16
@ReubenBond ReubenBond force-pushed the orleans/journaled-grain-sample branch from 90ba44e to d044c5c Compare January 21, 2026 21:16
@ReubenBond ReubenBond enabled auto-merge (squash) January 21, 2026 21:16
@ReubenBond ReubenBond merged commit 91b9946 into dotnet:main Jan 21, 2026
3 checks passed
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces a new Orleans sample demonstrating JournaledGrain functionality with event sourcing. The sample implements a todo list application using Blazor Interactive Server, Orleans JournaledGrains for state management, and Aspire for orchestration. It showcases both log-based and state-based consistency providers, grain observers for real-time UI updates, and the ability to view historical versions of todo lists.

Changes:

  • Added complete Orleans JournaledGrain sample with Aspire orchestration
  • Implemented event-sourced todo list grains with historical versioning capability
  • Created Blazor Interactive Server UI with real-time updates via grain observers

Reviewed changes

Copilot reviewed 42 out of 42 changed files in this pull request and generated 19 comments.

Show a summary per file
File Description
JournaledTodoList.slnx Solution file defining the three-project structure
JournaledTodoList.AppHost/* Aspire AppHost configuration with Azure Storage emulators for clustering and persistence
JournaledTodoList.ServiceDefaults/* Shared service defaults for OpenTelemetry, health checks, and resilience
JournaledTodoList.WebApp/Program.cs Application entry point configuring Orleans, Blazor, and services
JournaledTodoList.WebApp/Grains/* Grain implementations and interfaces for todo lists with event sourcing
JournaledTodoList.WebApp/Grains/Events/* Event definitions for todo list operations
JournaledTodoList.WebApp/Services/TodoListService.cs Service layer for grain interaction and observer subscriptions
JournaledTodoList.WebApp/Components/* Blazor components for UI including pages and layouts
JournaledTodoList.WebApp/wwwroot/app.css Application-specific CSS styles
Configuration files Standard appsettings and launch settings for ASP.NET Core

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +23 to +30
protected override async Task OnParametersSetAsync()
{
if (todoList?.Name != ListId)
{
currentViewTimestamp = null;
todoList = null;
await LoadTodoList();
}
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

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

This comparison checks if todoList?.Name differs from ListId to determine if the list has changed. However, todoList.Name should contain the display name (once bug with GetTodoListAsync is fixed), while ListId is the normalized ID used as the grain key. These will typically be different strings even for the same list. Consider storing and comparing an ID field in TodoList, or comparing against a stored previous ListId value.

Copilot uses AI. Check for mistakes.
Comment on lines +38 to +41
var list = new TodoList(
Name: this.GetPrimaryKeyString(),
Items: State.Items.Values.OrderBy(x => x.Id).ToImmutableArray(),
Timestamp: State.Timestamp);
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

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

The GetTodoListAsync method sets Name to this.GetPrimaryKeyString() (the grain ID) rather than State.Name (the display name from the projection). This means the display name set via SetNameAsync is ignored, and the TodoList always shows the normalized ID instead of the user-friendly name. The Name parameter should be set to State.Name to reflect the actual list name.

Copilot uses AI. Check for mistakes.
Comment on lines +29 to +32
? new TodoList(
Name: this.GetPrimaryKeyString(),
Items: historicalProjection.Items.Values.OrderBy(x => x.Id).ToImmutableArray(),
Timestamp: historicalProjection.Timestamp)
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

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

Similar to GetTodoListAsync, this method sets Name to this.GetPrimaryKeyString() instead of historicalProjection.Name. This means historical views won't show the correct display name that was set at that point in time. The Name parameter should be set to historicalProjection.Name to show the name as it was at that timestamp.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +5
public static class Constants
{
public const string StateStorageProviderName = "StateStorage";

public const string TodoListRegistryId = "registry";
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

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

The Constants class is defined in the global namespace instead of being in the JournaledTodoList.WebApp namespace like other files in the project. This is inconsistent with the project's namespace structure and could lead to naming conflicts. The class should be moved into the appropriate namespace.

Suggested change
public static class Constants
{
public const string StateStorageProviderName = "StateStorage";
public const string TodoListRegistryId = "registry";
namespace JournaledTodoList.WebApp
{
public static class Constants
{
public const string StateStorageProviderName = "StateStorage";
public const string TodoListRegistryId = "registry";
}

Copilot uses AI. Check for mistakes.
Comment on lines +36 to +44
public async Task<TodoList> GetTodoListAsync()
{
var list = new TodoList(
Name: this.GetPrimaryKeyString(),
Items: State.Items.Values.OrderBy(x => x.Id).ToImmutableArray(),
Timestamp: State.Timestamp);

return list;
}
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

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

The GetTodoListAsync method is declared as async but doesn't use await and simply returns a Task. Consider removing the async modifier and using Task.FromResult instead to avoid unnecessary async state machine overhead.

Copilot uses AI. Check for mistakes.
Comment on lines +55 to +59
public Task Subscribe(ITodoListRegistryObserver observer)
{
observers.Subscribe(observer, observer);
observer.OnTodoListsChanged(State.TodoLists);
return Task.CompletedTask;
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

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

The Subscribe method calls observer.OnTodoListsChanged synchronously without awaiting it. Since OnTodoListsChanged returns a Task, this call should be awaited to ensure the initial notification completes before the Subscribe method returns. This could lead to race conditions where the component isn't properly initialized before other operations occur.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +3
h1:focus {
outline: none;
}
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

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

Removing the outline on focused h1 elements (outline: none) creates an accessibility issue. Users who navigate with keyboards rely on focus indicators to know where they are on the page. If removing the default outline is necessary for design reasons, replace it with an alternative focus indicator that meets WCAG contrast requirements.

Copilot uses AI. Check for mistakes.
Comment on lines +46 to +51
public async Task AddTodoItemAsync(string title)
{
var evt = new TodoItemAdded(
Version,
DateTimeOffset.UtcNow,
title);
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

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

The TodoItemAdded event is being constructed with Version as the ItemId parameter, but Version represents the grain's event version number, not the item ID. This will cause incorrect item IDs to be assigned (0, 1, 2, etc. based on event version). The item ID should be generated differently, such as by tracking a counter in the TodoListProjection state or using a different strategy to ensure unique item IDs.

Copilot uses AI. Check for mistakes.

private async Task ViewAtTimestamp(DateTimeOffset timestamp)
{
if (timestamp == history[^1].Timestamp)
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

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

The method accesses history[^1] without checking if the history array is empty. If ViewAtTimestamp is somehow called when history is empty or default, this will throw an IndexOutOfRangeException. Consider adding a guard check for history.IsDefaultOrEmpty before accessing the array.

Copilot uses AI. Check for mistakes.
<div class="alert alert-warning" role="alert">
<strong>Viewing historical version</strong>
<span>as of @currentViewTimestamp.Value.ToLocalTime().ToString("g").</span>
<a @onclick:preventDefault @onclick:stopPropagation @onclick="ReturnToCurrentVersion" href="#" class="link-offset-2">To current version</a>.
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

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

The link text "To current version" is grammatically incomplete. It should be "Return to current version" or "Back to current version" to form a complete sentence or clear action.

Suggested change
<a @onclick:preventDefault @onclick:stopPropagation @onclick="ReturnToCurrentVersion" href="#" class="link-offset-2">To current version</a>.
<a @onclick:preventDefault @onclick:stopPropagation @onclick="ReturnToCurrentVersion" href="#" class="link-offset-2">Return to current version</a>.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants