-
Notifications
You must be signed in to change notification settings - Fork 5.2k
Orleans JournaledGrain sample #7029
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
@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. |
orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListRegistryGrain.cs
Show resolved
Hide resolved
| builder.AddKeyedAzureTableClient("clustering"); | ||
| builder.AddKeyedAzureBlobClient("grain-state"); | ||
| builder.UseOrleans(siloBuilder => | ||
| { | ||
| siloBuilder.AddLogStorageBasedLogConsistencyProviderAsDefault(); | ||
| siloBuilder.AddStateStorageBasedLogConsistencyProvider(name: Constants.StateStorageProviderName); | ||
| }); |
There was a problem hiding this comment.
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?
…grain to be state-based journaled
90ba44e to
d044c5c
Compare
There was a problem hiding this 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.
| protected override async Task OnParametersSetAsync() | ||
| { | ||
| if (todoList?.Name != ListId) | ||
| { | ||
| currentViewTimestamp = null; | ||
| todoList = null; | ||
| await LoadTodoList(); | ||
| } |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
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.
| var list = new TodoList( | ||
| Name: this.GetPrimaryKeyString(), | ||
| Items: State.Items.Values.OrderBy(x => x.Id).ToImmutableArray(), | ||
| Timestamp: State.Timestamp); |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
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.
| ? new TodoList( | ||
| Name: this.GetPrimaryKeyString(), | ||
| Items: historicalProjection.Items.Values.OrderBy(x => x.Id).ToImmutableArray(), | ||
| Timestamp: historicalProjection.Timestamp) |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
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.
| public static class Constants | ||
| { | ||
| public const string StateStorageProviderName = "StateStorage"; | ||
|
|
||
| public const string TodoListRegistryId = "registry"; |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
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.
| 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"; | |
| } |
| 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; | ||
| } |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
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.
| public Task Subscribe(ITodoListRegistryObserver observer) | ||
| { | ||
| observers.Subscribe(observer, observer); | ||
| observer.OnTodoListsChanged(State.TodoLists); | ||
| return Task.CompletedTask; |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
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.
| h1:focus { | ||
| outline: none; | ||
| } |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
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.
| public async Task AddTodoItemAsync(string title) | ||
| { | ||
| var evt = new TodoItemAdded( | ||
| Version, | ||
| DateTimeOffset.UtcNow, | ||
| title); |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
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.
|
|
||
| private async Task ViewAtTimestamp(DateTimeOffset timestamp) | ||
| { | ||
| if (timestamp == history[^1].Timestamp) |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
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.
| <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>. |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
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.
| <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>. |
This sample aims to showcase different ways to use JournaledGrains.
Features:
Todo: