diff --git a/docs/superpowers/plans/2026-07-10-grain-user-service-provider-factory.md b/docs/superpowers/plans/2026-07-10-grain-user-service-provider-factory.md new file mode 100644 index 0000000..b6ae8cc --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-grain-user-service-provider-factory.md @@ -0,0 +1,1890 @@ +# Grain User-Service-Provider Factory Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the `GrainScopeInitializer`/`IGrainScopeInitializerRegistry`/`AddGrainScopeInitializer` family with a single opt-in, compile-time-discovered `IGrainUserServiceProviderFactory` that lets a behavior class supply a cached, per-grain-type provider for its own (non-Quark) constructor-injected services — avoiding re-resolution of an expensive user dependency graph on every grain call, while Quark's own services remain exclusively engine-managed. + +**Architecture:** A behavior opts in by implementing `static abstract IServiceProvider CreateUserServiceProvider(IServiceProvider rootServices)`. The source generator detects this and emits a deferred registration; at silo startup the factory runs once per grain type and the result is cached in `IUserServiceProviderRegistry`. A small "Quark-only" satellite `IServiceProvider` is built once (from the generator's own registrations, captured via a new `AddQuarkOwnedScoped` marker mechanism) so `GrainActivation.RunActivationAsync` can, for opted-in grain types, create a cheap Quark-only scope each call and compose it with the cached user provider via `CompositeServiceProvider` (Quark-first, structurally guaranteeing Quark services are never satisfied by the user's provider) instead of creating a full fresh scope from the flat root every call. + +**Tech Stack:** .NET 10, `Microsoft.Extensions.DependencyInjection`, Roslyn incremental source generators (`Quark.CodeGenerator`), xUnit. + +## Global Constraints + +- Every production package has `IsTrimmable=true` / `EnableAotAnalyzer=true` — no reflection, no assembly scanning; all new registration/detection logic must be either plain code or generator-emitted. +- Never add `Version=` attributes to `` — package versions are centrally managed in `Directory.Packages.props`. +- Follow existing house style: `internal` for engine-only types, `public` only for the extension methods/interfaces developers or generated code must call. +- v1 scope excludes `IPersistentActivationMemory` / `[PersistentState]` / `ITransactionalState` / streams / reminders for opted-in behaviors — those need cross-package services (`IStorage` etc.) not covered here (confirmed, see spec §2). +- Spec: `docs/superpowers/specs/2026-07-10-grain-user-service-provider-factory-design.md` — read it for full rationale before starting. + +--- + +## File Structure + +New files: +- `src/Quark.Core.Abstractions/Hosting/IGrainUserServiceProviderFactory.cs` — the opt-in interface. +- `src/Quark.Runtime/CompositeServiceProvider.cs` — two-provider fallback `IServiceProvider`. +- `src/Quark.Runtime/IUserServiceProviderRegistry.cs` — registry interface (replaces `IGrainScopeInitializerRegistry`). +- `src/Quark.Runtime/UserServiceProviderRegistry.cs` — implementation. +- `src/Quark.Runtime/QuarkOnlyServiceProviderHolder.cs` — mutable holder for the lazily-built satellite provider. +- `tests/Quark.Tests.Unit/Runtime/CompositeServiceProviderTests.cs` +- `tests/Quark.Tests.Unit/Runtime/UserServiceProviderFactoryTests.cs` — replaces `GrainScopeInitializerTests.cs`. + +Deleted files: +- `src/Quark.Core.Abstractions/Hosting/GrainScopeInitializer.cs` +- `src/Quark.Runtime/IGrainScopeInitializerRegistry.cs` +- `src/Quark.Runtime/GrainScopeInitializerRegistry.cs` +- `tests/Quark.Tests.Unit/Runtime/GrainScopeInitializerTests.cs` + +Modified files: +- `src/Quark.Runtime/BehaviorResolver.cs`, `src/Quark.Runtime/IBehaviorResolver.cs` — `Resolve` takes an explicit construction provider. +- `src/Quark.Runtime/GrainScopeBinder.cs` — split binding/construction providers, drop unused async. +- `src/Quark.Runtime/RuntimeServiceCollectionExtensions.cs` — new `AddQuarkOwnedScoped`, new markers, new `AddGrainUserServiceProviderFactory`; remove `AddGrainScopeInitializer` family; wire new registry/holder into `AddQuarkRuntime()`; update `AddEagerActivationMemory`. +- `src/Quark.Runtime/SiloHostedService.cs` — replace `ApplyScopeInitializerRegistrations` with `ApplyUserServiceProviderFactoryRegistrations`; dispose the satellite provider on stop. +- `src/Quark.Runtime/BehaviorStartupValidator.cs` — skip opted-in behaviors (their real construction path isn't ready yet at this point in hosted-service ordering). +- `src/Quark.Runtime/GrainActivation.cs` — `RunActivationAsync` branches on the registry + holder. +- `src/Quark.CodeGenerator/BehaviorRegistrationGenerator.cs` — detect the new interface, emit registration; switch `IActivationMemory`/`IManagedActivationMemory` inline emissions to `AddQuarkOwnedScoped`. +- `tests/Quark.Tests.Unit/Runtime/BehaviorResolverTests.cs` — update call sites for the new signature. +- `tests/Quark.Tests.Unit/Runtime/AddGrainBehaviorFactoryOverloadTests.cs` — replace the scope-initializer test with a user-service-provider-factory equivalent. +- `tests/Quark.Tests.CodeGenerator/BehaviorRegistrationGeneratorTests.cs` — new tests for the generator changes. +- `FEATURES.md`, `wiki/Source-Generators.md` — documentation. + +--- + +## Task 1: `IGrainUserServiceProviderFactory` interface + +**Files:** +- Create: `src/Quark.Core.Abstractions/Hosting/IGrainUserServiceProviderFactory.cs` +- Delete: `src/Quark.Core.Abstractions/Hosting/GrainScopeInitializer.cs` +- Test: `tests/Quark.Tests.Unit/Runtime/UserServiceProviderFactoryTests.cs` (created empty in this task, populated in Task 8) + +**Interfaces:** +- Produces: `Quark.Core.Abstractions.Hosting.IGrainUserServiceProviderFactory` with `static abstract IServiceProvider CreateUserServiceProvider(IServiceProvider rootServices)` — every later task that references the opt-in mechanism uses this exact interface and method name. + +- [ ] **Step 1: Delete the old delegate type** + +Delete `src/Quark.Core.Abstractions/Hosting/GrainScopeInitializer.cs` entirely (its only consumers are removed in Tasks 4–6). + +- [ ] **Step 2: Create the new interface** + +Create `src/Quark.Core.Abstractions/Hosting/IGrainUserServiceProviderFactory.cs`: + +```csharp +namespace Quark.Core.Abstractions.Hosting; + +/// +/// Opt-in, compile-time-discovered factory that supplies the IServiceProvider used to resolve a +/// behavior's own (non-Quark) constructor-injected services. Implemented directly on the behavior +/// class. Called once per grain type at silo startup; the returned provider is cached and shared by +/// every activation of that type for the process lifetime. +/// +public interface IGrainUserServiceProviderFactory +{ + /// + /// The ordinary root IServiceProvider built from the silo's registered services (silo.Services). + /// Use this to pull already-registered user singletons, or return it unchanged if the developer's + /// services are already cheap/stateless to resolve from it directly. + /// + static abstract IServiceProvider CreateUserServiceProvider(IServiceProvider rootServices); +} +``` + +- [ ] **Step 3: Create an empty placeholder test file** + +Create `tests/Quark.Tests.Unit/Runtime/UserServiceProviderFactoryTests.cs`: + +```csharp +using Xunit; + +namespace Quark.Tests.Unit.Runtime; + +public sealed class UserServiceProviderFactoryTests +{ + // Populated in Task 8, once GrainActivation/SiloHostedService/RuntimeServiceCollectionExtensions + // wiring exists to actually exercise the opt-in flow end-to-end. + [Fact] + public void Placeholder() { } +} +``` + +- [ ] **Step 4: Build to confirm the old delegate's removal doesn't break anything yet** + +Run: `dotnet build Quark.slnx` +Expected: FAILS — `GrainScopeInitializerRegistry.cs`, `IGrainScopeInitializerRegistry.cs`, `RuntimeServiceCollectionExtensions.cs`, `GrainScopeBinder.cs`, `GrainScopeInitializerTests.cs`, `AddGrainBehaviorFactoryOverloadTests.cs` still reference the deleted `GrainScopeInitializer` type. This is expected — those are cleaned up in Tasks 3–6 and 8. Confirm the ONLY errors are "type or namespace 'GrainScopeInitializer' could not be found" (or equivalent) in those specific files — no unrelated breakage. + +- [ ] **Step 5: Commit** + +```bash +git add src/Quark.Core.Abstractions/Hosting/IGrainUserServiceProviderFactory.cs \ + src/Quark.Core.Abstractions/Hosting/GrainScopeInitializer.cs \ + tests/Quark.Tests.Unit/Runtime/UserServiceProviderFactoryTests.cs +git commit -m "Add IGrainUserServiceProviderFactory, remove GrainScopeInitializer delegate" +``` + +--- + +## Task 2: `CompositeServiceProvider` + +**Files:** +- Create: `src/Quark.Runtime/CompositeServiceProvider.cs` +- Test: `tests/Quark.Tests.Unit/Runtime/CompositeServiceProviderTests.cs` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `Quark.Runtime.CompositeServiceProvider` — `internal sealed class CompositeServiceProvider(IServiceProvider primary, IServiceProvider secondary) : IServiceProvider` with `GetService(Type)` trying `primary` first, then `secondary`. Task 7 constructs this directly. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/Quark.Tests.Unit/Runtime/CompositeServiceProviderTests.cs`: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using Quark.Runtime; +using Xunit; + +namespace Quark.Tests.Unit.Runtime; + +public sealed class CompositeServiceProviderTests +{ + private interface IMarkerA; + private interface IMarkerB; + + private sealed class MarkerA : IMarkerA; + private sealed class MarkerB : IMarkerB; + + [Fact] + public void GetService_ReturnsFromPrimary_WhenPrimaryHasIt() + { + var primaryServices = new ServiceCollection(); + primaryServices.AddSingleton(); + using ServiceProvider primary = primaryServices.BuildServiceProvider(); + + var secondaryServices = new ServiceCollection(); + using ServiceProvider secondary = secondaryServices.BuildServiceProvider(); + + var composite = new CompositeServiceProvider(primary, secondary); + + Assert.IsType(composite.GetService(typeof(IMarkerA))); + } + + [Fact] + public void GetService_FallsBackToSecondary_WhenPrimaryDoesNotHaveIt() + { + var primaryServices = new ServiceCollection(); + using ServiceProvider primary = primaryServices.BuildServiceProvider(); + + var secondaryServices = new ServiceCollection(); + secondaryServices.AddSingleton(); + using ServiceProvider secondary = secondaryServices.BuildServiceProvider(); + + var composite = new CompositeServiceProvider(primary, secondary); + + Assert.IsType(composite.GetService(typeof(IMarkerB))); + } + + [Fact] + public void GetService_ReturnsNull_WhenNeitherHasIt() + { + var primaryServices = new ServiceCollection(); + using ServiceProvider primary = primaryServices.BuildServiceProvider(); + + var secondaryServices = new ServiceCollection(); + using ServiceProvider secondary = secondaryServices.BuildServiceProvider(); + + var composite = new CompositeServiceProvider(primary, secondary); + + Assert.Null(composite.GetService(typeof(IMarkerA))); + } + + [Fact] + public void GetService_PrimaryWins_WhenBothHaveIt() + { + var primaryServices = new ServiceCollection(); + primaryServices.AddSingleton(); + using ServiceProvider primary = primaryServices.BuildServiceProvider(); + + var secondaryServices = new ServiceCollection(); + var secondaryMarker = new MarkerA(); + secondaryServices.AddSingleton(secondaryMarker); + using ServiceProvider secondary = secondaryServices.BuildServiceProvider(); + + var composite = new CompositeServiceProvider(primary, secondary); + + Assert.NotSame(secondaryMarker, composite.GetService(typeof(IMarkerA))); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test tests/Quark.Tests.Unit/Quark.Tests.Unit.csproj --filter "FullyQualifiedName~CompositeServiceProviderTests"` +Expected: FAIL to compile — `CompositeServiceProvider` doesn't exist yet. + +- [ ] **Step 3: Implement** + +Create `src/Quark.Runtime/CompositeServiceProvider.cs`: + +```csharp +namespace Quark.Runtime; + +/// +/// IServiceProvider that resolves from a primary provider first, falling back to a secondary +/// provider when the primary has no registration for the requested type. Used to compose Quark's +/// own per-call scope with a developer-supplied, cached user-service provider +/// (see IGrainUserServiceProviderFactory) without letting the user provider ever satisfy a +/// Quark-owned service type. +/// +internal sealed class CompositeServiceProvider(IServiceProvider primary, IServiceProvider secondary) : IServiceProvider +{ + public object? GetService(Type serviceType) => primary.GetService(serviceType) ?? secondary.GetService(serviceType); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test tests/Quark.Tests.Unit/Quark.Tests.Unit.csproj --filter "FullyQualifiedName~CompositeServiceProviderTests"` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/Quark.Runtime/CompositeServiceProvider.cs tests/Quark.Tests.Unit/Runtime/CompositeServiceProviderTests.cs +git commit -m "Add CompositeServiceProvider for the Quark-first fallback resolution" +``` + +--- + +## Task 3: `IUserServiceProviderRegistry` + `QuarkOnlyServiceProviderHolder` + +**Files:** +- Create: `src/Quark.Runtime/IUserServiceProviderRegistry.cs` +- Create: `src/Quark.Runtime/UserServiceProviderRegistry.cs` +- Create: `src/Quark.Runtime/QuarkOnlyServiceProviderHolder.cs` +- Delete: `src/Quark.Runtime/IGrainScopeInitializerRegistry.cs` +- Delete: `src/Quark.Runtime/GrainScopeInitializerRegistry.cs` +- Test: `tests/Quark.Tests.Unit/Runtime/UserServiceProviderFactoryTests.cs` (add registry-only tests) + +**Interfaces:** +- Consumes: nothing new. +- Produces: `Quark.Runtime.IUserServiceProviderRegistry` (`internal`, `Register(GrainType, IServiceProvider)` / `TryGet(GrainType, out IServiceProvider?)`), `Quark.Runtime.UserServiceProviderRegistry` (implementation), `Quark.Runtime.QuarkOnlyServiceProviderHolder` (`internal sealed class` with mutable `IServiceProvider? Provider { get; set; }`). Tasks 5–7 consume all three. + +- [ ] **Step 1: Delete the old registry** + +Delete `src/Quark.Runtime/IGrainScopeInitializerRegistry.cs` and `src/Quark.Runtime/GrainScopeInitializerRegistry.cs`. + +- [ ] **Step 2: Write the failing tests** + +Add to `tests/Quark.Tests.Unit/Runtime/UserServiceProviderFactoryTests.cs` (replace the `Placeholder` test): + +```csharp +using Microsoft.Extensions.DependencyInjection; +using Quark.Core.Abstractions.Identity; +using Quark.Runtime; +using Xunit; + +namespace Quark.Tests.Unit.Runtime; + +public sealed class UserServiceProviderFactoryTests +{ + [Fact] + public void UserServiceProviderRegistry_TryGet_ReturnsFalse_WhenNotRegistered() + { + var registry = new UserServiceProviderRegistry(); + Assert.False(registry.TryGet(new GrainType("Unregistered"), out _)); + } + + [Fact] + public void UserServiceProviderRegistry_TryGet_ReturnsRegisteredProvider() + { + var registry = new UserServiceProviderRegistry(); + using ServiceProvider provider = new ServiceCollection().BuildServiceProvider(); + var grainType = new GrainType("Widget"); + + registry.Register(grainType, provider); + + Assert.True(registry.TryGet(grainType, out IServiceProvider? found)); + Assert.Same(provider, found); + } + + [Fact] + public void UserServiceProviderRegistry_Register_Throws_OnNullProvider() + { + var registry = new UserServiceProviderRegistry(); + Assert.Throws(() => registry.Register(new GrainType("Widget"), null!)); + } + + [Fact] + public void QuarkOnlyServiceProviderHolder_DefaultsToNull() + { + Assert.Null(new QuarkOnlyServiceProviderHolder().Provider); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test tests/Quark.Tests.Unit/Quark.Tests.Unit.csproj --filter "FullyQualifiedName~UserServiceProviderFactoryTests"` +Expected: FAIL to compile — none of the three new types exist yet. + +- [ ] **Step 3: Implement** + +Create `src/Quark.Runtime/IUserServiceProviderRegistry.cs`: + +```csharp +using Quark.Core.Abstractions.Identity; + +namespace Quark.Runtime; + +internal interface IUserServiceProviderRegistry +{ + void Register(GrainType grainType, IServiceProvider provider); + + bool TryGet(GrainType grainType, out IServiceProvider? provider); +} +``` + +Create `src/Quark.Runtime/UserServiceProviderRegistry.cs`: + +```csharp +using System.Collections.Concurrent; +using Quark.Core.Abstractions.Identity; + +namespace Quark.Runtime; + +internal sealed class UserServiceProviderRegistry : IUserServiceProviderRegistry +{ + private readonly ConcurrentDictionary _providers = new(); + + public void Register(GrainType grainType, IServiceProvider provider) + { + ArgumentNullException.ThrowIfNull(provider); + _providers[grainType] = provider; + } + + public bool TryGet(GrainType grainType, out IServiceProvider? provider) + => _providers.TryGetValue(grainType, out provider); +} +``` + +Create `src/Quark.Runtime/QuarkOnlyServiceProviderHolder.cs`: + +```csharp +namespace Quark.Runtime; + +/// +/// Mutable holder for the lazily-built Quark-only satellite IServiceProvider (see +/// SiloHostedService.ApplyUserServiceProviderFactoryRegistrations). Registered as a singleton so +/// GrainActivation can read it without a constructor signature change; null when no behavior in the +/// process has opted into IGrainUserServiceProviderFactory. +/// +internal sealed class QuarkOnlyServiceProviderHolder +{ + public IServiceProvider? Provider { get; set; } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test tests/Quark.Tests.Unit/Quark.Tests.Unit.csproj --filter "FullyQualifiedName~UserServiceProviderFactoryTests"` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/Quark.Runtime/IUserServiceProviderRegistry.cs src/Quark.Runtime/UserServiceProviderRegistry.cs \ + src/Quark.Runtime/QuarkOnlyServiceProviderHolder.cs \ + src/Quark.Runtime/IGrainScopeInitializerRegistry.cs src/Quark.Runtime/GrainScopeInitializerRegistry.cs \ + tests/Quark.Tests.Unit/Runtime/UserServiceProviderFactoryTests.cs +git commit -m "Add IUserServiceProviderRegistry and QuarkOnlyServiceProviderHolder, remove old registry" +``` + +--- + +## Task 4: `IBehaviorResolver`/`BehaviorResolver`/`GrainScopeBinder` — explicit construction provider + +This fixes a real correctness bug in the naive design (spec §3): `BehaviorResolver` captures its OWN +ambient `IServiceProvider` in its constructor today; if resolved from a Quark-only scope, it would use +that scope alone to construct behaviors, starving user-owned constructor parameters. The fix: `Resolve` +takes the construction provider explicitly, decoupled from whichever provider resolved `BehaviorResolver` +itself. This task is independent of the opt-in feature — it's a prerequisite refactor. + +**Files:** +- Modify: `src/Quark.Runtime/BehaviorResolver.cs` +- Modify: `src/Quark.Runtime/IBehaviorResolver.cs` +- Modify: `src/Quark.Runtime/GrainScopeBinder.cs` +- Modify: `tests/Quark.Tests.Unit/Runtime/BehaviorResolverTests.cs` + +**Interfaces:** +- Consumes: nothing new from Tasks 1–3. +- Produces: `IBehaviorResolver.Resolve(GrainType grainType, IServiceProvider services)` (was `Resolve(GrainType grainType)`); `GrainScopeBinder.BindAndResolve(IServiceProvider bindingServices, IServiceProvider constructionServices, GrainActivation activation)` returning `IGrainBehavior` (was `async BindAndResolveAsync(IServiceProvider sp, GrainActivation activation, CancellationToken ct)` returning `ValueTask`). Task 7 (`GrainActivation.RunActivationAsync`) is the only other caller and is updated there. + +- [ ] **Step 1: Update the existing tests to the new signature (will fail to compile until Step 3)** + +Edit `tests/Quark.Tests.Unit/Runtime/BehaviorResolverTests.cs` — change every constructor call and every `.Resolve(...)` call: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using Quark.Core.Abstractions.Grains; +using Quark.Core.Abstractions.Identity; +using Quark.Runtime; +using Xunit; + +namespace Quark.Tests.Unit.Runtime; + +public sealed class BehaviorResolverTests +{ + [Fact] + public void Resolve_UsesRegisteredFactory_NeverReflection() + { + // Widget is deliberately NOT registered in DI. If BehaviorResolver fell back to + // ActivatorUtilities.CreateInstance (reflection) here, resolving WidgetBehavior's + // constructor parameter would throw. Success proves the factory path was used. + var services = new ServiceCollection(); + var typeRegistry = new GrainTypeRegistry(); + var factoryRegistry = new GrainBehaviorFactoryRegistry(); + var grainType = new GrainType("Widget"); + + factoryRegistry.Register(grainType, static _ => new WidgetBehavior(new Widget(42))); + + using ServiceProvider provider = services.BuildServiceProvider(); + var resolver = new BehaviorResolver(typeRegistry, factoryRegistry); + + var behavior = Assert.IsType(resolver.Resolve(grainType, provider)); + Assert.Equal(42, behavior.Widget.Value); + } + + [Fact] + public void Resolve_FallsBackToReflection_WhenNoFactoryRegistered() + { + var services = new ServiceCollection(); + var typeRegistry = new GrainTypeRegistry(); + var factoryRegistry = new GrainBehaviorFactoryRegistry(); + var grainType = new GrainType("PlainCounter"); + typeRegistry.Register(grainType, typeof(PlainCounterBehavior)); + + using ServiceProvider provider = services.BuildServiceProvider(); + var resolver = new BehaviorResolver(typeRegistry, factoryRegistry); + + Assert.IsType(resolver.Resolve(grainType, provider)); + } + + [Fact] + public void Resolve_Throws_WhenGrainTypeUnknown() + { + var services = new ServiceCollection(); + using ServiceProvider provider = services.BuildServiceProvider(); + var resolver = new BehaviorResolver(new GrainTypeRegistry(), new GrainBehaviorFactoryRegistry()); + + Assert.Throws(() => resolver.Resolve(new GrainType("Missing"), provider)); + } + + private sealed class Widget(int value) + { + public int Value { get; } = value; + } + + private sealed class WidgetBehavior(Widget widget) : IGrainBehavior + { + public Widget Widget { get; } = widget; + } + + private sealed class PlainCounterBehavior : IGrainBehavior; +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test tests/Quark.Tests.Unit/Quark.Tests.Unit.csproj --filter "FullyQualifiedName~BehaviorResolverTests"` +Expected: FAIL to compile — `BehaviorResolver`'s constructor still takes 3 args and `Resolve` still takes 1. + +- [ ] **Step 3: Update `IBehaviorResolver`** + +Edit `src/Quark.Runtime/IBehaviorResolver.cs` — full replacement: + +```csharp +using Quark.Core.Abstractions.Grains; + +namespace Quark.Runtime; + +/// +/// Resolves the for a grain type, constructing it against an +/// explicitly-supplied rather than an ambient one — so the caller +/// always controls which provider builds the behavior (the flat per-call scope by default, or a +/// composite of a Quark-only scope + a cached user provider for opted-in grain types). +/// +public interface IBehaviorResolver +{ + IGrainBehavior Resolve(GrainType grainType, IServiceProvider services); +} +``` + +- [ ] **Step 4: Update `BehaviorResolver`** + +Edit `src/Quark.Runtime/BehaviorResolver.cs` — full replacement: + +```csharp +using Quark.Core.Abstractions.Grains; + +namespace Quark.Runtime; + +internal sealed class BehaviorResolver( + IGrainTypeRegistry typeRegistry, + GrainBehaviorFactoryRegistry factoryRegistry) : IBehaviorResolver +{ + public IGrainBehavior Resolve(GrainType grainType, IServiceProvider services) + { + if (factoryRegistry.TryGetFactory(grainType, out Func? factory) && + factory is not null) + { + return factory(services); + } + + if (!typeRegistry.TryGetGrainClass(grainType, out Type? type) || type is null) + { + throw new InvalidOperationException( + $"No behavior registered for grain type '{grainType.Value}'."); + } + +#pragma warning disable IL2026 // Fallback only reached for hand-wired (non-generator) behavior registrations. + return ReflectionBehaviorActivator.Create(services, type); +#pragma warning restore IL2026 + } +} +``` + +- [ ] **Step 5: Update `GrainScopeBinder`** + +Edit `src/Quark.Runtime/GrainScopeBinder.cs` — full replacement: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using Quark.Core.Abstractions.Grains; +using Quark.Core.Abstractions.Hosting; + +namespace Quark.Runtime; + +internal static class GrainScopeBinder +{ + /// + /// Provider used to bind the shell accessor and call context — always Quark's own scope + /// (the flat scope by default, or the small Quark-only scope for opted-in grain types). + /// + /// + /// Provider used to construct the behavior instance — the same as + /// by default, or a composite of the Quark-only scope + a cached user provider for opted-in + /// grain types. + /// + public static IGrainBehavior BindAndResolve( + IServiceProvider bindingServices, + IServiceProvider constructionServices, + GrainActivation activation) + { + ((ActivationShellAccessor)bindingServices.GetRequiredService()).Shell = activation; + + ICallContextSetter callContextSetter = bindingServices.GetRequiredService(); + callContextSetter.Set(activation.GrainId); + callContextSetter.SetIdempotencyKey(QuarkRequestContext.IdempotencyKey); + + return bindingServices.GetRequiredService().Resolve(activation.GrainType, constructionServices); + } +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `dotnet test tests/Quark.Tests.Unit/Quark.Tests.Unit.csproj --filter "FullyQualifiedName~BehaviorResolverTests"` +Expected: PASS (3 tests). Note: `GrainActivation.cs` (Task 7) and `RuntimeServiceCollectionExtensions.cs`/`SiloHostedService.cs` (Tasks 5–6) still reference the OLD `BindAndResolveAsync`/removed types — the full solution won't build again until Task 7 completes. Run the filtered test above, not a full build, to confirm this task in isolation. + +- [ ] **Step 7: Commit** + +```bash +git add src/Quark.Runtime/BehaviorResolver.cs src/Quark.Runtime/IBehaviorResolver.cs \ + src/Quark.Runtime/GrainScopeBinder.cs tests/Quark.Tests.Unit/Runtime/BehaviorResolverTests.cs +git commit -m "BehaviorResolver: take construction provider explicitly, fix ambient-scope capture bug" +``` + +--- + +## Task 5: `RuntimeServiceCollectionExtensions.cs` — new registration surface + +**Files:** +- Modify: `src/Quark.Runtime/RuntimeServiceCollectionExtensions.cs` +- Test: `tests/Quark.Tests.Unit/Runtime/AddGrainBehaviorFactoryOverloadTests.cs` + +**Interfaces:** +- Consumes: `IGrainUserServiceProviderFactory` (Task 1), `IUserServiceProviderRegistry`, `QuarkOnlyServiceProviderHolder` (Task 3). +- Produces: `public static IServiceCollection AddQuarkOwnedScoped(this IServiceCollection, Func factory) where TService : class`; `public static IServiceCollection AddGrainUserServiceProviderFactory(this IServiceCollection, string? behaviorId = null)`; `internal interface IQuarkOwnedServiceRegistration { void Apply(IServiceCollection satelliteServices); }`; `internal interface IUserServiceProviderFactoryRegistration { void Apply(IUserServiceProviderRegistry registry, IServiceProvider rootServices); }`. Task 6 (`SiloHostedService`) consumes both marker interfaces by name (`RuntimeServiceCollectionExtensions.IQuarkOwnedServiceRegistration`, `RuntimeServiceCollectionExtensions.IUserServiceProviderFactoryRegistration`) exactly as the existing `IGrainBehaviorRegistration` pattern is consumed today. Task 9 (generator) emits calls to `AddQuarkOwnedScoped` and `AddGrainUserServiceProviderFactory` by these exact names. + +- [ ] **Step 1: Write the failing test** + +Edit `tests/Quark.Tests.Unit/Runtime/AddGrainBehaviorFactoryOverloadTests.cs` — replace the +`AddGrainScopeInitializer_WithMatchingBehaviorId_RegistersUnderSameKeyAsAddGrainBehavior` test (which +references the deleted `AddGrainScopeInitializer`/`IGrainScopeInitializerRegistry`) with: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using Quark.Core.Abstractions.Grains; +using Quark.Core.Abstractions.Hosting; +using Quark.Core.Abstractions.Identity; +using Quark.Runtime; +using Xunit; + +namespace Quark.Tests.Unit.Runtime; + +public sealed class AddGrainBehaviorFactoryOverloadTests +{ + [Fact] + public void AddGrainBehavior_WithExplicitBehaviorIdAndFactory_RegistersBothWithoutReflection() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.Configure(o => + { + o.ClusterId = "test"; + o.ServiceId = "factory-overload"; + o.SiloName = "silo0"; + }); + services.AddQuarkRuntime(); + + // Widget is deliberately never registered in DI. + services.AddGrainBehavior( + behaviorId: "custom-widget-id", + factory: static _ => new WidgetBehavior(new Widget(7))); + + using ServiceProvider provider = services.BuildServiceProvider(); + + var typeRegistry = provider.GetRequiredService(); + foreach (RuntimeServiceCollectionExtensions.IGrainBehaviorRegistration reg in + provider.GetServices()) + { + reg.Apply(typeRegistry); + } + + var factoryRegistry = provider.GetRequiredService(); + foreach (RuntimeServiceCollectionExtensions.IGrainBehaviorFactoryRegistration reg in + provider.GetServices()) + { + reg.Apply(factoryRegistry); + } + + var expectedGrainType = new GrainType("custom-widget-id"); + Assert.True(typeRegistry.TryGetGrainClass(expectedGrainType, out Type? clrType)); + Assert.Equal(typeof(WidgetBehavior), clrType); + + Assert.True(factoryRegistry.TryGetFactory(expectedGrainType, out var factory)); + var behavior = Assert.IsType(factory!(provider)); + Assert.Equal(7, behavior.Widget.Value); + } + + [Fact] + public void AddGrainUserServiceProviderFactory_WithMatchingBehaviorId_RegistersUnderSameKeyAsAddGrainBehavior() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.Configure(o => + { + o.ClusterId = "test"; + o.ServiceId = "user-service-provider-factory-key-alignment"; + o.SiloName = "silo0"; + }); + services.AddQuarkRuntime(); + + services.AddGrainBehavior( + behaviorId: "custom-widget-id", + factory: static sp => new OptedInWidgetBehavior(new Widget(7))); + services.AddGrainUserServiceProviderFactory( + behaviorId: "custom-widget-id"); + + using ServiceProvider provider = services.BuildServiceProvider(); + + var registry = new UserServiceProviderRegistry(); + foreach (RuntimeServiceCollectionExtensions.IUserServiceProviderFactoryRegistration reg in + provider.GetServices()) + { + reg.Apply(registry, provider); + } + + Assert.True(registry.TryGet(new GrainType("custom-widget-id"), out IServiceProvider? found)); + Assert.Same(provider, found); + } + + [Fact] + public void AddQuarkOwnedScoped_RegistersServiceAndCapturesMarker() + { + var services = new ServiceCollection(); + services.AddQuarkOwnedScoped(static _ => new Widget(9)); + + using ServiceProvider provider = services.BuildServiceProvider(); + + Assert.Equal(9, provider.GetRequiredService().Value); + + var satellite = new ServiceCollection(); + foreach (RuntimeServiceCollectionExtensions.IQuarkOwnedServiceRegistration marker in + provider.GetServices()) + { + marker.Apply(satellite); + } + + using ServiceProvider satelliteProvider = satellite.BuildServiceProvider(); + Assert.Equal(9, satelliteProvider.GetRequiredService().Value); + } + + private interface IWidgetGrain : IGrain + { + } + + private sealed class Widget(int value) + { + public int Value { get; } = value; + } + + private sealed class WidgetBehavior(Widget widget) : IGrainBehavior, IWidgetGrain + { + public Widget Widget { get; } = widget; + } + + private sealed class OptedInWidgetBehavior(Widget widget) : IGrainBehavior, IWidgetGrain, IGrainUserServiceProviderFactory + { + public Widget Widget { get; } = widget; + + public static IServiceProvider CreateUserServiceProvider(IServiceProvider rootServices) => rootServices; + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test tests/Quark.Tests.Unit/Quark.Tests.Unit.csproj --filter "FullyQualifiedName~AddGrainBehaviorFactoryOverloadTests"` +Expected: FAIL to compile — `AddGrainUserServiceProviderFactory`, `AddQuarkOwnedScoped`, `IUserServiceProviderFactoryRegistration`, `IQuarkOwnedServiceRegistration` don't exist yet. + +- [ ] **Step 3: Remove the old `AddGrainScopeInitializer` family** + +In `src/Quark.Runtime/RuntimeServiceCollectionExtensions.cs`, delete: +- The doc comment + method `AddGrainScopeInitializer` (the block starting + `/// \n /// Registers a delegate that configures this grain type's per-call scope...` + through the closing brace of that method). +- The nested `internal interface IGrainScopeInitializerRegistration { void Apply(IGrainScopeInitializerRegistry registry); }`. +- The nested `private sealed class GrainScopeInitializerRegistration(GrainType grainType, GrainScopeInitializer initializer) : IGrainScopeInitializerRegistration { public void Apply(IGrainScopeInitializerRegistry registry) => registry.Register(grainType, initializer); }`. + +- [ ] **Step 4: Replace the registry registration line in `AddQuarkRuntime()`** + +Change: + +```csharp + services.TryAddSingleton(); +``` + +to: + +```csharp + services.TryAddSingleton(); + services.TryAddSingleton(); +``` + +- [ ] **Step 5: Add `AddQuarkOwnedScoped` and its marker** + +Add this public method near `AddManagedActivationMemory`/`AddEagerActivationMemory` (after +`AddGrainPlacementStrategy`, before `AddManagedActivationMemory`): + +```csharp + /// + /// Registers a Quark-owned scoped service AND captures a replayable marker so it can be + /// reconstructed onto a separate "Quark-only" satellite at + /// startup (see ). Used by the source generator + /// for per-behavior accessor registrations (IActivationMemory<T> etc.) — every + /// assembly's accessors become replayable this way, whether or not any behavior opts in. + /// + public static IServiceCollection AddQuarkOwnedScoped( + this IServiceCollection services, + Func factory) + where TService : class + { + services.AddScoped(factory); + services.AddSingleton(new QuarkOwnedServiceRegistration(factory)); + return services; + } +``` + +- [ ] **Step 6: Add `AddGrainUserServiceProviderFactory`** + +Add this public method right after `AddGrainBehavior(string, Func<...>)` (where +`AddGrainScopeInitializer` used to be): + +```csharp + /// + /// Registers 's + /// opt-in. Called by the generated QuarkRegistrations.g.cs path with an explicit + /// always supplied; use this overload directly for hand-wired + /// (non-generator) test/sample registrations too. + /// + /// + /// Explicit grain type key. Must match the behaviorId passed to the corresponding + /// call — otherwise this registers under a + /// different key and silently never applies. When null, falls back to reflecting + /// or the interface name, exactly as + /// does when its own behaviorId is + /// omitted. + /// + public static IServiceCollection AddGrainUserServiceProviderFactory( + this IServiceCollection services, + string? behaviorId = null) + where TInterface : IGrain + where TBehavior : class, IGrainBehavior, TInterface, IGrainUserServiceProviderFactory + { +#pragma warning disable IL2026 // Fallback only reached for hand-wired (non-generator) registrations. + string key = behaviorId ?? GetGrainTypeKey(); +#pragma warning restore IL2026 + services.AddSingleton( + new UserServiceProviderFactoryRegistration(new GrainType(key), TBehavior.CreateUserServiceProvider)); + + return services; + } +``` + +- [ ] **Step 7: Add the two new marker interfaces + implementations** + +Add these next to the other `internal interface .../private sealed class ...` pairs in the +"internal deferred-registration markers" region (where `IGrainScopeInitializerRegistration` used to be): + +```csharp + internal interface IQuarkOwnedServiceRegistration + { + void Apply(IServiceCollection satelliteServices); + } + + private sealed class QuarkOwnedServiceRegistration(Func factory) + : IQuarkOwnedServiceRegistration + where TService : class + { + public void Apply(IServiceCollection satelliteServices) => satelliteServices.AddScoped(factory); + } + + internal interface IUserServiceProviderFactoryRegistration + { + void Apply(IUserServiceProviderRegistry registry, IServiceProvider rootServices); + } + + private sealed class UserServiceProviderFactoryRegistration(GrainType grainType, Func factory) + : IUserServiceProviderFactoryRegistration + { + public void Apply(IUserServiceProviderRegistry registry, IServiceProvider rootServices) + => registry.Register(grainType, factory(rootServices)); + } +``` + +- [ ] **Step 8: Update `AddEagerActivationMemory` to use `AddQuarkOwnedScoped`** + +Change its body from `services.AddScoped>(...)` to +`services.AddQuarkOwnedScoped>(...)` — same factory lambda: + +```csharp + public static IServiceCollection AddEagerActivationMemory( + this IServiceCollection services) + where T : class + { + services.AddQuarkOwnedScoped>(static sp => + new EagerActivationMemoryAccessor( + sp.GetRequiredService() + .Shell.GetOrCreateEagerHolder())); + return services; + } +``` + +- [ ] **Step 9: Run tests to verify they pass** + +Run: `dotnet test tests/Quark.Tests.Unit/Quark.Tests.Unit.csproj --filter "FullyQualifiedName~AddGrainBehaviorFactoryOverloadTests"` +Expected: PASS (3 tests). Note: `SiloHostedService.cs` and `GrainActivation.cs` still reference the removed +`ApplyScopeInitializerRegistrations`/old `GrainScopeBinder` signature — full-solution build still fails +until Tasks 6–7 complete; run the filtered test above, not a full build. + +- [ ] **Step 10: Commit** + +```bash +git add src/Quark.Runtime/RuntimeServiceCollectionExtensions.cs \ + tests/Quark.Tests.Unit/Runtime/AddGrainBehaviorFactoryOverloadTests.cs +git commit -m "Add AddQuarkOwnedScoped/AddGrainUserServiceProviderFactory, remove AddGrainScopeInitializer" +``` + +--- + +## Task 6: `SiloHostedService` — build the registry and satellite provider at startup + +**Files:** +- Modify: `src/Quark.Runtime/SiloHostedService.cs` +- Modify: `src/Quark.Runtime/BehaviorStartupValidator.cs` + +**Interfaces:** +- Consumes: `IUserServiceProviderRegistry`, `UserServiceProviderRegistry`, `QuarkOnlyServiceProviderHolder` (Task 3); `RuntimeServiceCollectionExtensions.IUserServiceProviderFactoryRegistration`, `IQuarkOwnedServiceRegistration` (Task 5). +- Produces: `SiloHostedService.ApplyUserServiceProviderFactoryRegistrations()` (private method, called from `StartAsync` where `ApplyScopeInitializerRegistrations()` used to be called) and disposal of the satellite provider in `StopAsync`. Task 7 (`GrainActivation`) reads `IUserServiceProviderRegistry`/`QuarkOnlyServiceProviderHolder` off `_root` — this task is what populates them. + +**Why `BehaviorStartupValidator` needs a change:** `AddQuarkRuntime()` registers hosted services in this +order: `GrainIdleCollector`, `BehaviorStartupValidator`, `SiloHostedService`. .NET's generic host runs +`IHostedService.StartAsync` in registration order, so `BehaviorStartupValidator.StartAsync` runs **before** +`SiloHostedService.StartAsync` — meaning `IUserServiceProviderRegistry`/`QuarkOnlyServiceProviderHolder` +are NOT yet populated when the validator runs. Validating an opted-in behavior today's way (`root.CreateScope()` +against the flat root) would be a false-positive startup failure for any behavior whose +`CreateUserServiceProvider` deliberately does NOT rely on `silo.Services` registrations. Skip validation +for those behaviors instead of validating them against the wrong provider. + +- [ ] **Step 1: Write the failing test for the satellite-provider build** + +There is no existing dedicated test file for `SiloHostedService`'s registration-application methods (they're +exercised indirectly via `GrainScopeInitializerTests.cs`'s `ApplyRegistrations` helper, which manually +replicated the same logic rather than calling `SiloHostedService` directly). This task's behavior is +covered end-to-end in Task 8 (`UserServiceProviderFactoryTests.cs`, which drives a real +`LocalGrainCallInvoker`/`SiloHostedService`-equivalent flow) rather than a standalone unit test here — write +the implementation now; Task 8 is the test for it. This mirrors how the original `GrainScopeInitializerRegistry` +population was only ever tested indirectly through the same kind of end-to-end test. + +- [ ] **Step 2: Replace `ApplyScopeInitializerRegistrations` in `SiloHostedService.cs`** + +In `src/Quark.Runtime/SiloHostedService.cs`, change the call in `StartAsync`: + +```csharp + // Apply deferred per-call scope initializers (AddGrainScopeInitializer calls). + ApplyScopeInitializerRegistrations(); +``` + +to: + +```csharp + // Apply deferred user-service-provider-factory registrations (AddGrainUserServiceProviderFactory calls). + ApplyUserServiceProviderFactoryRegistrations(); +``` + +Then replace the `ApplyScopeInitializerRegistrations()` method body entirely with: + +```csharp + private void ApplyUserServiceProviderFactoryRegistrations() + { + if (_services.GetService() is not { } registry) + { + return; + } + + var factoryRegistrations = _services + .GetServices() + .ToList(); + + foreach (RuntimeServiceCollectionExtensions.IUserServiceProviderFactoryRegistration reg in factoryRegistrations) + { + reg.Apply(registry, _services); + } + + if (factoryRegistrations.Count == 0) + { + return; + } + + GrainTypeRegistry mainTypeRegistry = _services.GetRequiredService(); + GrainBehaviorFactoryRegistry mainFactoryRegistry = _services.GetRequiredService(); + + var quarkOnly = new ServiceCollection(); + quarkOnly.AddSingleton(mainTypeRegistry); + quarkOnly.AddSingleton(mainTypeRegistry); + quarkOnly.AddSingleton(mainFactoryRegistry); + quarkOnly.AddScoped(); + quarkOnly.AddScoped(sp => sp.GetRequiredService()); + quarkOnly.AddScoped(); + quarkOnly.AddScoped(sp => sp.GetRequiredService()); + quarkOnly.AddScoped(sp => sp.GetRequiredService()); + quarkOnly.AddScoped(); + + foreach (RuntimeServiceCollectionExtensions.IQuarkOwnedServiceRegistration marker in + _services.GetServices()) + { + marker.Apply(quarkOnly); + } + + _services.GetRequiredService().Provider = quarkOnly.BuildServiceProvider(); + } +``` + +- [ ] **Step 3: Dispose the satellite provider on shutdown** + +In `StopAsync`, right after the existing `GrainActivationTable` drain block (`if (_services.GetService() is { } table) { ... }`), add: + +```csharp + if (_services.GetService()?.Provider is IAsyncDisposable quarkOnlyProvider) + { + await quarkOnlyProvider.DisposeAsync().ConfigureAwait(false); + } +``` + +- [ ] **Step 4: Skip opted-in behaviors in `BehaviorStartupValidator`** + +Edit `src/Quark.Runtime/BehaviorStartupValidator.cs` — inside the `foreach` loop in `StartAsync`, add a +skip check right after the loop variable is bound: + +```csharp + foreach ((GrainType grainType, Type behaviorType) in typeRegistry.GetAll()) + { + if (typeof(IGrainUserServiceProviderFactory).IsAssignableFrom(behaviorType)) + { + // Opted-in behaviors are constructed against a composite of a Quark-only scope + a + // cached user provider built later in SiloHostedService.StartAsync (which runs AFTER + // this hosted service, per AddQuarkRuntime()'s hosted-service registration order). + // Validating against the flat root here would produce false-positive startup failures + // for behaviors whose CreateUserServiceProvider doesn't rely on silo.Services at all. + logger.LogDebug( + "Behavior {Type} skipped DI validation (opts into IGrainUserServiceProviderFactory)", + behaviorType.Name); + continue; + } + + try + { +``` + +(The existing `try { ... } catch { ... }` block and its closing brace stay as-is — this just adds the +`if`/`continue` guard before the `try`.) + +- [ ] **Step 5: Build to confirm this task compiles in isolation** + +Run: `dotnet build src/Quark.Runtime/Quark.Runtime.csproj` +Expected: Still FAILS — `GrainActivation.cs` (Task 7) still calls the old `GrainScopeBinder.BindAndResolveAsync` +signature and doesn't yet branch on the new registry/holder. Confirm the ONLY remaining errors are in +`GrainActivation.cs`. + +- [ ] **Step 6: Commit** + +```bash +git add src/Quark.Runtime/SiloHostedService.cs src/Quark.Runtime/BehaviorStartupValidator.cs +git commit -m "SiloHostedService: build user-service-provider registry and Quark-only satellite at startup" +``` + +--- + +## Task 7: `GrainActivation.RunActivationAsync` — branch on the opt-in path + +**Files:** +- Modify: `src/Quark.Runtime/GrainActivation.cs:881-891` + +**Interfaces:** +- Consumes: `IUserServiceProviderRegistry`, `QuarkOnlyServiceProviderHolder` (Task 3), `CompositeServiceProvider` (Task 2), `GrainScopeBinder.BindAndResolve` (Task 4). +- Produces: the completed `RunActivationAsync` — no further tasks build on this directly, but Task 8's tests exercise it end-to-end. + +- [ ] **Step 1: There is no isolated unit test for this method today** + +`RunActivationAsync` is `internal` and exercised only through `LocalGrainCallInvoker`/`GrainActivationTable` +integration flows — exactly like the original scope-initializer behavior, which was tested via +`GrainScopeInitializerTests.cs` driving a real `LocalGrainCallInvoker`. Task 8 is the test for this method; +implement it now. + +- [ ] **Step 2: Replace `RunActivationAsync`** + +In `src/Quark.Runtime/GrainActivation.cs`, replace the method (currently at lines 881-891): + +```csharp + internal async Task RunActivationAsync(CancellationToken ct) + { + using IServiceScope scope = _root.CreateScope(); + IServiceProvider sp = scope.ServiceProvider; + IGrainBehavior behavior = await GrainScopeBinder.BindAndResolveAsync(sp, this, ct).ConfigureAwait(false); + await RunEagerInitAsync(sp, ct).ConfigureAwait(false); + if (behavior is IActivationLifecycle lifecycle) + { + await lifecycle.OnActivateAsync(ct).ConfigureAwait(false); + } + } +``` + +with: + +```csharp + internal async Task RunActivationAsync(CancellationToken ct) + { + IUserServiceProviderRegistry registry = _root.GetRequiredService(); + QuarkOnlyServiceProviderHolder holder = _root.GetRequiredService(); + + bool useQuarkOnlyScope = holder.Provider is not null && + registry.TryGet(GrainType, out IServiceProvider? userProvider) && userProvider is not null; + + using IServiceScope scope = useQuarkOnlyScope ? holder.Provider!.CreateScope() : _root.CreateScope(); + IServiceProvider constructionServices = useQuarkOnlyScope + ? new CompositeServiceProvider(scope.ServiceProvider, userProvider!) + : scope.ServiceProvider; + + IGrainBehavior behavior = GrainScopeBinder.BindAndResolve(scope.ServiceProvider, constructionServices, this); + await RunEagerInitAsync(constructionServices, ct).ConfigureAwait(false); + if (behavior is IActivationLifecycle lifecycle) + { + await lifecycle.OnActivateAsync(ct).ConfigureAwait(false); + } + } +``` + +Update the doc comment immediately above the method (currently: `// Runs the full activation sequence in a +single scope: ...`) to: + +```csharp + // Runs the full activation sequence: + // 1. Bind shell accessor + call context, using the Quark-only scope for opted-in grain types + // (see IUserServiceProviderRegistry/QuarkOnlyServiceProviderHolder) or the flat scope otherwise. + // 2. Resolve behavior (ctor fires; any IEagerActivationMemory.Load() calls register factories). + // 3. Initialize all eager holders with the construction provider BEFORE OnActivateAsync. + // 4. Call OnActivateAsync if the behavior implements IActivationLifecycle. +``` + +- [ ] **Step 3: Full solution build** + +Run: `dotnet build Quark.slnx` +Expected: SUCCEEDS. This is the first point since Task 1 where the whole solution should compile — +confirm no remaining references to `GrainScopeInitializer`, `IGrainScopeInitializerRegistry`, +`GrainScopeInitializerRegistry`, `AddGrainScopeInitializer`, or the old 1-arg `IBehaviorResolver.Resolve`/ +`GrainScopeBinder.BindAndResolveAsync` signatures anywhere in the solution. + +- [ ] **Step 4: Run the full unit test suite** + +Run: `dotnet test tests/Quark.Tests.Unit/Quark.Tests.Unit.csproj` +Expected: PASS — including all existing tests that exercise `RunActivationAsync` indirectly (activation +lifecycle tests, mailbox tests, etc.), since the non-opted-in path is behaviorally identical to before. + +- [ ] **Step 5: Commit** + +```bash +git add src/Quark.Runtime/GrainActivation.cs +git commit -m "GrainActivation: branch RunActivationAsync on the user-service-provider opt-in path" +``` + +--- + +## Task 8: End-to-end tests for the opt-in flow + +**Files:** +- Modify: `tests/Quark.Tests.Unit/Runtime/UserServiceProviderFactoryTests.cs` (remove placeholder, add real tests) + +**Interfaces:** +- Consumes: everything from Tasks 1–7. +- Produces: nothing further downstream — this is the integration-level confirmation the mechanism works end-to-end. + +- [ ] **Step 1: Write the tests** + +Replace the entire contents of `tests/Quark.Tests.Unit/Runtime/UserServiceProviderFactoryTests.cs` with: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Quark.Core.Abstractions.Grains; +using Quark.Core.Abstractions.Hosting; +using Quark.Core.Abstractions.Identity; +using Quark.Runtime; +using Quark.Serialization.Abstractions.Buffers; +using Xunit; + +namespace Quark.Tests.Unit.Runtime; + +public sealed class UserServiceProviderFactoryTests +{ + [Fact] + public void UserServiceProviderRegistry_TryGet_ReturnsFalse_WhenNotRegistered() + { + var registry = new UserServiceProviderRegistry(); + Assert.False(registry.TryGet(new GrainType("Unregistered"), out _)); + } + + [Fact] + public void UserServiceProviderRegistry_TryGet_ReturnsRegisteredProvider() + { + var registry = new UserServiceProviderRegistry(); + using ServiceProvider provider = new ServiceCollection().BuildServiceProvider(); + var grainType = new GrainType("Widget"); + + registry.Register(grainType, provider); + + Assert.True(registry.TryGet(grainType, out IServiceProvider? found)); + Assert.Same(provider, found); + } + + [Fact] + public void UserServiceProviderRegistry_Register_Throws_OnNullProvider() + { + var registry = new UserServiceProviderRegistry(); + Assert.Throws(() => registry.Register(new GrainType("Widget"), null!)); + } + + [Fact] + public void QuarkOnlyServiceProviderHolder_DefaultsToNull() + { + Assert.Null(new QuarkOnlyServiceProviderHolder().Provider); + } + + [Fact] + public async Task OptedInBehavior_UserFactory_RunsOnce_ReusedAcrossCalls() + { + var callCount = 0; + ServiceCollection services = CreateServices(); + + services.AddGrainBehavior( + behaviorId: "CountingGrain", + factory: static sp => new CountingBehavior(sp.GetRequiredService())); + services.AddGrainUserServiceProviderFactory(behaviorId: "CountingGrain"); + services.AddSingleton(new UserFactoryProbe(() => callCount++)); + + await using ServiceProvider provider = services.BuildServiceProvider(); + ApplyRegistrations(provider); + + LocalGrainCallInvoker invoker = CreateInvoker(provider); + var grainId = new GrainId(new GrainType("CountingGrain"), "counter-1"); + + int first = await invoker.InvokeAsync(grainId, new IncrementInvokable(), CancellationToken.None); + int second = await invoker.InvokeAsync(grainId, new IncrementInvokable(), CancellationToken.None); + + Assert.Equal(1, first); + Assert.Equal(2, second); + Assert.Equal(1, callCount); // CreateUserServiceProvider ran exactly once, not once per call. + } + + [Fact] + public async Task NonOptedInBehavior_IsUnaffected() + { + ServiceCollection services = CreateServices(); + services.AddGrainBehavior( + behaviorId: "PlainCountingGrain", + factory: static sp => new PlainCountingBehavior(sp.GetRequiredService())); + + await using ServiceProvider provider = services.BuildServiceProvider(); + ApplyRegistrations(provider); + + LocalGrainCallInvoker invoker = CreateInvoker(provider); + var grainId = new GrainId(new GrainType("PlainCountingGrain"), "counter-2"); + + int result = await invoker.InvokeAsync(grainId, new IncrementInvokable(), CancellationToken.None); + Assert.Equal(1, result); + } + + [Fact] + public async Task OptedInBehavior_QuarkServicesResolveFromEngine_NotFromUserProvider() + { + ServiceCollection services = CreateServices(); + + // Deliberately return a provider that ALSO has ICallContext registered — a misuse scenario. + // The engine's real per-call ICallContext must still win (structural guarantee, not convention). + services.AddGrainBehavior( + behaviorId: "TenantGrain", + factory: static sp => new TenantBehavior(sp.GetRequiredService())); + services.AddGrainUserServiceProviderFactory(behaviorId: "TenantGrain"); + + await using ServiceProvider provider = services.BuildServiceProvider(); + ApplyRegistrations(provider); + + LocalGrainCallInvoker invoker = CreateInvoker(provider); + var grainId = new GrainId(new GrainType("TenantGrain"), "tenant-xyz"); + + string result = await invoker.InvokeAsync(grainId, new GetGrainKeyInvokable(), CancellationToken.None); + + Assert.Equal("tenant-xyz", result); + } + + [Fact] + public void CreateUserServiceProviderThrows_FailsSiloStartup_NotFirstCall() + { + ServiceCollection services = CreateServices(); + services.AddGrainBehavior( + behaviorId: "ThrowingFactoryGrain", + factory: static sp => new ThrowingFactoryBehavior(sp.GetRequiredService())); + services.AddGrainUserServiceProviderFactory( + behaviorId: "ThrowingFactoryGrain"); + + using ServiceProvider provider = services.BuildServiceProvider(); + + var registry = provider.GetRequiredService(); + foreach (RuntimeServiceCollectionExtensions.IGrainBehaviorRegistration reg in + provider.GetServices()) + { + reg.Apply(registry); + } + + var userRegistry = new UserServiceProviderRegistry(); + var factoryRegistrations = provider + .GetServices(); + + Assert.Throws(() => + { + foreach (RuntimeServiceCollectionExtensions.IUserServiceProviderFactoryRegistration reg in factoryRegistrations) + { + reg.Apply(userRegistry, provider); + } + }); + } + + private static ServiceCollection CreateServices() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.Configure(o => + { + o.ClusterId = "test"; + o.ServiceId = "user-service-provider-factory"; + o.SiloName = "silo0"; + }); + services.AddQuarkRuntime(); + services.AddSingleton(); + return services; + } + + private static void ApplyRegistrations(ServiceProvider provider) + { + var typeRegistry = provider.GetRequiredService(); + foreach (RuntimeServiceCollectionExtensions.IGrainBehaviorRegistration reg in + provider.GetServices()) + { + reg.Apply(typeRegistry); + } + + var factoryRegistry = provider.GetRequiredService(); + foreach (RuntimeServiceCollectionExtensions.IGrainBehaviorFactoryRegistration reg in + provider.GetServices()) + { + reg.Apply(factoryRegistry); + } + + var userRegistry = provider.GetRequiredService(); + var factoryRegistrations = provider + .GetServices() + .ToList(); + foreach (RuntimeServiceCollectionExtensions.IUserServiceProviderFactoryRegistration reg in factoryRegistrations) + { + reg.Apply(userRegistry, provider); + } + + if (factoryRegistrations.Count > 0) + { + var mainTypeRegistry = provider.GetRequiredService(); + var mainFactoryRegistry = provider.GetRequiredService(); + + var quarkOnly = new ServiceCollection(); + quarkOnly.AddSingleton(mainTypeRegistry); + quarkOnly.AddSingleton(mainTypeRegistry); + quarkOnly.AddSingleton(mainFactoryRegistry); + quarkOnly.AddScoped(); + quarkOnly.AddScoped(sp => sp.GetRequiredService()); + quarkOnly.AddScoped(); + quarkOnly.AddScoped(sp => sp.GetRequiredService()); + quarkOnly.AddScoped(sp => sp.GetRequiredService()); + quarkOnly.AddScoped(); + + foreach (RuntimeServiceCollectionExtensions.IQuarkOwnedServiceRegistration marker in + provider.GetServices()) + { + marker.Apply(quarkOnly); + } + + provider.GetRequiredService().Provider = quarkOnly.BuildServiceProvider(); + } + } + + private static LocalGrainCallInvoker CreateInvoker(ServiceProvider provider) + => new( + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService(), + provider, + provider.GetRequiredService>(), + NullLogger.Instance, + NullLogger.Instance); + + private sealed class Counter + { + public int Value { get; set; } + } + + private sealed class UserFactoryProbe(Action onCreate) + { + public void RecordCreate() => onCreate(); + } + + private interface ICountingGrain : IGrain + { + Task IncrementAsync(); + } + + private sealed class CountingBehavior(Counter counter) : IGrainBehavior, ICountingGrain, IGrainUserServiceProviderFactory + { + public Task IncrementAsync() + { + counter.Value++; + return Task.FromResult(counter.Value); + } + + public static IServiceProvider CreateUserServiceProvider(IServiceProvider rootServices) + { + rootServices.GetRequiredService().RecordCreate(); + return rootServices; + } + } + + private sealed class PlainCountingBehavior(Counter counter) : IGrainBehavior, ICountingGrain + { + public Task IncrementAsync() + { + counter.Value++; + return Task.FromResult(counter.Value); + } + } + + private sealed class ThrowingFactoryBehavior(Counter counter) : IGrainBehavior, ICountingGrain, IGrainUserServiceProviderFactory + { + public Task IncrementAsync() => Task.FromResult(counter.Value); + + public static IServiceProvider CreateUserServiceProvider(IServiceProvider rootServices) + => throw new InvalidOperationException("simulated startup misconfiguration"); + } + + private interface ITenantGrain : IGrain + { + Task GetKeyAsync(); + } + + private sealed class TenantBehavior(ICallContext ctx) : IGrainBehavior, ITenantGrain, IGrainUserServiceProviderFactory + { + public Task GetKeyAsync() => Task.FromResult(ctx.GrainId.Key); + + public static IServiceProvider CreateUserServiceProvider(IServiceProvider rootServices) + { + // Misuse: registers a decoy ICallContext into the "user" provider. The engine's real + // per-call ICallContext must still win via CompositeServiceProvider's Quark-first ordering. + var decoy = new ServiceCollection(); + decoy.AddSingleton(new DecoyCallContext()); + return decoy.BuildServiceProvider(); + } + } + + private sealed class DecoyCallContext : ICallContext + { + public GrainId GrainId => new(new GrainType("Decoy"), "decoy-key"); + } + + private readonly struct IncrementInvokable : IGrainInvokable + { + public uint MethodId => 1; + + public ValueTask Invoke(IGrainBehavior behavior) + => new(((ICountingGrain)behavior).IncrementAsync()); + + public void Serialize(ref CodecWriter writer) { } + + public int DeserializeResult(ref CodecReader reader) => reader.ReadInt32(); + } + + private readonly struct GetGrainKeyInvokable : IGrainInvokable + { + public uint MethodId => 1; + + public ValueTask Invoke(IGrainBehavior behavior) + => new(((ITenantGrain)behavior).GetKeyAsync()); + + public void Serialize(ref CodecWriter writer) { } + + public string DeserializeResult(ref CodecReader reader) => reader.ReadString(); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail, then pass** + +Run: `dotnet test tests/Quark.Tests.Unit/Quark.Tests.Unit.csproj --filter "FullyQualifiedName~UserServiceProviderFactoryTests"` + +If any test fails, diagnose against the exact mechanism in Tasks 3–7 before changing test expectations — +in particular: +- `OptedInBehavior_UserFactory_RunsOnce_ReusedAcrossCalls` depends on `ApplyRegistrations` building the + Quark-only satellite BEFORE `CreateInvoker` drives any calls (order in the test matters). +- `OptedInBehavior_QuarkServicesResolveFromEngine_NotFromUserProvider` is the direct regression test for + the `CompositeServiceProvider` Quark-first ordering documented in spec §3 — if this fails, check + `CompositeServiceProvider`'s argument order in `GrainActivation.RunActivationAsync` (Task 7, Step 2): + the Quark scope must be `primary`, the user provider `secondary`. + +Expected: PASS (7 tests total, including the 4 registry tests from Task 3). + +- [ ] **Step 3: Run the full unit test suite** + +Run: `dotnet test tests/Quark.Tests.Unit/Quark.Tests.Unit.csproj` +Expected: PASS — no regressions in unrelated tests. + +- [ ] **Step 4: Commit** + +```bash +git add tests/Quark.Tests.Unit/Runtime/UserServiceProviderFactoryTests.cs +git commit -m "Add end-to-end tests for the IGrainUserServiceProviderFactory opt-in flow" +``` + +--- + +## Task 9: `BehaviorRegistrationGenerator` — detect the opt-in interface, emit registration + +**Files:** +- Modify: `src/Quark.CodeGenerator/BehaviorRegistrationGenerator.cs` +- Modify: `tests/Quark.Tests.CodeGenerator/BehaviorRegistrationGeneratorTests.cs` + +**Interfaces:** +- Consumes: `AddGrainUserServiceProviderFactory` and `AddQuarkOwnedScoped` (Task 5), by exact name — the generator emits calls to these. +- Produces: generated code calling `RuntimeServiceCollectionExtensions.AddGrainUserServiceProviderFactory<...>` for behaviors implementing `IGrainUserServiceProviderFactory`, and `AddQuarkOwnedScoped` instead of `AddScoped` for `IActivationMemory`/`IManagedActivationMemory` accessor emissions. + +- [ ] **Step 1: Write the failing tests** + +Add these tests to `tests/Quark.Tests.CodeGenerator/BehaviorRegistrationGeneratorTests.cs` (near +`Generates_IActivationMemory_Scoped_Registration`): + +```csharp + [Fact] + public void Generates_UserServiceProviderFactory_Registration_When_Behavior_Opts_In() + { + const string source = """ + using System.Threading.Tasks; + using Quark.Core.Abstractions.Grains; + using Quark.Core.Abstractions.Hosting; + + namespace Demo; + + public interface ICounterGrain : IGrainWithStringKey + { + Task IncrementAsync(); + } + + public sealed class CounterBehavior : IGrainBehavior, ICounterGrain, IGrainUserServiceProviderFactory + { + public Task IncrementAsync() => Task.CompletedTask; + + public static IServiceProvider CreateUserServiceProvider(IServiceProvider rootServices) => rootServices; + } + """; + + GeneratorTestResult result = GeneratorTestDriver.Run(source, new GrainProxyGenerator(), new BehaviorRegistrationGenerator()); + + AssertNoErrors(result.Diagnostics); + string generated = GetRegistrations(result); + + Assert.Contains( + "AddGrainUserServiceProviderFactory(", + generated); + Assert.Contains("behaviorId: \"CounterGrain\");", generated); + } + + [Fact] + public void Does_Not_Generate_UserServiceProviderFactory_Registration_When_Behavior_Does_Not_Opt_In() + { + const string source = """ + using System.Threading.Tasks; + using Quark.Core.Abstractions.Grains; + + namespace Demo; + + public interface ICounterGrain : IGrainWithStringKey + { + Task IncrementAsync(); + } + + public sealed class CounterBehavior : IGrainBehavior, ICounterGrain + { + public Task IncrementAsync() => Task.CompletedTask; + } + """; + + GeneratorTestResult result = GeneratorTestDriver.Run(source, new GrainProxyGenerator(), new BehaviorRegistrationGenerator()); + + AssertNoErrors(result.Diagnostics); + string generated = GetRegistrations(result); + + Assert.DoesNotContain("AddGrainUserServiceProviderFactory<", generated); + } + + [Fact] + public void Generates_IActivationMemory_Registration_Via_AddQuarkOwnedScoped() + { + const string source = """ + using System.Threading.Tasks; + using Quark.Core.Abstractions.Grains; + using Quark.Core.Abstractions.Hosting; + + namespace Demo; + + public sealed class CounterState { public int Value { get; set; } } + + public interface ICounterGrain : IGrainWithStringKey + { + Task IncrementAsync(); + } + + public sealed class CounterBehavior : IGrainBehavior, ICounterGrain + { + public CounterBehavior(IActivationMemory memory) { } + public Task IncrementAsync() => Task.CompletedTask; + } + """; + + GeneratorTestResult result = GeneratorTestDriver.Run(source, new GrainProxyGenerator(), new BehaviorRegistrationGenerator()); + + AssertNoErrors(result.Diagnostics); + string generated = GetRegistrations(result); + + Assert.Contains( + "RuntimeServiceCollectionExtensions.AddQuarkOwnedScoped>(services,", + generated); + Assert.DoesNotContain( + "services.AddScoped>(", + generated); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test tests/Quark.Tests.CodeGenerator/Quark.Tests.CodeGenerator.csproj --filter "FullyQualifiedName~BehaviorRegistrationGeneratorTests"` +Expected: FAIL — `AddGrainUserServiceProviderFactory_...` tests fail (nothing emitted); `Generates_IActivationMemory_Registration_Via_AddQuarkOwnedScoped` fails (still emits `AddScoped`, not `AddQuarkOwnedScoped`). Existing tests should still pass. + +- [ ] **Step 3: Add the detection constant and model field** + +In `src/Quark.CodeGenerator/BehaviorRegistrationGenerator.cs`, add a new constant alongside the existing +ones (near `IActivationMemoryNs`): + +```csharp + private const string IGrainUserServiceProviderFactoryFqn = "Quark.Core.Abstractions.Hosting.IGrainUserServiceProviderFactory"; +``` + +In `ExtractModel`, right after the existing `grainIface` disambiguation block (after +`if (grainIfaces.Count > 1) { ... }`), add: + +```csharp + bool implementsUserServiceProviderFactory = type.AllInterfaces.Any( + static i => i.ToDisplayString() == IGrainUserServiceProviderFactoryFqn); +``` + +- [ ] **Step 4: Thread the new field through `BehaviorModel`** + +In the `BehaviorModel` class, add a new property and thread it through both constructors: + +```csharp + // Error-only model (QRK0050): only diagnostics are populated. + public BehaviorModel(ImmutableArray diagnostics) + { + Diagnostics = diagnostics; + BehaviorFqn = string.Empty; + GrainInterfaceFqn = string.Empty; + GrainTypeName = string.Empty; + ProxyFqn = string.Empty; + PlacementStrategyExpression = string.Empty; + FactoryExpression = null; + InMemoryStateTypes = ImmutableArray.Empty; + PersistentStateTypes = ImmutableArray.Empty; + ManagedStateTypes = ImmutableArray.Empty; + EagerStateTypes = ImmutableArray.Empty; + ImplicitStreamNamespaces = ImmutableArray.Empty; + PersistentStateSlots = ImmutableArray.Empty; + ImplementsUserServiceProviderFactory = false; + } + + public BehaviorModel( + string behaviorFqn, + string grainInterfaceFqn, + string grainTypeName, + string proxyFqn, + string placementStrategyExpression, + string? factoryExpression, + ImmutableArray inMemoryStateTypes, + ImmutableArray persistentStateTypes, + ImmutableArray managedStateTypes, + ImmutableArray eagerStateTypes, + ImmutableArray implicitStreamNamespaces, + ImmutableArray persistentStateSlots, + bool implementsUserServiceProviderFactory, + ImmutableArray diagnostics) + { + BehaviorFqn = behaviorFqn; + GrainInterfaceFqn = grainInterfaceFqn; + GrainTypeName = grainTypeName; + ProxyFqn = proxyFqn; + PlacementStrategyExpression = placementStrategyExpression; + FactoryExpression = factoryExpression; + InMemoryStateTypes = inMemoryStateTypes; + PersistentStateTypes = persistentStateTypes; + ManagedStateTypes = managedStateTypes; + EagerStateTypes = eagerStateTypes; + ImplicitStreamNamespaces = implicitStreamNamespaces; + PersistentStateSlots = persistentStateSlots; + ImplementsUserServiceProviderFactory = implementsUserServiceProviderFactory; + Diagnostics = diagnostics; + } + + public bool IsValid => !string.IsNullOrEmpty(BehaviorFqn); + public string BehaviorFqn { get; } + public string GrainInterfaceFqn { get; } + public string GrainTypeName { get; } + public string ProxyFqn { get; } + public string PlacementStrategyExpression { get; } + public string? FactoryExpression { get; } + public ImmutableArray InMemoryStateTypes { get; } + public ImmutableArray PersistentStateTypes { get; } + public ImmutableArray ManagedStateTypes { get; } + public ImmutableArray EagerStateTypes { get; } + public ImmutableArray ImplicitStreamNamespaces { get; } + public ImmutableArray PersistentStateSlots { get; } + public bool ImplementsUserServiceProviderFactory { get; } + public ImmutableArray Diagnostics { get; } +``` + +Update the single call site in `ExtractModel` (the `return new BehaviorModel(...)` at the end) to pass the +new argument in the matching position (right before `diagnostics`): + +```csharp + return new BehaviorModel( + behaviorFqn: behaviorFqn, + grainInterfaceFqn: grainIfaceFqn, + grainTypeName: grainTypeName, + proxyFqn: proxyFqn, + placementStrategyExpression: placementStrategyExpression, + factoryExpression: factoryExpression, + inMemoryStateTypes: inMemory.Distinct().ToImmutableArray(), + persistentStateTypes: persistent.Distinct().ToImmutableArray(), + managedStateTypes: managed.Distinct().ToImmutableArray(), + eagerStateTypes: eager.Distinct().ToImmutableArray(), + implicitStreamNamespaces: implicitNamespaces.Distinct().ToImmutableArray(), + persistentStateSlots: persistentSlots.Distinct().ToImmutableArray(), + implementsUserServiceProviderFactory: implementsUserServiceProviderFactory, + diagnostics: diagList.ToImmutableArray()); +``` + +- [ ] **Step 5: Emit the registration call** + +In the `Emit` method's per-behavior loop (the `foreach (BehaviorModel m in valid)` block that emits +`AddGrainBehavior`/`AddGrainPlacementStrategy`/`AddGrainTransportDispatcher`), add right after the +`AddGrainTransportDispatcher` emission: + +```csharp + if (m.ImplementsUserServiceProviderFactory) + { + sb.AppendLine($" global::Quark.Runtime.RuntimeServiceCollectionExtensions.AddGrainUserServiceProviderFactory<{m.GrainInterfaceFqn}, {m.BehaviorFqn}>("); + sb.AppendLine($" services, behaviorId: \"{m.GrainTypeName}\");"); + } +``` + +- [ ] **Step 6: Switch `IActivationMemory`/`IManagedActivationMemory` emissions to `AddQuarkOwnedScoped`** + +Change the `IActivationMemory` emission block from: + +```csharp + foreach (string tArg in inMemoryStates) + { + sb.AppendLine($" services.AddScoped>(static sp =>"); + sb.AppendLine($" new global::Quark.Persistence.Abstractions.ActivationMemoryAccessor<{tArg}>("); + sb.AppendLine($" sp.GetRequiredService()"); + sb.AppendLine($" .Shell.GetOrCreateHolder<{tArg}>()));"); + } +``` + +to: + +```csharp + foreach (string tArg in inMemoryStates) + { + sb.AppendLine($" global::Quark.Runtime.RuntimeServiceCollectionExtensions.AddQuarkOwnedScoped>(services, static sp =>"); + sb.AppendLine($" new global::Quark.Persistence.Abstractions.ActivationMemoryAccessor<{tArg}>("); + sb.AppendLine($" sp.GetRequiredService()"); + sb.AppendLine($" .Shell.GetOrCreateHolder<{tArg}>()));"); + } +``` + +Change the `IManagedActivationMemory` emission block from: + +```csharp + foreach (string tArg in managedStates) + { + sb.AppendLine($" services.AddScoped>(static sp =>"); + sb.AppendLine($" new global::Quark.Persistence.Abstractions.ManagedActivationMemoryAccessor<{tArg}>("); + sb.AppendLine($" sp.GetRequiredService()"); + sb.AppendLine($" .Shell.GetOrCreateManagedHolder<{tArg}>()));"); + } +``` + +to: + +```csharp + foreach (string tArg in managedStates) + { + sb.AppendLine($" global::Quark.Runtime.RuntimeServiceCollectionExtensions.AddQuarkOwnedScoped>(services, static sp =>"); + sb.AppendLine($" new global::Quark.Persistence.Abstractions.ManagedActivationMemoryAccessor<{tArg}>("); + sb.AppendLine($" sp.GetRequiredService()"); + sb.AppendLine($" .Shell.GetOrCreateManagedHolder<{tArg}>()));"); + } +``` + +`IPersistentActivationMemory` (lines around 452-459) and `IPersistentState` slots (lines around +490-495) are **intentionally left unchanged** — still plain `services.AddScoped<...>(...)` — per the v1 +non-goal (spec §2): they need `IStorage`/`IGrainStorage` from separate packages not covered here. + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `dotnet test tests/Quark.Tests.CodeGenerator/Quark.Tests.CodeGenerator.csproj --filter "FullyQualifiedName~BehaviorRegistrationGeneratorTests"` +Expected: PASS — all new tests plus every pre-existing test in the file (the `IPersistentActivationMemory` +scoped-registration test must still pass unchanged, confirming persistence emissions were untouched). + +- [ ] **Step 8: Run the full test suite** + +Run: `dotnet test Quark.slnx` +Expected: PASS across all test projects. + +- [ ] **Step 9: Commit** + +```bash +git add src/Quark.CodeGenerator/BehaviorRegistrationGenerator.cs \ + tests/Quark.Tests.CodeGenerator/BehaviorRegistrationGeneratorTests.cs +git commit -m "Generator: detect IGrainUserServiceProviderFactory, emit via AddQuarkOwnedScoped" +``` + +--- + +## Task 10: AOT smoke build and documentation + +**Files:** +- No new AOT-smoke host — confirmed via `.github/workflows/ci.yml:47` that the repo's existing "Native AOT + smoke publish" step runs `dotnet publish src/Quark.Runtime/Quark.Runtime.csproj -f net10.0 -c Release -r + ${{ matrix.rid }} /p:PublishAot=true` — it AOT-publishes the `Quark.Runtime` **library** itself (confirming + every type in that assembly, including the ones this plan adds, trims/AOTs clean), not a sample host with + generator-driven behaviors. No existing CI step exercises generator-emitted code + (`AddGrainUserServiceProviderFactory`/`AddQuarkOwnedScoped` calls) under `PublishAot=true` at all — this + is a **pre-existing gap**, not something this change introduces, so adding a new generator-consuming AOT + smoke sample to CI is explicitly **out of scope** here (a CI workflow change should be its own, + separately-reviewed task). This task only re-runs the existing command to confirm no regression. +- Modify: `FEATURES.md` +- Modify: `wiki/Source-Generators.md` + +**Interfaces:** +- Consumes: everything from Tasks 1–9. +- Produces: nothing further — this is the closing documentation/verification task. + +- [ ] **Step 1: Run the existing AOT smoke publish locally** + +Run: `dotnet publish src/Quark.Runtime/Quark.Runtime.csproj -f net10.0 -c Release -r linux-x64 /p:PublishAot=true` +Expected: SUCCEEDS with no new `RequiresUnreferencedCode`/`RequiresDynamicCode`/trim warnings attributable +to this change — confirms `CompositeServiceProvider`, `UserServiceProviderRegistry`, +`QuarkOnlyServiceProviderHolder`, and the modified `BehaviorResolver`/`GrainScopeBinder`/ +`SiloHostedService`/`GrainActivation`/`BehaviorStartupValidator` don't introduce new AOT diagnostics in the +`Quark.Runtime` assembly itself. + +- [ ] **Step 2: Update `FEATURES.md`** + +Find the row/section covering grain-scope/DI extensibility (search `grep -n "scope initializer\|ScopeInitializer" FEATURES.md`) +and update it to describe `IGrainUserServiceProviderFactory` as the current mechanism, noting the +`GrainScopeInitializer` family was removed in favor of it, with a one-line pointer to +`docs/superpowers/specs/2026-07-10-grain-user-service-provider-factory-design.md`. + +- [ ] **Step 3: Update `wiki/Source-Generators.md`** + +Add a short section documenting the new generator behavior: when a behavior implements +`IGrainUserServiceProviderFactory`, the generator emits an `AddGrainUserServiceProviderFactory<,>` call; +and that `IActivationMemory`/`IManagedActivationMemory`/`IEagerActivationMemory` accessor +registrations now go through `AddQuarkOwnedScoped` instead of plain `AddScoped` (functionally +identical for behaviors that don't opt in — this is what makes the Quark-only satellite provider possible +for those that do). Note the v1 limitation: `IPersistentActivationMemory`/`[PersistentState]` are not +yet supported for opted-in behaviors. + +- [ ] **Step 4: Final full-repo verification** + +Run: `dotnet build Quark.slnx && dotnet test Quark.slnx` +Expected: Both succeed cleanly. + +- [ ] **Step 5: Commit** + +```bash +git add FEATURES.md wiki/Source-Generators.md +git commit -m "Document IGrainUserServiceProviderFactory in FEATURES.md and Source-Generators wiki" +``` diff --git a/docs/superpowers/plans/2026-07-10-journaledgrain-snapshotting.md b/docs/superpowers/plans/2026-07-10-journaledgrain-snapshotting.md new file mode 100644 index 0000000..3e9d044 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-journaledgrain-snapshotting.md @@ -0,0 +1,1053 @@ +# JournaledGrain Snapshotting Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give `JournaledGrain` an optional snapshot mechanism so activation replays only the events after the latest snapshot instead of the entire log from version 0. + +**Architecture:** A new dedicated `ISnapshotStore` abstraction (separate from `ILogStorage`/`IGrainStorage`) stores `(version, state)` snapshots. `JournaledGrain` writes a snapshot automatically every N confirmed events (plus a manual hook), and on activation seeds state from the snapshot then replays only the tail. The event log stays the sole source of truth: a missing snapshot triggers a full replay; a present-but-broken snapshot (undeserializable, or version ahead of the log) throws `CorruptSnapshotException` (fail-fast), recoverable via `ClearSnapshotAsync`. + +**Tech Stack:** C# / .NET 10, xUnit, Quark serialization deep-copiers (`ICopierProvider`/`IDeepCopier`), `Microsoft.Extensions.DependencyInjection`. + +**Spec:** `docs/superpowers/specs/2026-07-10-journaledgrain-snapshotting-design.md` + +## Global Constraints + +- Target framework: `net10.0`. SDK pinned to `10.0.201` (`global.json`). +- No `Version=` on `` — versions are centralized in `Directory.Packages.props`. +- AOT/trim safe: prefer source generation over reflection; every production package has `IsTrimmable=true` / `EnableAotAnalyzer=true` and `TreatWarningsAsErrors`. Do not introduce new reflection. +- `Quark.Persistence.Abstractions` holds abstractions only; concrete providers live in `Quark.Persistence.InMemory`. +- The event log is the sole source of truth; a snapshot must never change the replayed result. Missing snapshot → full replay (not an error). Present-but-broken → `CorruptSnapshotException`. +- In test projects the code generators do NOT run — hand-write any `IDeepCopier` a test needs and register it in DI. +- Commit message style: `Component: imperative summary` (match recent history, e.g. `CodecProvider: fix ...`). End every commit body with: + `Co-Authored-By: Claude Opus 4.8 (1M context) ` +- Build the whole solution with `dotnet build Quark.slnx`; run the touched unit tests with the filters shown per task. + +--- + +### Task 1: `ISnapshotStore` abstraction + envelope + exception + +**Files:** +- Create: `src/Quark.Persistence.Abstractions/Journaling/ISnapshotStore.cs` +- Test: `tests/Quark.Tests.Unit/Journaling/SnapshotEnvelopeTests.cs` + +**Interfaces:** +- Produces: + - `interface ISnapshotStore` with `Task?> ReadSnapshotAsync(GrainId, CancellationToken) where TState : class`, `Task WriteSnapshotAsync(GrainId, SnapshotEnvelope, CancellationToken) where TState : class`, `Task ClearSnapshotAsync(GrainId, CancellationToken)`. + - `sealed class SnapshotEnvelope` with ctor `(int version, TState state)`, `int Version { get; }`, `TState State { get; }`. + - `sealed class CorruptSnapshotException : Exception` with ctor `(GrainId grainId, int snapshotVersion, string message, Exception? inner = null)`, `GrainId GrainId { get; }`, `int SnapshotVersion { get; }`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/Quark.Tests.Unit/Journaling/SnapshotEnvelopeTests.cs`: + +```csharp +using Quark.Core.Abstractions.Identity; +using Quark.Persistence.Abstractions.Journaling; +using Xunit; + +namespace Quark.Tests.Unit.Journaling; + +public sealed class SnapshotEnvelopeTests +{ + private sealed class State { public int N { get; set; } } + + [Fact] + public void SnapshotEnvelope_ExposesVersionAndState() + { + var s = new State { N = 7 }; + var env = new SnapshotEnvelope(3, s); + Assert.Equal(3, env.Version); + Assert.Same(s, env.State); + } + + [Fact] + public void CorruptSnapshotException_CarriesGrainIdAndVersion() + { + var id = new GrainId(new GrainType("G"), "k"); + var ex = new CorruptSnapshotException(id, 42, "boom"); + Assert.Equal(id, ex.GrainId); + Assert.Equal(42, ex.SnapshotVersion); + Assert.Contains("boom", ex.Message); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet build tests/Quark.Tests.Unit/Quark.Tests.Unit.csproj` +Expected: FAIL — `ISnapshotStore` / `SnapshotEnvelope` / `CorruptSnapshotException` do not exist (CS0246). + +- [ ] **Step 3: Write minimal implementation** + +Create `src/Quark.Persistence.Abstractions/Journaling/ISnapshotStore.cs`: + +```csharp +using Quark.Core.Abstractions.Identity; + +namespace Quark.Persistence.Abstractions.Journaling; + +/// +/// Optional snapshot store for . A snapshot is a +/// replay-shortcut only; the event log remains the source of truth. A missing snapshot is +/// normal (activation full-replays). A present-but-corrupt snapshot must surface as a +/// rather than silently producing wrong state. +/// +public interface ISnapshotStore +{ + /// + /// Reads the latest snapshot for , or null if none exists. + /// Durable providers throw when a stored snapshot + /// cannot be deserialized into . + /// + Task?> ReadSnapshotAsync( + GrainId grainId, CancellationToken ct = default) where TState : class; + + /// Writes (replaces) the snapshot for . + Task WriteSnapshotAsync( + GrainId grainId, SnapshotEnvelope snapshot, CancellationToken ct = default) + where TState : class; + + /// Deletes any stored snapshot for (recovery path). + Task ClearSnapshotAsync(GrainId grainId, CancellationToken ct = default); +} + +/// A point-in-time projection of grain state and the log version it folds up to. +public sealed class SnapshotEnvelope +{ + public SnapshotEnvelope(int version, TState state) + { + Version = version; + State = state; + } + + /// Number of events folded into — i.e. the index of the next event. + public int Version { get; } + + /// State after applying events [0, Version). + public TState State { get; } +} + +/// Thrown when a present snapshot is unusable (undeserializable or inconsistent with the log). +public sealed class CorruptSnapshotException : Exception +{ + public CorruptSnapshotException(GrainId grainId, int snapshotVersion, string message, Exception? inner = null) + : base(message, inner) + { + GrainId = grainId; + SnapshotVersion = snapshotVersion; + } + + /// The grain whose snapshot is corrupt. + public GrainId GrainId { get; } + + /// The version stamped on the offending snapshot. + public int SnapshotVersion { get; } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test tests/Quark.Tests.Unit/Quark.Tests.Unit.csproj --filter "FullyQualifiedName~SnapshotEnvelopeTests"` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/Quark.Persistence.Abstractions/Journaling/ISnapshotStore.cs \ + tests/Quark.Tests.Unit/Journaling/SnapshotEnvelopeTests.cs +git commit -m "$(cat <<'EOF' +ISnapshotStore: add snapshot-store abstraction for JournaledGrain + +Introduces ISnapshotStore, SnapshotEnvelope, and +CorruptSnapshotException in Quark.Persistence.Abstractions.Journaling as +the foundation for JournaledGrain log snapshotting (#144). + +Co-Authored-By: Claude Opus 4.8 (1M context) +EOF +)" +``` + +--- + +### Task 2: `InMemorySnapshotStore` + DI registration + +**Files:** +- Create: `src/Quark.Persistence.InMemory/InMemorySnapshotStore.cs` +- Create: `src/Quark.Persistence.InMemory/InMemorySnapshotStoreServiceCollectionExtensions.cs` +- Test: `tests/Quark.Tests.Unit/Journaling/InMemorySnapshotStoreTests.cs` + +**Interfaces:** +- Consumes: `ISnapshotStore`, `SnapshotEnvelope` (Task 1); `ICopierProvider`/`IDeepCopier`/`CopyContext` (`Quark.Serialization.Abstractions.Abstractions`). +- Produces: + - `sealed class InMemorySnapshotStore : ISnapshotStore` with ctor `(ICopierProvider copiers)`. + - `static class InMemorySnapshotStoreServiceCollectionExtensions` with `IServiceCollection AddInMemorySnapshotStore(this IServiceCollection services)`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/Quark.Tests.Unit/Journaling/InMemorySnapshotStoreTests.cs`: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using Quark.Core.Abstractions.Identity; +using Quark.Persistence.Abstractions.Journaling; +using Quark.Persistence.InMemory; +using Quark.Serialization; +using Quark.Serialization.Abstractions.Abstractions; +using Xunit; + +namespace Quark.Tests.Unit.Journaling; + +public sealed class InMemorySnapshotStoreTests +{ + // Snapshotted state needs a deep copier. Generators don't run in test projects, so hand-write one. + public sealed class Bag + { + public int N { get; set; } + public List Items { get; set; } = []; + } + + private sealed class BagCopier : IDeepCopier + { + public Bag DeepCopy(Bag original, CopyContext context) => + new() { N = original.N, Items = [.. original.Items] }; + } + + private static (InMemorySnapshotStore Store, GrainId Id) NewStore() + { + var services = new ServiceCollection(); + services.AddQuarkSerialization(); + services.AddSingleton>(new BagCopier()); + var sp = services.BuildServiceProvider(); + var store = new InMemorySnapshotStore(sp.GetRequiredService()); + return (store, new GrainId(new GrainType("G"), "k")); + } + + [Fact] + public async Task ReadSnapshotAsync_ReturnsNull_WhenMissing() + { + (InMemorySnapshotStore store, GrainId id) = NewStore(); + Assert.Null(await store.ReadSnapshotAsync(id)); + } + + [Fact] + public async Task WriteThenRead_RoundTripsVersionAndState() + { + (InMemorySnapshotStore store, GrainId id) = NewStore(); + await store.WriteSnapshotAsync(id, new SnapshotEnvelope(5, new Bag { N = 9, Items = ["a"] })); + + SnapshotEnvelope? read = await store.ReadSnapshotAsync(id); + Assert.NotNull(read); + Assert.Equal(5, read!.Version); + Assert.Equal(9, read.State.N); + Assert.Equal(new[] { "a" }, read.State.Items); + } + + [Fact] + public async Task Write_IsolatesFromLaterMutationOfOriginal() + { + (InMemorySnapshotStore store, GrainId id) = NewStore(); + var live = new Bag { N = 1, Items = ["x"] }; + await store.WriteSnapshotAsync(id, new SnapshotEnvelope(1, live)); + + live.N = 99; // mutate the live state after the snapshot was taken + live.Items.Add("y"); + + SnapshotEnvelope? read = await store.ReadSnapshotAsync(id); + Assert.Equal(1, read!.State.N); + Assert.Equal(new[] { "x" }, read.State.Items); + } + + [Fact] + public async Task Read_IsolatesStoredCopyFromCallerMutation() + { + (InMemorySnapshotStore store, GrainId id) = NewStore(); + await store.WriteSnapshotAsync(id, new SnapshotEnvelope(1, new Bag { N = 1, Items = ["x"] })); + + SnapshotEnvelope? first = await store.ReadSnapshotAsync(id); + first!.State.N = 42; // caller mutates the returned copy + first.State.Items.Add("z"); + + SnapshotEnvelope? second = await store.ReadSnapshotAsync(id); + Assert.Equal(1, second!.State.N); + Assert.Equal(new[] { "x" }, second.State.Items); + } + + [Fact] + public async Task ClearSnapshotAsync_RemovesSnapshot() + { + (InMemorySnapshotStore store, GrainId id) = NewStore(); + await store.WriteSnapshotAsync(id, new SnapshotEnvelope(1, new Bag { N = 1 })); + await store.ClearSnapshotAsync(id); + Assert.Null(await store.ReadSnapshotAsync(id)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet build tests/Quark.Tests.Unit/Quark.Tests.Unit.csproj` +Expected: FAIL — `InMemorySnapshotStore` does not exist (CS0246). + +- [ ] **Step 3: Write minimal implementation** + +Create `src/Quark.Persistence.InMemory/InMemorySnapshotStore.cs`: + +```csharp +using System.Collections.Concurrent; +using Quark.Core.Abstractions.Identity; +using Quark.Persistence.Abstractions.Journaling; +using Quark.Serialization.Abstractions.Abstractions; + +namespace Quark.Persistence.InMemory; + +/// +/// In-memory for development and tests. State is deep-copied on +/// both write and read to isolate the stored snapshot from the grain's live, still-mutating +/// state (the same isolation applies). Not durable across +/// process restarts, so it never produces the undeserializable-snapshot failure mode. +/// +public sealed class InMemorySnapshotStore : ISnapshotStore +{ + private readonly ConcurrentDictionary _snapshots = new(); + private readonly ICopierProvider _copiers; + + /// Initializes the in-memory snapshot store. + public InMemorySnapshotStore(ICopierProvider copiers) => _copiers = copiers; + + /// + public Task WriteSnapshotAsync( + GrainId grainId, SnapshotEnvelope snapshot, CancellationToken ct = default) + where TState : class + { + ct.ThrowIfCancellationRequested(); + TState isolated = _copiers.GetRequiredCopier().DeepCopy(snapshot.State, new CopyContext()); + _snapshots[grainId] = (snapshot.Version, isolated); + return Task.CompletedTask; + } + + /// + public Task?> ReadSnapshotAsync( + GrainId grainId, CancellationToken ct = default) + where TState : class + { + ct.ThrowIfCancellationRequested(); + if (!_snapshots.TryGetValue(grainId, out (int Version, object State) entry)) + return Task.FromResult?>(null); + + TState copy = _copiers.GetRequiredCopier().DeepCopy((TState)entry.State, new CopyContext()); + return Task.FromResult?>(new SnapshotEnvelope(entry.Version, copy)); + } + + /// + public Task ClearSnapshotAsync(GrainId grainId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + _snapshots.TryRemove(grainId, out _); + return Task.CompletedTask; + } +} +``` + +Create `src/Quark.Persistence.InMemory/InMemorySnapshotStoreServiceCollectionExtensions.cs`: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Quark.Persistence.Abstractions.Journaling; + +namespace Quark.Persistence.InMemory; + +/// Service registration helpers for the in-memory snapshot store. +public static class InMemorySnapshotStoreServiceCollectionExtensions +{ + /// + /// Registers the in-memory . Once registered, every + /// with a positive SnapshotInterval + /// writes snapshots and replays only post-snapshot events on activation. + /// + public static IServiceCollection AddInMemorySnapshotStore(this IServiceCollection services) + { + services.TryAddSingleton(); + return services; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test tests/Quark.Tests.Unit/Quark.Tests.Unit.csproj --filter "FullyQualifiedName~InMemorySnapshotStoreTests"` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/Quark.Persistence.InMemory/InMemorySnapshotStore.cs \ + src/Quark.Persistence.InMemory/InMemorySnapshotStoreServiceCollectionExtensions.cs \ + tests/Quark.Tests.Unit/Journaling/InMemorySnapshotStoreTests.cs +git commit -m "$(cat <<'EOF' +InMemorySnapshotStore: add in-memory ISnapshotStore provider + +Deep-copies state on write and read (via ICopierProvider) to isolate the +stored snapshot from the grain's live state, matching InMemoryGrainStorage +isolation. Adds AddInMemorySnapshotStore() DI helper. (#144) + +Co-Authored-By: Claude Opus 4.8 (1M context) +EOF +)" +``` + +--- + +### Task 3: `JournaledGrain` write path — auto snapshot every N events + manual hook + +**Files:** +- Modify: `src/Quark.Persistence.Abstractions/Journaling/JournaledGrainState.cs` +- Modify: `src/Quark.Persistence.Abstractions/Journaling/JournaledGrain.cs` +- Test: `tests/Quark.Tests.Unit/Journaling/JournaledGrainSnapshotTests.cs` + +**Interfaces:** +- Consumes: `ISnapshotStore`, `SnapshotEnvelope` (Task 1). +- Produces (for Task 4 and tests): + - `JournaledGrain` ctor gains 4th optional param `ISnapshotStore? snapshotStore = null`. + - `protected virtual int SnapshotInterval => 100;` (0 disables). + - `protected Task WriteSnapshotAsync(CancellationToken cancellationToken = default)` — manual snapshot; no-op when no store. + - `JournaledGrainState` gains `int LastSnapshotVersion { get; set; }`. + - Test helpers in the new test file: `sealed class FakeSnapshotStore : ISnapshotStore` (records writes in `List<(GrainId Id, int Version)> Writes`, `Seed(GrainId, SnapshotEnvelope)`, optional `Func? ReadThrows`); `sealed class CounterState { public int Count { get; set; } }`; `abstract record CounterEvent` + `sealed record Bumped : CounterEvent`; `sealed class CounterGrain : JournaledGrain`; `FixedCallContext`; `ActivateAsync(...)` helper. + +- [ ] **Step 1: Write the failing test** + +Create `tests/Quark.Tests.Unit/Journaling/JournaledGrainSnapshotTests.cs`: + +```csharp +using Quark.Core.Abstractions.Grains; +using Quark.Core.Abstractions.Hosting; +using Quark.Core.Abstractions.Identity; +using Quark.Persistence.Abstractions; +using Quark.Persistence.Abstractions.Journaling; +using Quark.Persistence.InMemory; +using Xunit; + +namespace Quark.Tests.Unit.Journaling; + +public sealed class JournaledGrainSnapshotTests +{ + // ---- Write-path tests (Task 3) ---- + + [Fact] + public async Task ConfirmEvents_WritesSnapshot_WhenIntervalReached() + { + var snap = new FakeSnapshotStore(); + CounterGrain g = await ActivateAsync(new InMemoryLogStorage(), snap, interval: 3, NewId()); + + g.Bump(); g.Bump(); g.Bump(); + await g.SaveAsync(); // ConfirmedVersion 0 -> 3 + + Assert.Single(snap.Writes); + Assert.Equal(3, snap.Writes[0].Version); + } + + [Fact] + public async Task ConfirmEvents_DoesNotSnapshot_BelowInterval() + { + var snap = new FakeSnapshotStore(); + CounterGrain g = await ActivateAsync(new InMemoryLogStorage(), snap, interval: 3, NewId()); + + g.Bump(); g.Bump(); + await g.SaveAsync(); // ConfirmedVersion 0 -> 2 + + Assert.Empty(snap.Writes); + } + + [Fact] + public async Task SnapshotInterval_Zero_DisablesAutoSnapshot() + { + var snap = new FakeSnapshotStore(); + CounterGrain g = await ActivateAsync(new InMemoryLogStorage(), snap, interval: 0, NewId()); + + for (int i = 0; i < 5; i++) g.Bump(); + await g.SaveAsync(); + + Assert.Empty(snap.Writes); + } + + [Fact] + public async Task WriteSnapshotAsync_Manual_WritesAtCurrentVersion() + { + var snap = new FakeSnapshotStore(); + CounterGrain g = await ActivateAsync(new InMemoryLogStorage(), snap, interval: 0, NewId()); + + g.Bump(); g.Bump(); + await g.SaveAsync(); // version 2, no auto snapshot (interval 0) + await g.SnapshotNowAsync(); + + Assert.Single(snap.Writes); + Assert.Equal(2, snap.Writes[0].Version); + } + + [Fact] + public async Task WriteSnapshotAsync_NoStore_IsNoOp() + { + CounterGrain g = await ActivateAsync(new InMemoryLogStorage(), snapshotStore: null, interval: 3, NewId()); + g.Bump(); + await g.SaveAsync(); + await g.SnapshotNowAsync(); // must not throw + Assert.Equal(1, g.Version); + } + + // ---- Shared helpers ---- + + private static GrainId NewId() => new(new GrainType("CounterGrain"), Guid.NewGuid().ToString("N")); + + private static async Task ActivateAsync( + ILogStorage? log, ISnapshotStore? snapshotStore, int interval, GrainId id) + { + var holder = new StateHolder>(); + var memory = new ActivationMemoryAccessor>(holder); + var grain = new CounterGrain(memory, new FixedCallContext(id), log, snapshotStore, interval); + await grain.OnActivateAsync(CancellationToken.None); + return grain; + } + + public sealed class CounterState { public int Count { get; set; } } + + public abstract record CounterEvent; + public sealed record Bumped : CounterEvent; + + public sealed class CounterGrain : JournaledGrain + { + private readonly int _interval; + + public CounterGrain( + IActivationMemory> memory, + ICallContext ctx, + ILogStorage? log, + ISnapshotStore? snapshotStore, + int interval) + : base(memory, ctx, log, snapshotStore) + => _interval = interval; + + protected override int SnapshotInterval => _interval; + + public new CounterState State => base.State; + public new int Version => base.Version; + + public void Bump() => RaiseEvent(new Bumped()); + public Task SaveAsync() => ConfirmEventsAsync(); + public Task SnapshotNowAsync() => WriteSnapshotAsync(); + + protected override void TransitionState(CounterState state, CounterEvent @event) => state.Count++; + } + + private sealed class FixedCallContext(GrainId grainId) : ICallContext + { + public GrainId GrainId => grainId; + } + + private sealed class FakeSnapshotStore : ISnapshotStore + { + private readonly Dictionary _snaps = []; + public List<(GrainId Id, int Version)> Writes { get; } = []; + public Func? ReadThrows { get; set; } + + public void Seed(GrainId id, SnapshotEnvelope snap) where TState : class + => _snaps[id] = snap; + + public Task?> ReadSnapshotAsync( + GrainId grainId, CancellationToken ct = default) where TState : class + { + if (ReadThrows?.Invoke(grainId) is { } ex) throw ex; + return Task.FromResult(_snaps.TryGetValue(grainId, out object? s) + ? (SnapshotEnvelope?)s + : null); + } + + public Task WriteSnapshotAsync( + GrainId grainId, SnapshotEnvelope snapshot, CancellationToken ct = default) + where TState : class + { + Writes.Add((grainId, snapshot.Version)); + _snaps[grainId] = snapshot; + return Task.CompletedTask; + } + + public Task ClearSnapshotAsync(GrainId grainId, CancellationToken ct = default) + { + _snaps.Remove(grainId); + return Task.CompletedTask; + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet build tests/Quark.Tests.Unit/Quark.Tests.Unit.csproj` +Expected: FAIL — `JournaledGrain` has no 4-arg ctor / no `SnapshotInterval` / no `WriteSnapshotAsync`; `JournaledGrainState` has no `LastSnapshotVersion` (CS1729 / CS1061). + +- [ ] **Step 3: Write minimal implementation** + +In `src/Quark.Persistence.Abstractions/Journaling/JournaledGrainState.cs`, add one property to the class body: + +```csharp + /// The captured by the most recent snapshot write. + public int LastSnapshotVersion { get; set; } +``` + +In `src/Quark.Persistence.Abstractions/Journaling/JournaledGrain.cs`: + +Add the field next to `_logStorage`: + +```csharp + private ISnapshotStore? _snapshotStore; +``` + +Replace the constructor with: + +```csharp + protected JournaledGrain( + IActivationMemory> memory, + ICallContext ctx, + ILogStorage? logStorage = null, + ISnapshotStore? snapshotStore = null) + { + _memory = memory; + _ctx = ctx; + _logStorage = logStorage; + _snapshotStore = snapshotStore; + } +``` + +Add the cadence property (place it near the other `protected` members, e.g. after `State`): + +```csharp + /// + /// Number of confirmed events between automatic snapshots. Override per grain type. + /// 0 disables automatic snapshotting for this grain type. Default: 100. + /// Automatic snapshots require a registered . + /// + protected virtual int SnapshotInterval => 100; +``` + +In `ConfirmEventsAsync`, after `st.StagedEvents.Clear();`, append: + +```csharp + if (_snapshotStore is not null && SnapshotInterval > 0 && + st.ConfirmedVersion - st.LastSnapshotVersion >= SnapshotInterval) + { + await WriteSnapshotCoreAsync(cancellationToken).ConfigureAwait(false); + } +``` + +Add the two snapshot-write methods (e.g. right after `ConfirmEventsAsync`): + +```csharp + /// + /// Writes a snapshot of the current confirmed state to the registered + /// . No-op when no snapshot store is registered. + /// + protected Task WriteSnapshotAsync(CancellationToken cancellationToken = default) => + _snapshotStore is null ? Task.CompletedTask : WriteSnapshotCoreAsync(cancellationToken); + + private async Task WriteSnapshotCoreAsync(CancellationToken ct) + { + JournaledGrainState st = _memory.Value; + await _snapshotStore! + .WriteSnapshotAsync(GrainId, new SnapshotEnvelope(st.ConfirmedVersion, st.State), ct) + .ConfigureAwait(false); + st.LastSnapshotVersion = st.ConfirmedVersion; + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test tests/Quark.Tests.Unit/Quark.Tests.Unit.csproj --filter "FullyQualifiedName~JournaledGrainSnapshotTests"` +Expected: PASS (5 tests). + +- [ ] **Step 5: Run the existing JournaledGrain tests to confirm no regression** + +Run: `dotnet test tests/Quark.Tests.Unit/Quark.Tests.Unit.csproj --filter "FullyQualifiedName~JournaledGrainTests"` +Expected: PASS (existing 6 tests — the new optional ctor param is backward-compatible). + +- [ ] **Step 6: Commit** + +```bash +git add src/Quark.Persistence.Abstractions/Journaling/JournaledGrain.cs \ + src/Quark.Persistence.Abstractions/Journaling/JournaledGrainState.cs \ + tests/Quark.Tests.Unit/Journaling/JournaledGrainSnapshotTests.cs +git commit -m "$(cat <<'EOF' +JournaledGrain: write snapshots every N confirmed events + manual hook + +Adds an optional ISnapshotStore ctor dependency, a per-type SnapshotInterval +(default 100, 0 disables), and a protected WriteSnapshotAsync() hook. +ConfirmEventsAsync writes a snapshot once ConfirmedVersion advances a full +interval past the last snapshot. (#144) + +Co-Authored-By: Claude Opus 4.8 (1M context) +EOF +)" +``` + +--- + +### Task 4: `JournaledGrain` activation path — snapshot-aware replay + fail-fast + recovery + +**Files:** +- Modify: `src/Quark.Persistence.Abstractions/Journaling/JournaledGrain.cs` (`ReloadFromLogAsync`) +- Test: `tests/Quark.Tests.Unit/Journaling/JournaledGrainSnapshotTests.cs` (add activation tests + `SpyLogStorage`) + +**Interfaces:** +- Consumes: everything from Task 3, plus `LogEntry` / `ILogStorage` (`Quark.Persistence.Abstractions.Journaling`). +- Produces: snapshot-aware `ReloadFromLogAsync`; a `SpyLogStorage` test decorator recording `(int From, int To)` reads. + +- [ ] **Step 1: Write the failing test** + +Append these members to the existing `JournaledGrainSnapshotTests` class (before the `// ---- Shared helpers ----` marker): + +```csharp + // ---- Activation-path tests (Task 4) ---- + + [Fact] + public async Task Activation_WithSnapshot_ReplaysOnlyTail() + { + var log = new InMemoryLogStorage(); + var snap = new FakeSnapshotStore(); + GrainId id = NewId(); + + // Seed 5 confirmed events (interval 0 → no auto snapshot to keep the log clean). + CounterGrain seed = await ActivateAsync(log, snap, interval: 0, id); + for (int i = 0; i < 5; i++) seed.Bump(); + await seed.SaveAsync(); + + // Hand-seed a snapshot at version 3 (Count == 3). + snap.Seed(id, new SnapshotEnvelope(3, new CounterState { Count = 3 })); + + var spy = new SpyLogStorage(log); + CounterGrain reactivated = await ActivateAsync(spy, snap, interval: 0, id); + + Assert.Equal(5, reactivated.Version); + Assert.Equal(5, reactivated.State.Count); + // Boundary probe reads from snapshot.Version - 1 (== 2), NOT from 0. + Assert.Equal(2, spy.Reads[0].From); + } + + [Fact] + public async Task Activation_NoSnapshot_FullReplayFromZero() + { + var log = new InMemoryLogStorage(); + var snap = new FakeSnapshotStore(); + GrainId id = NewId(); + + CounterGrain seed = await ActivateAsync(log, snap, interval: 0, id); + for (int i = 0; i < 4; i++) seed.Bump(); + await seed.SaveAsync(); + + var spy = new SpyLogStorage(log); + CounterGrain reactivated = await ActivateAsync(spy, snap, interval: 0, id); // no snapshot seeded + + Assert.Equal(4, reactivated.State.Count); + Assert.Equal(0, spy.Reads[0].From); // full replay from 0 + } + + [Fact] + public async Task Activation_SnapshotAheadOfLog_Throws() + { + var log = new InMemoryLogStorage(); + var snap = new FakeSnapshotStore(); + GrainId id = NewId(); + + CounterGrain seed = await ActivateAsync(log, snap, interval: 0, id); + seed.Bump(); seed.Bump(); seed.Bump(); + await seed.SaveAsync(); // log has 3 entries + + snap.Seed(id, new SnapshotEnvelope(5, new CounterState { Count = 5 })); // ahead of log + + CorruptSnapshotException ex = await Assert.ThrowsAsync( + () => ActivateAsync(log, snap, interval: 0, id)); + Assert.Equal(id, ex.GrainId); + Assert.Equal(5, ex.SnapshotVersion); + } + + [Fact] + public async Task Activation_StoreThrowsCorrupt_Propagates() + { + var log = new InMemoryLogStorage(); + var snap = new FakeSnapshotStore(); + GrainId id = NewId(); + snap.ReadThrows = gid => new CorruptSnapshotException(gid, 1, "undeserializable"); + + await Assert.ThrowsAsync( + () => ActivateAsync(log, snap, interval: 0, id)); + } + + [Fact] + public async Task Activation_AfterClear_RecoversViaFullReplay() + { + var log = new InMemoryLogStorage(); + var snap = new FakeSnapshotStore(); + GrainId id = NewId(); + + CounterGrain seed = await ActivateAsync(log, snap, interval: 0, id); + seed.Bump(); seed.Bump(); seed.Bump(); + await seed.SaveAsync(); // log has 3 entries + snap.Seed(id, new SnapshotEnvelope(5, new CounterState { Count = 5 })); + + await Assert.ThrowsAsync( + () => ActivateAsync(log, snap, interval: 0, id)); // bricked + + await snap.ClearSnapshotAsync(id); // recovery + + CounterGrain recovered = await ActivateAsync(log, snap, interval: 0, id); + Assert.Equal(3, recovered.Version); + Assert.Equal(3, recovered.State.Count); + } +``` + +Also add the `SpyLogStorage` decorator inside the test class (e.g. after `FakeSnapshotStore`): + +```csharp + private sealed class SpyLogStorage(ILogStorage inner) : ILogStorage + { + public List<(int From, int To)> Reads { get; } = []; + + public Task> ReadEntriesAsync( + GrainId grainId, int fromVersion, int toVersion, CancellationToken ct = default) + { + Reads.Add((fromVersion, toVersion)); + return inner.ReadEntriesAsync(grainId, fromVersion, toVersion, ct); + } + + public Task AppendEntriesAsync( + GrainId grainId, int expectedVersion, IReadOnlyList entries, CancellationToken ct = default) + => inner.AppendEntriesAsync(grainId, expectedVersion, entries, ct); + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/Quark.Tests.Unit/Quark.Tests.Unit.csproj --filter "FullyQualifiedName~JournaledGrainSnapshotTests"` +Expected: FAIL — `Activation_WithSnapshot_ReplaysOnlyTail` and the other new tests fail (current `ReloadFromLogAsync` ignores snapshots: it always reads from 0 and never throws). + +- [ ] **Step 3: Write minimal implementation** + +Replace `ReloadFromLogAsync` in `src/Quark.Persistence.Abstractions/Journaling/JournaledGrain.cs` with: + +```csharp + private async Task ReloadFromLogAsync(CancellationToken ct) + { + JournaledGrainState st = _memory.Value; + st.State = new TState(); + st.ConfirmedVersion = 0; + st.LastSnapshotVersion = 0; + + if (_snapshotStore is not null) + { + SnapshotEnvelope? snap = + await _snapshotStore.ReadSnapshotAsync(GrainId, ct).ConfigureAwait(false); + + if (snap is not null && snap.Version > 0) + { + // Boundary probe: read from snap.Version - 1 so we can confirm the log actually + // contains >= snap.Version contiguous entries WITHOUT a length API on ILogStorage + // (AppendEntriesAsync guarantees version == index, so entry[V-1] existing => 0..V-1 exist). + IReadOnlyList tail = await _logStorage! + .ReadEntriesAsync(GrainId, snap.Version - 1, int.MaxValue, ct).ConfigureAwait(false); + + if (tail.Count == 0 || tail[0].Version != snap.Version - 1) + throw new CorruptSnapshotException(GrainId, snap.Version, + $"Snapshot version {snap.Version} is ahead of the event log for grain {GrainId}."); + + st.State = snap.State; // store returned an isolated copy + st.ConfirmedVersion = snap.Version; + st.LastSnapshotVersion = snap.Version; + + for (int i = 1; i < tail.Count; i++) // skip the boundary entry (already in the snapshot) + { + TransitionState(st.State, (TEvent)tail[i].Event); + st.ConfirmedVersion = tail[i].Version + 1; + } + + return; + } + } + + // No usable snapshot: full replay from 0 (original behavior). + IReadOnlyList all = + await _logStorage!.ReadEntriesAsync(GrainId, 0, int.MaxValue, ct).ConfigureAwait(false); + foreach (LogEntry entry in all) + { + TransitionState(st.State, (TEvent)entry.Event); + st.ConfirmedVersion = entry.Version + 1; + } + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test tests/Quark.Tests.Unit/Quark.Tests.Unit.csproj --filter "FullyQualifiedName~JournaledGrainSnapshotTests"` +Expected: PASS (10 tests total — 5 write-path + 5 activation-path). + +- [ ] **Step 5: Run full unit suite for the persistence area** + +Run: `dotnet test tests/Quark.Tests.Unit/Quark.Tests.Unit.csproj --filter "FullyQualifiedName~Journaling"` +Expected: PASS (all Journaling tests: `SnapshotEnvelopeTests`, `InMemorySnapshotStoreTests`, `JournaledGrainTests`, `JournaledGrainSnapshotTests`). + +- [ ] **Step 6: Commit** + +```bash +git add src/Quark.Persistence.Abstractions/Journaling/JournaledGrain.cs \ + tests/Quark.Tests.Unit/Journaling/JournaledGrainSnapshotTests.cs +git commit -m "$(cat <<'EOF' +JournaledGrain: seed activation from snapshot, replay only the tail + +On activation, reads the latest snapshot and replays only events after its +version. A missing snapshot full-replays from 0 (unchanged). A snapshot whose +version is ahead of the log throws CorruptSnapshotException (fail-fast); +recovery is via ISnapshotStore.ClearSnapshotAsync. (#144) + +Co-Authored-By: Claude Opus 4.8 (1M context) +EOF +)" +``` + +--- + +### Task 5: Bank sample — showcase snapshotting on the ledger + +**Files:** +- Modify: `samples/Persistence/Bank.Grains/LedgerState.cs` +- Modify: `samples/Persistence/Bank.Grains/BankStateCopiers.cs` +- Modify: `samples/Persistence/Bank.Grains/LedgerBehavior.cs` +- Modify: `samples/Persistence/Bank.Server/Program.cs` + +**Interfaces:** +- Consumes: `ISnapshotStore` (Task 1), `AddInMemorySnapshotStore` (Task 2), `JournaledGrain` snapshot API (Tasks 3-4). +- Produces: a runnable sample where the ledger snapshots every 5 events. (No automated test — samples are verified by building; the behavior is covered by Task 4's unit tests.) + +- [ ] **Step 1: Make `LedgerState` serializable so it can be snapshotted** + +Replace `samples/Persistence/Bank.Grains/LedgerState.cs` with: + +```csharp +using Quark.Serialization.Abstractions.Attributes; + +namespace Bank.Grains; + +/// +/// Projection for , rebuilt by replaying s. +/// [GenerateSerializer] lets the in-memory ISnapshotStore deep-copy it so activation +/// can replay only post-snapshot events instead of the whole log. +/// +[GenerateSerializer] +public sealed class LedgerState +{ + [Id(0)] public decimal Balance { get; set; } + [Id(1)] public List History { get; set; } = []; +} + +/// Base type for ledger events. Events are the source of truth, persisted to the log. +public abstract record LedgerEvent; + +/// Money paid into the ledger. +public sealed record Credited(decimal Amount, string Note) : LedgerEvent; + +/// Money paid out of the ledger. +public sealed record Debited(decimal Amount, string Note) : LedgerEvent; +``` + +- [ ] **Step 2: Register the generated `IDeepCopier`** + +In `samples/Persistence/Bank.Grains/BankStateCopiers.cs`, add a registration line inside `AddBankStateCopiers`, after the `ProfileState` line: + +```csharp + services.AddSingleton>( + sp => new LedgerStateCopier(sp.GetRequiredService())); +``` + +(`LedgerStateCopier` is emitted by the code generator for the `[GenerateSerializer]` type, exactly like `AccountStateCopier`/`ProfileStateCopier`.) + +- [ ] **Step 3: Forward `ISnapshotStore` and set a small interval in `LedgerBehavior`** + +In `samples/Persistence/Bank.Grains/LedgerBehavior.cs`, update the constructor and add an interval override. + +Change the constructor to accept and forward a snapshot store: + +```csharp + public LedgerBehavior( + IActivationMemory> memory, + ICallContext ctx, + ILogStorage? log = null, + ISnapshotStore? snapshot = null) + : base(memory, ctx, log, snapshot) { } + + // Snapshot every 5 confirmed events so long-lived ledgers don't replay their whole history. + protected override int SnapshotInterval => 5; +``` + +- [ ] **Step 4: Register the snapshot store in the silo** + +In `samples/Persistence/Bank.Server/Program.cs`, immediately after the existing +`silo.Services.AddSingleton();` line, add: + +```csharp + // Snapshot store — lets the JournaledGrain ledger replay only post-snapshot events. + silo.Services.AddInMemorySnapshotStore(); +``` + +- [ ] **Step 5: Build the sample to verify it compiles** + +Run: `dotnet build samples/Persistence/Bank.Server/Bank.Server.csproj` +Expected: BUILD SUCCEEDED (the generator emits `LedgerStateCopier`; the behavior forwards the snapshot store). + +- [ ] **Step 6: Commit** + +```bash +git add samples/Persistence/Bank.Grains/LedgerState.cs \ + samples/Persistence/Bank.Grains/BankStateCopiers.cs \ + samples/Persistence/Bank.Grains/LedgerBehavior.cs \ + samples/Persistence/Bank.Server/Program.cs +git commit -m "$(cat <<'EOF' +Bank sample: snapshot the JournaledGrain ledger every 5 events + +Makes LedgerState [GenerateSerializer], registers its deep copier, forwards +ISnapshotStore into LedgerBehavior with SnapshotInterval=5, and wires +AddInMemorySnapshotStore() in the silo. (#144) + +Co-Authored-By: Claude Opus 4.8 (1M context) +EOF +)" +``` + +--- + +### Task 6: Full-solution verification + +**Files:** none (verification only). + +- [ ] **Step 1: Build the whole solution** + +Run: `dotnet build Quark.slnx` +Expected: BUILD SUCCEEDED, no new warnings. + +- [ ] **Step 2: Run the full unit-test project** + +Run: `dotnet test tests/Quark.Tests.Unit/Quark.Tests.Unit.csproj` +Expected: PASS (pre-existing timing-flaky tests may need an isolated re-run; the Journaling tests must be green). + +- [ ] **Step 3: AOT publish smoke test (trim/AOT safety)** + +Run: `dotnet publish src/Quark.Runtime/Quark.Runtime.csproj -f net10.0 -c Release -r linux-x64 /p:PublishAot=true` +Expected: publish succeeds with no new trim/AOT warnings. + +- [ ] **Step 4: Final confirmation** + +Confirm all six task commits are present (`git log --oneline -6`) and the working tree is clean (`git status`). The last snapshotting commit references `#144`; do not push unless the user asks. + +--- + +## Notes / follow-ups (out of scope — see spec §9) + +- `RedisSnapshotStore` (serializing `TState` via `QuarkSerializer`) — this is where the undeserializable-snapshot `CorruptSnapshotException` path gets exercised. +- Durable Redis `ILogStorage` — a durable snapshot paired with the InMemory-only log is only half-durable. +- Optional silo-level `SnapshotOptions` default interval if per-type overrides prove insufficient. + +File a paired follow-up issue for the two durable-provider items after this plan lands. diff --git a/docs/superpowers/specs/2026-07-10-grain-user-service-provider-factory-design.md b/docs/superpowers/specs/2026-07-10-grain-user-service-provider-factory-design.md new file mode 100644 index 0000000..8c646cc --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-grain-user-service-provider-factory-design.md @@ -0,0 +1,346 @@ +# Design: Opt-in user-service-provider factory, replacing the grain-scope-initializer family + +**Issue:** #162 +**Date:** 2026-07-10 +**Status:** Draft — ready for implementation + +--- + +## 1. Problem statement + +A benchmark run against issue #162 surfaced DI overhead in the per-call activation path. +`GrainActivation.RunActivationAsync` (`src/Quark.Runtime/GrainActivation.cs:881-891`) creates a fresh +`IServiceScope` from the root `IServiceProvider` on **every single grain call**: + +```csharp +internal async Task RunActivationAsync(CancellationToken ct) +{ + using IServiceScope scope = _root.CreateScope(); + IServiceProvider sp = scope.ServiceProvider; + IGrainBehavior behavior = await GrainScopeBinder.BindAndResolveAsync(sp, this, ct).ConfigureAwait(false); + await RunEagerInitAsync(sp, ct).ConfigureAwait(false); + if (behavior is IActivationLifecycle lifecycle) + { + await lifecycle.OnActivateAsync(ct).ConfigureAwait(false); + } +} +``` + +`GrainScopeBinder.BindAndResolveAsync` (`src/Quark.Runtime/GrainScopeBinder.cs:9-27`) then, inside that +fresh scope, binds the shell accessor, sets `ICallContext`, optionally runs a registered +`GrainScopeInitializer`, and resolves the behavior via `IBehaviorResolver.Resolve` — +`BehaviorResolver.Resolve` (`src/Quark.Runtime/BehaviorResolver.cs:11-28`) calls a **compile-time-generated +factory** (`Func` from `GrainBehaviorFactoryRegistry`, +`src/Quark.Runtime/GrainBehaviorFactoryRegistry.cs`) that does explicit +`new MyBehavior(sp.GetRequiredService(), ...)` calls per constructor parameter — no reflection. + +For grain types whose behavior constructors resolve non-trivial user services (e.g. a repository backed +by a connection pool, a rules engine, anything with real construction cost), re-resolving that whole +dependency graph on every call is pure waste when the service is effectively stateless/reusable across +calls. There is currently **no way to avoid this** short of making every dependency a singleton at +registration time — which doesn't help because the *scope creation and per-call resolution* is what's +being paid for, not the singleton/scoped distinction itself. + +### Today's scope-initializer family (all removed by this spec) + +- `GrainScopeInitializer` (delegate) — `src/Quark.Core.Abstractions/Hosting/GrainScopeInitializer.cs:7-10` +- `IGrainScopeInitializerRegistry` / `GrainScopeInitializerRegistry` — + `src/Quark.Runtime/IGrainScopeInitializerRegistry.cs`, `src/Quark.Runtime/GrainScopeInitializerRegistry.cs` +- `AddGrainScopeInitializer()` — + `src/Quark.Runtime/RuntimeServiceCollectionExtensions.cs:220-237` +- `GrainScopeInitializerRegistration` (deferred marker) — `RuntimeServiceCollectionExtensions.cs:342-346` +- `SiloHostedService.ApplyScopeInitializerRegistrations()` — `src/Quark.Runtime/SiloHostedService.cs:157-168` + +This family lets a developer run a callback **inside** the already-created fresh scope — it does nothing +to avoid the scope-creation cost itself, since the scope already exists by the time the initializer runs. +It solves a different problem (populate/mutate the scope after creation) than the one this spec addresses +(skip re-resolving an expensive user dependency graph every call). Confirmed sole usages in the repo: +`tests/Quark.Tests.Unit/Runtime/GrainScopeInitializerTests.cs` and one reference in +`AddGrainBehaviorFactoryOverloadTests.cs` — no samples depend on it. + +### Conclusion + +Introduce a single, centralized, opt-in extensibility point **on the behavior class itself** that lets a +developer control how *their own* services are resolved and cached across calls, while Quark's own +framework services (`ICallContext`, `IActivationShellAccessor`, `IBehaviorResolver`, the persistence +accessors: `IActivationMemory`, `IPersistentActivationMemory`, `IManagedActivationMemory`, +`IPersistentState`) remain exclusively engine-managed, never sourced from the developer's resolver. +Remove the scope-initializer family entirely — it is superseded, not extended. + +--- + +## 2. Goals / Non-goals + +### Goals +- One opt-in mechanism, declared on the behavior class, for a developer to supply their own + long-lived provider for **their own** constructor-injected services — avoiding re-resolution of an + expensive user dependency graph on every call. +- Behaviors that don't opt in are **completely unaffected** — identical fresh-scope-per-call behavior, + zero change to registration wiring or runtime cost. +- A structural (not conventional) guarantee that Quark's own services are never resolved through the + developer-supplied provider — the split is enforced by construction, not by developer discipline. +- AOT/trim-safe: no reflection, no runtime type scanning; resolved entirely via compile-time source + generation, consistent with the rest of `Quark.CodeGenerator`. +- Remove `GrainScopeInitializer`/`IGrainScopeInitializerRegistry`/`AddGrainScopeInitializer` and their + startup-application step — one centralized mechanism replaces a family of three coupled APIs. + +### Non-goals +- **Not** eliminating per-call scope creation for Quark's own services. Quark's own per-call state is + still built from a scope (a small one — see §4) every call; the saving targeted here is skipping + re-resolution of the *user's* dependency graph, which is what the benchmark showed as the actual cost + for grains with non-trivial user dependencies. +- **Not** a general per-activation (per grain-key) customization. The factory runs once per **grain type** + at silo startup and the resulting provider is shared by every activation of that type — see §7 open + question 1 for the tradeoff this accepts. +- **Not** changing anything about placement, activation lifecycle (`OnActivateAsync`/`OnDeactivateAsync`), + or `RunEagerInitAsync` — these continue to run exactly as today, over whichever composite provider is in + effect. +- **Not** touching non-generator-based (hand-wired) behavior registration paths used in test projects + (`tests/Quark.Tests.Unit/Integration/`) — those keep constructing behaviors manually; this spec's + mechanism is generator-driven only. +- **Not** supporting `IPersistentActivationMemory` / `[PersistentState]` (`IPersistentState`) / + `ITransactionalState` / streams / reminders on opted-in behaviors in this first cut. Those all need + `IStorage`/`IGrainStorage` (or other cross-package services) registered by separate packages + (`Quark.Persistence.InMemory`, `Quark.Persistence.Redis`, etc.) via their own extension methods, which + this spec does not touch. `IActivationMemory`, `IManagedActivationMemory`, and + `IEagerActivationMemory` **are** supported — they only need the activation shell, nothing + external (§4.2). A behavior that opts into `IGrainUserServiceProviderFactory` and *also* takes one of + the unsupported types fails fast at activation with a clear `InvalidOperationException` ("Unable to + resolve service...") — not silently wrong. Extending storage/stream/reminder provider registration to + flow into the satellite provider is a natural follow-up, out of scope here (confirmed decision, not an + oversight). + +--- + +## 3. Architecture overview + +The satellite "Quark-only" provider is **not** built by teaching the generator to emit a second, +duplicated registration method. Instead, the four generator-emitted accessor calls +(`IActivationMemory`, `IManagedActivationMemory`, plus the `AddEagerActivationMemory` helper) +switch from plain `services.AddScoped(factory)` to a new `services.AddQuarkOwnedScoped(factory)` +extension that *also* drops a deferred marker recording `factory`. At startup, replaying every captured +marker onto a fresh `IServiceCollection` reconstructs an equivalent Quark-only registration set with zero +duplicated emission logic — the same deferred-marker idiom already used for +`IGrainBehaviorRegistration`/`IGrainPlacementStrategyRegistration` elsewhere in this file. + +``` +compile time (per assembly): + BehaviorRegistrationGenerator scans IGrainBehavior implementers (as today) AND additionally + checks whether each implements IGrainUserServiceProviderFactory. If so, it emits a deferred + IUserServiceProviderFactoryRegistration (GrainType → the static CreateUserServiceProvider call), + the same idiom as the removed IGrainScopeInitializerRegistration. + Separately (independent of opt-in), its IActivationMemory/IManagedActivationMemory/ + IEagerActivationMemory accessor emissions now call AddQuarkOwnedScoped instead of AddScoped — + same factory lambda, one wrapper method, so every assembly's accessors are marker-capturable. + +silo startup (SiloHostedService.StartAsync, after grain/factory/placement registrations apply): + - IUserServiceProviderRegistry is populated: for each deferred factory registration, + call TBehavior.CreateUserServiceProvider(appRoot) ONCE and cache the result by GrainType. + - IF any such registration exists, build the Quark-only satellite root: + var quarkOnly = new ServiceCollection(); + quarkOnly.AddSingleton(mainTypeRegistry); quarkOnly.AddSingleton(mainTypeRegistry); + quarkOnly.AddSingleton(mainFactoryRegistry); // SAME instances as the main root + quarkOnly.AddScoped(); ... AddScoped(); + foreach (var marker in _services.GetServices()) marker.Apply(quarkOnly); + QuarkOnlyServiceProviderHolder.Provider = quarkOnly.BuildServiceProvider(); + (skipped entirely — zero cost — for silos with no opted-in behaviors) + +per call, GrainActivation.RunActivationAsync: + registry.TryGet(GrainType, out userProvider) && holder.Provider is { } quarkRoot ? + NO → today's path, unchanged: using scope = _root.CreateScope(); + GrainScopeBinder.BindAndResolveAsync(sp, sp, this, ct) // same provider for binding + construction + YES → using quarkScope = quarkRoot.CreateScope(); // small — Quark's own types only + var composite = new CompositeServiceProvider(quarkScope.ServiceProvider, userProvider); // quark-first + GrainScopeBinder.BindAndResolveAsync(quarkScope.ServiceProvider, composite, this, ct) + RunEagerInitAsync(composite, ct); OnActivateAsync as today. +``` + +`CompositeServiceProvider` tries the Quark-only side **first**, falling back to the cached user provider. +This ordering is load-bearing, not arbitrary: if a developer's `CreateUserServiceProvider` returns +`rootServices` unchanged (a natural, common choice — "my services are already cheap to resolve from the +app root"), that root also contains Quark's own type registrations (same flat `silo.Services` collection). +Querying it first would silently resolve `ICallContext`/etc. as a captive, cross-call-shared instance +instead of Quark's real per-call one — a correctness bug, not just a missed optimization. Quark-first +resolution makes the "only user services, never Quark services" guarantee structural regardless of what +the developer's provider happens to also contain. + +**`IBehaviorResolver` changes shape to make this safe.** Today `BehaviorResolver` captures `IServiceProvider +scope` in its own constructor (`BehaviorResolver.cs:6-9`) and uses that captured instance to construct the +behavior — but when `BehaviorResolver` itself is resolved from the Quark-only scope, MS.DI would inject +*that scope's own* provider as `scope`, not the outer composite, silently starving the behavior's +user-owned constructor parameters. The fix: `IBehaviorResolver.Resolve` takes the construction provider as +an explicit parameter instead of relying on ambient constructor capture — +`IGrainBehavior Resolve(GrainType grainType, IServiceProvider services)` — so the caller always controls +which provider builds the behavior, decoupled from which provider resolved `IBehaviorResolver` itself. + +--- + +## 4. New API surface + +### 4.1 The factory interface (new, `Quark.Core.Abstractions`) + +```csharp +namespace Quark.Core.Abstractions.Hosting; + +/// +/// Opt-in, compile-time-discovered factory that supplies the IServiceProvider used to resolve a +/// behavior's OWN (non-Quark) constructor-injected services. Implemented directly on the behavior +/// class. Called once per grain type at silo startup; the returned provider is cached and shared by +/// every activation of that type for the process lifetime — see §7 open question 1. +/// +public interface IGrainUserServiceProviderFactory +{ + /// + /// The ordinary root IServiceProvider built from the silo's registered services (silo.Services). + /// Use this to pull already-registered user singletons, or return it unchanged if the developer's + /// services are already cheap/stateless to resolve from it directly. + /// + static abstract IServiceProvider CreateUserServiceProvider(IServiceProvider rootServices); +} +``` + +A behavior opts in by implementing this interface directly: + +```csharp +public sealed class MyBehavior(ICallContext ctx, IMyExpensiveRepo repo) : IMyGrain, IGrainUserServiceProviderFactory +{ + public static IServiceProvider CreateUserServiceProvider(IServiceProvider rootServices) => rootServices; + // ... +} +``` + +Static interface members require real C# polymorphism (the concrete type is known at the generic/ +compile-time call site) — no reflection is involved in dispatching to it. + +### 4.2 Generator wiring (`Quark.CodeGenerator`) + +`BehaviorRegistrationGenerator` already discovers every `IGrainBehavior` implementer per assembly at +compile time (`BehaviorRegistrationGenerator.cs`). It is extended to: + +1. Detect whether the concrete behavior type also implements `IGrainUserServiceProviderFactory`. +2. If so, emit a deferred `IUserServiceProviderFactoryRegistration` (same idiom as the removed + `IGrainScopeInitializerRegistration`) calling `TBehavior.CreateUserServiceProvider` directly — a plain + static call, not generic dispatch, since the concrete type is known at compile time. +3. Change its **existing** `IActivationMemory`/`IManagedActivationMemory` inline emissions + (`BehaviorRegistrationGenerator.cs:443-447, 464-468`) and the `AddEagerActivationMemory` helper body + (`RuntimeServiceCollectionExtensions.cs:278-287`) from `services.AddScoped(factory)` to + `services.AddQuarkOwnedScoped(factory)` — same factory lambda, new wrapper — so every assembly's + accessor registrations become replayable onto the satellite collection (§3). This applies to *every* + behavior's accessors, not just opted-in ones — harmless, since nothing consumes the marker unless at + least one behavior in the process opts in. `IPersistentActivationMemory`/`[PersistentState]` inline + emissions are intentionally **not** changed (§2 non-goal). + +### 4.3 Startup application (`Quark.Runtime`) + +`SiloHostedService.ApplyScopeInitializerRegistrations()` is replaced by `ApplyUserServiceProviderFactoryRegistrations()`, +which: +1. Populates the new `IUserServiceProviderRegistry` (`ConcurrentDictionary` — + same shape as the removed `GrainScopeInitializerRegistry`) by invoking each deferred factory once + against the app root `IServiceProvider`. +2. If at least one such registration exists, builds the Quark-only satellite root exactly as described in + §3 (fresh `ServiceCollection`, the 6 fixed core lines, the main root's *existing* + `GrainTypeRegistry`/`GrainBehaviorFactoryRegistry` **instances** registered by reference — not + rebuilt — plus every captured `IQuarkOwnedServiceRegistration` marker replayed onto it), assigning the + result to a mutable `QuarkOnlyServiceProviderHolder` singleton (registered `null` by default in + `AddQuarkRuntime()`) so `GrainActivation` can read it without any constructor signature change. The + satellite provider is disposed in `SiloHostedService.StopAsync` alongside existing teardown. + +### 4.4 Runtime call flow (`Quark.Runtime`) + +`GrainActivation.RunActivationAsync` branches on `_root.GetRequiredService() +.TryGet(GrainType, ...)` combined with `_root.GetRequiredService().Provider` +being non-null. When both hold: create the **Quark-only** scope (`quarkRoot.CreateScope()`) instead of the +flat `_root.CreateScope()`, bind `ICallContext`/shell accessor into it exactly as today (via +`GrainScopeBinder.BindAndResolveAsync`, now taking separate `bindingServices`/`constructionServices` +parameters — see §3), and pass the small `CompositeServiceProvider` (Quark-only first, cached user provider +second) as the construction provider to `IBehaviorResolver.Resolve(grainType, constructionServices)`. +Behaviors that don't implement the interface take the existing, entirely unchanged path — same provider +passed for both binding and construction, identical to today's single-`sp` flow. + +--- + +## 5. Failure & edge cases + +| Case | Behaviour | +|---|---| +| `CreateUserServiceProvider` throws at startup | Silo startup fails fast (same failure mode as any other misconfigured DI registration today) — surfaced before any activation is attempted, not deferred to first call. | +| Behavior implements `IGrainUserServiceProviderFactory` but has no non-Quark constructor dependencies | No-op in effect: the cached provider is simply never consulted, since every parameter resolves from the Quark-only scope. Harmless, not an error. | +| A constructor parameter type is ambiguous (registered in both the Quark-only satellite AND the cached user provider) | Quark-only scope wins (queried first in `CompositeServiceProvider`) — this can only happen if a developer manually registers a Quark abstraction type into their own `rootServices`/user provider, which is a misuse; document it as undefined/discouraged rather than guarding it at runtime. | +| Grain type never activated | Factory still runs once at startup (eager, not lazy) — consistent with "known cost paid once, upfront" rather than adding a first-call branch to check. | + +--- + +## 6. AOT / trim safety + +- **No reflection.** Static interface member dispatch is resolved by the generic/compile-time call site + the generator emits — the same guarantee `BehaviorRegistrationGenerator` already provides for + `IGrainBehavior` discovery. +- **No assembly scanning** — purely additive to the existing per-assembly generator pass. +- **`CompositeServiceProvider`** is a small hand-written class with two `IServiceProvider` fields and a + `GetService(Type)` that tries one then the other — no dynamic codegen, no `Type`-keyed reflection beyond + what `IServiceProvider.GetService` already does. +- **AOT smoke:** extend the existing `PublishAot=true` runtime smoke build with a behavior implementing + `IGrainUserServiceProviderFactory` — must stay warning-free. + +--- + +## 7. Open questions + +1. **Per-grain-type sharing, not per-activation.** The cached user provider is shared by every activation + of a grain type, not scoped per grain key. This is a deliberate simplification (confirmed during + design): a developer who needs per-key variance in their user services should encode that inside their + own provider (e.g. keyed internally), not rely on Quark to create one provider per activation. Flag if + a real use case needs per-activation granularity — it would require a different mechanism (see the + rejected "lazy cache-on-first-call" alternative considered during design). +2. **Eager vs. lazy factory invocation.** Chosen: eager, at startup, for every registered grain type + regardless of whether it's ever activated. Alternative: lazy on first activation. Eager was chosen for + fail-fast startup validation; revisit if silos register many grain types that are rarely activated and + startup cost becomes material. +3. **Persistence-pattern support deferred (§2 non-goal, confirmed during design).** `IPersistentActivationMemory`, + `[PersistentState]`, `ITransactionalState`, streams, and reminders are not resolvable by opted-in + behaviors in v1 — they need `IStorage`/`IGrainStorage` and other services from packages this spec + doesn't touch. Extending `Quark.Persistence.InMemory`/`Redis` (and similar) to register through + `AddQuarkOwnedScoped` is the natural follow-up once this ships. + +--- + +## 8. Implementation sequence + +1. `Quark.Core.Abstractions/Hosting/IGrainUserServiceProviderFactory.cs` — new interface (§4.1). +2. `Quark.Runtime/CompositeServiceProvider.cs` — the two-provider fallback `IServiceProvider` (§4.4). +3. `Quark.Runtime/IUserServiceProviderRegistry.cs` + implementation — replaces + `IGrainScopeInitializerRegistry`/`GrainScopeInitializerRegistry`. +4. `Quark.CodeGenerator/BehaviorRegistrationGenerator.cs` — detect `IGrainUserServiceProviderFactory` + implementers; emit the deferred factory-registration call and the Quark-only satellite collection + entries (§4.2). +5. `Quark.Runtime/SiloHostedService.cs` — replace `ApplyScopeInitializerRegistrations()` with the + eager factory-invocation step that builds `IUserServiceProviderRegistry` and (if needed) the + Quark-only satellite root (§4.3). +6. `Quark.Runtime/GrainActivation.cs` + `Quark.Runtime/GrainScopeBinder.cs` — branch on + `IUserServiceProviderRegistry.TryGet` (§4.4). +7. Remove `GrainScopeInitializer`, `IGrainScopeInitializerRegistry`, `GrainScopeInitializerRegistry`, + `AddGrainScopeInitializer()`, `GrainScopeInitializerRegistration`, and + `SiloHostedService.ApplyScopeInitializerRegistrations()`. +8. Update/replace `tests/Quark.Tests.Unit/Runtime/GrainScopeInitializerTests.cs` and the reference in + `AddGrainBehaviorFactoryOverloadTests.cs` with coverage for the new mechanism (opted-in behavior reuses + the cached user provider across calls; non-opted-in behavior is unaffected; Quark service types are + never resolved from the cached user provider even if present there). +9. AOT smoke test per §6; update `FEATURES.md`, `wiki/Source-Generators.md`. + +--- + +## 9. Testing strategy + +- **Unit — opted-in behavior reuses the cached provider:** register a behavior whose + `CreateUserServiceProvider` returns a provider wrapping a counting factory; drive two calls against the + same activation; assert the user factory ran once, not twice. +- **Unit — non-opted-in behavior unaffected:** existing scope-per-call tests continue to pass unchanged. +- **Unit — Quark service resolution is structural, not conventional:** register a behavior whose + `CreateUserServiceProvider` deliberately returns a provider that ALSO has an `ICallContext` registered + (misuse) — assert the engine's own `ICallContext` instance is what the behavior actually receives, not + the one from the user provider. +- **Unit — startup fail-fast:** a `CreateUserServiceProvider` that throws fails silo startup, not the + first grain call. +- **Unit — activation lifecycle unaffected:** `OnActivateAsync`/`RunEagerInitAsync` run identically over + the composite provider for opted-in behaviors. +- **AOT smoke** per §6. diff --git a/docs/superpowers/specs/2026-07-10-journaledgrain-snapshotting-design.md b/docs/superpowers/specs/2026-07-10-journaledgrain-snapshotting-design.md new file mode 100644 index 0000000..4497144 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-journaledgrain-snapshotting-design.md @@ -0,0 +1,338 @@ +# JournaledGrain snapshotting — design + +**Issue:** #144 — *JournaledGrain replays the entire event log on every activation — no snapshotting* +**Date:** 2026-07-10 +**Status:** Approved design — ready for implementation plan +**Scope:** `ISnapshotStore` abstraction + `InMemorySnapshotStore` + `JournaledGrain` wiring + config + exception + tests. Redis snapshot store and a durable Redis `ILogStorage` are an explicit follow-up (see §9). + +## 1. Problem + +`JournaledGrain.ReloadFromLogAsync` reads and replays **every** event from +version 0 to `int.MaxValue` on each activation +(`src/Quark.Persistence.Abstractions/Journaling/JournaledGrain.cs`): + +```csharp +private async Task ReloadFromLogAsync(CancellationToken ct) +{ + JournaledGrainState st = _memory.Value; + IReadOnlyList all = + await _logStorage!.ReadEntriesAsync(GrainId, 0, int.MaxValue, ct).ConfigureAwait(false); + st.State = new TState(); + foreach (LogEntry entry in all) + { + TransitionState(st.State, (TEvent)entry.Event); + st.ConfirmedVersion = entry.Version + 1; + } +} +``` + +Replay cost is unbounded and grows linearly with the grain's entire event history, forever. +A long-lived event-sourced grain (bank account, inventory ledger) pays a growing activation +latency that never amortizes. No snapshot mechanism exists anywhere in the codebase. + +## 2. Guiding principle + +The **event log remains the sole source of truth.** A snapshot is *only* a replay-shortcut: +given a snapshot `(version V, state S)`, activation seeds `State = S` and replays only log +entries `[V, …)` instead of `[0, …)`. A snapshot must never affect correctness — if it is +missing, broken, or inconsistent with the log, the system either falls back to a full replay +(missing) or fails loudly (broken), but never silently produces wrong state. + +## 3. Design decisions (locked) + +| Decision | Choice | +|---|---| +| **Storage** | A **new dedicated `ISnapshotStore`** abstraction, separate from both `ILogStorage` and `IGrainStorage`. | +| **Cadence** | **Auto every N confirmed events** (configurable; default 100; `0` disables) **plus** a manual `WriteSnapshotAsync()` hook. | +| **Fallback policy** | **Strict / fail-fast.** A *missing* snapshot is normal → full replay. A *present-but-broken* snapshot (undeserializable, or version ahead of the log) throws `CorruptSnapshotException` and blocks activation. | +| **Recovery** | `ISnapshotStore.ClearSnapshotAsync(grainId)` + typed `CorruptSnapshotException` (carries `GrainId` + snapshot version). | +| **Provider scope** | `InMemorySnapshotStore` now + the abstraction. Redis snapshot store and durable Redis `ILogStorage` are a called-out follow-up. | + +## 4. New abstraction — `Quark.Persistence.Abstractions.Journaling` + +```csharp +/// +/// Optional snapshot store for . A snapshot is a +/// replay-shortcut only; the event log remains the source of truth. Missing snapshots are +/// normal (activation full-replays). A present-but-corrupt snapshot must surface as a +/// rather than silently producing wrong state. +/// +public interface ISnapshotStore +{ + /// + /// Reads the latest snapshot for , or null if none exists. + /// Durable providers throw when a stored snapshot + /// cannot be deserialized into . + /// + Task?> ReadSnapshotAsync( + GrainId grainId, CancellationToken ct = default) where TState : class; + + /// Writes (replaces) the snapshot for . + Task WriteSnapshotAsync( + GrainId grainId, SnapshotEnvelope snapshot, CancellationToken ct = default) + where TState : class; + + /// Deletes any stored snapshot for (recovery path). + Task ClearSnapshotAsync(GrainId grainId, CancellationToken ct = default); +} + +/// A point-in-time projection of grain state and the log version it folds up to. +public sealed class SnapshotEnvelope +{ + public SnapshotEnvelope(int version, TState state) { Version = version; State = state; } + + /// Number of events folded into — i.e. the index of the next event. + public int Version { get; } + + /// State after applying events [0, Version). + public TState State { get; } +} + +/// Thrown when a present snapshot is unusable (undeserializable or inconsistent with the log). +public sealed class CorruptSnapshotException : Exception +{ + public CorruptSnapshotException(GrainId grainId, int snapshotVersion, string message, Exception? inner = null) + : base(message, inner) { GrainId = grainId; SnapshotVersion = snapshotVersion; } + + public GrainId GrainId { get; } + public int SnapshotVersion { get; } +} +``` + +**Rationale for a per-call generic `TState` rather than a typed store:** mirrors `IGrainStorage`'s +existing shape (`ReadStateAsync`), so a single provider instance serves all grain types. + +## 5. Provider — `InMemorySnapshotStore` (`Quark.Persistence.InMemory`) + +```csharp +public sealed class InMemorySnapshotStore : ISnapshotStore +{ + private readonly ConcurrentDictionary _snapshots = new(); + private readonly ICopierProvider _copiers; // already reachable: this package references Quark.Serialization + + public InMemorySnapshotStore(ICopierProvider copiers) => _copiers = copiers; + + public Task WriteSnapshotAsync(GrainId id, SnapshotEnvelope snap, CancellationToken ct = default) + where TState : class + { + ct.ThrowIfCancellationRequested(); + // Same deep-copy idiom already used by InMemoryGrainStorage.cs:92. + TState isolated = _copiers.GetRequiredCopier().DeepCopy(snap.State, new CopyContext()); + _snapshots[id] = (snap.Version, isolated); + return Task.CompletedTask; + } + + public Task?> ReadSnapshotAsync(GrainId id, CancellationToken ct = default) + where TState : class + { + ct.ThrowIfCancellationRequested(); + if (!_snapshots.TryGetValue(id, out var e)) + return Task.FromResult?>(null); + TState copy = _copiers.GetRequiredCopier().DeepCopy((TState)e.State, new CopyContext()); + return Task.FromResult?>(new SnapshotEnvelope(e.Version, copy)); + } + + public Task ClearSnapshotAsync(GrainId id, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + _snapshots.TryRemove(id, out _); + return Task.CompletedTask; + } +} +``` + +**Why deep-copy on both write and read:** the grain hands us its *live* `State`, which keeps being +mutated by later `RaiseEvent` calls. Without an isolating copy, the stored snapshot would drift to +reflect later mutations, and a subsequent activation would double-apply events. Copying on write +isolates the store from the caller; copying on read isolates the caller from the store (the returned +state is mutated during tail replay). + +**Soft new constraint:** a snapshotted `TState` needs a generated deep copier — i.e. `[GenerateSerializer]` +on `TState`. This is the same machinery Quark already uses for in-process grain-call isolation, so it is +idiomatic; it is only required for grains that actually enable snapshotting. The Bank sample's `LedgerState` +gains `[GenerateSerializer]` to demonstrate. + +**DI registration** (`Quark.Persistence.InMemory`): + +```csharp +public static IServiceCollection AddInMemorySnapshotStore(this IServiceCollection services) +{ + services.TryAddSingleton(); + return services; +} +``` + +`InMemorySnapshotStore` never throws `CorruptSnapshotException` — with no serialization there is nothing +to corrupt. The undeserializable-snapshot failure mode belongs to durable providers (follow-up); the +*ahead-of-log* failure mode is detected in `JournaledGrain` (§6) and applies to every provider. + +## 6. `JournaledGrain` changes + +### 6.1 Constructor & state + +```csharp +protected JournaledGrain( + IActivationMemory> memory, + ICallContext ctx, + ILogStorage? logStorage = null, + ISnapshotStore? snapshotStore = null) // NEW — optional, backward-compatible +``` + +An unregistered `ISnapshotStore` resolves to `null` (snapshotting off), exactly as `ILogStorage` +does today. Because JournaledGrain subclasses already carry nullable-default ctor params, the +`BehaviorRegistrationGenerator` already routes them through the `ActivatorUtilities` reflection path +(a compile-time factory is only generated when *all* ctor params are required — see +`BehaviorRegistrationGenerator.cs`, the `Parameters.All(p => !p.HasExplicitDefaultValue)` guard). So +`ActivatorUtilities.CreateInstance` fills an unregistered optional param with its default. **No source +generator change is required.** + +`JournaledGrainState` gains one field: + +```csharp +public int LastSnapshotVersion { get; set; } // ConfirmedVersion at the last snapshot write +``` + +### 6.2 Cadence configuration + +```csharp +/// +/// Number of confirmed events between automatic snapshots. Override per grain type. +/// 0 disables automatic snapshotting for this grain type. Default: 100. +/// +protected virtual int SnapshotInterval => 100; +``` + +Per-type override via a virtual property keeps configuration zero-DI and discoverable. Automatic +snapshotting only ever happens when an `ISnapshotStore` is registered *and* `SnapshotInterval > 0`. + +### 6.3 Write path (append + auto-snapshot) + +At the end of `ConfirmEventsAsync`, after the log append and `ConfirmedVersion` bump: + +```csharp +st.ConfirmedVersion += st.StagedEvents.Count; +st.StagedEvents.Clear(); + +if (_snapshotStore is not null && SnapshotInterval > 0 && + st.ConfirmedVersion - st.LastSnapshotVersion >= SnapshotInterval) +{ + await WriteSnapshotCoreAsync(cancellationToken).ConfigureAwait(false); +} +``` + +```csharp +private async Task WriteSnapshotCoreAsync(CancellationToken ct) +{ + JournaledGrainState st = _memory.Value; + await _snapshotStore!.WriteSnapshotAsync( + GrainId, new SnapshotEnvelope(st.ConfirmedVersion, st.State), ct).ConfigureAwait(false); + st.LastSnapshotVersion = st.ConfirmedVersion; +} + +/// Manually writes a snapshot of the current confirmed state. No-op if no snapshot store is registered. +protected Task WriteSnapshotAsync(CancellationToken cancellationToken = default) => + _snapshotStore is null ? Task.CompletedTask : WriteSnapshotCoreAsync(cancellationToken); +``` + +### 6.4 Activation path (snapshot-aware replay) + +```csharp +private async Task ReloadFromLogAsync(CancellationToken ct) +{ + JournaledGrainState st = _memory.Value; + st.State = new TState(); + st.ConfirmedVersion = 0; + st.LastSnapshotVersion = 0; + + if (_snapshotStore is not null) + { + SnapshotEnvelope? snap = + await _snapshotStore.ReadSnapshotAsync(GrainId, ct).ConfigureAwait(false); + // ^ durable providers throw CorruptSnapshotException here on an undeserializable snapshot + + if (snap is not null && snap.Version > 0) + { + // Boundary probe: read from snap.Version-1 so we can confirm the log actually contains + // >= snap.Version contiguous entries WITHOUT adding a length API to ILogStorage + // (AppendEntriesAsync guarantees version == index, so entry[V-1] existing ⇒ 0..V-1 all exist). + IReadOnlyList tail = + await _logStorage!.ReadEntriesAsync(GrainId, snap.Version - 1, int.MaxValue, ct).ConfigureAwait(false); + + if (tail.Count == 0 || tail[0].Version != snap.Version - 1) + throw new CorruptSnapshotException(GrainId, snap.Version, + $"Snapshot version {snap.Version} is ahead of the event log for grain {GrainId}."); + + st.State = snap.State; // store returned an isolated copy + st.ConfirmedVersion = snap.Version; + st.LastSnapshotVersion = snap.Version; + + for (int i = 1; i < tail.Count; i++) // skip the boundary entry (already in the snapshot) + { + TransitionState(st.State, (TEvent)tail[i].Event); + st.ConfirmedVersion = tail[i].Version + 1; + } + return; + } + } + + // No usable snapshot → full replay from 0 (today's behavior). + IReadOnlyList all = + await _logStorage!.ReadEntriesAsync(GrainId, 0, int.MaxValue, ct).ConfigureAwait(false); + foreach (LogEntry entry in all) + { + TransitionState(st.State, (TEvent)entry.Event); + st.ConfirmedVersion = entry.Version + 1; + } +} +``` + +`OnActivateAsync` guard is unchanged: replay only runs when `_logStorage is not null`. When a snapshot +store is registered but no log store is, snapshotting is inert (there is nothing to shorten). + +## 7. Error handling & recovery + +| Situation | Behavior | +|---|---| +| No snapshot stored | Normal — full replay from 0. Not an error. | +| `SnapshotInterval == 0` or no `ISnapshotStore` | Snapshotting off; today's full-replay behavior. | +| Snapshot present, deserialization fails (durable) | `CorruptSnapshotException(grainId, version)` from `ReadSnapshotAsync`. | +| Snapshot present, version ahead of log | `CorruptSnapshotException(grainId, version)` from the boundary probe. | +| Recovery | Operator / management grain calls `ClearSnapshotAsync(grainId)`; next activation full-replays and writes a fresh snapshot. | + +Fail-fast is deliberate: a durability subsystem should surface corruption loudly rather than silently +burning CPU on repeated full replays or, worse, producing wrong state. + +## 8. Testing (`tests/Quark.Tests.Unit/Journaling`) + +A spying `ILogStorage` decorator records the `(fromVersion, toVersion)` of each `ReadEntriesAsync` so +tests can assert *how many* entries were replayed. + +- **Auto-snapshot at interval:** confirm N events with `SnapshotInterval = N` ⇒ exactly one snapshot written at version N. +- **Tail-only replay:** with a snapshot at V and events up to V+k, a fresh activation replays only `[V, V+k)` (assert via the spy: read starts at `V-1`, not 0). +- **Manual `WriteSnapshotAsync`:** writes at the current confirmed version regardless of interval. +- **Disabled:** `SnapshotInterval = 0` ⇒ no snapshot ever written; full replay on activation. +- **Missing snapshot:** store returns null ⇒ full replay from 0; no exception. +- **Ahead-of-log:** snapshot version > log length ⇒ `CorruptSnapshotException`. +- **Recovery:** after a forced corrupt/ahead snapshot, `ClearSnapshotAsync` ⇒ next activation succeeds via full replay and rewrites a snapshot. +- **Deep-copy isolation:** write a snapshot, mutate live `State`, read the snapshot back ⇒ read reflects the value at snapshot time, not the later mutation. +- **Bank sample:** `LedgerState` gains `[GenerateSerializer]`; `Bank.Server` registers `AddInMemorySnapshotStore()`; a deactivate/reactivate cycle after many events replays only the tail. + +## 9. Follow-up (out of scope for this spec) + +- **`RedisSnapshotStore`** — serialize `TState` via `QuarkSerializer`/`IFieldCodec` (as Redis grain storage already does); this is where the *undeserializable-snapshot* `CorruptSnapshotException` path is exercised. +- **Durable Redis `ILogStorage`** — a durable snapshot paired with the current InMemory-only log is only half-durable; a durable log is the natural companion. File as a paired issue. +- **`SnapshotOptions` global default** — if per-type `SnapshotInterval` overrides prove insufficient, add a silo-level default interval. Not needed for v1. + +## 10. Files touched + +**New** +- `src/Quark.Persistence.Abstractions/Journaling/ISnapshotStore.cs` (`ISnapshotStore`, `SnapshotEnvelope`, `CorruptSnapshotException`) +- `src/Quark.Persistence.InMemory/InMemorySnapshotStore.cs` +- `tests/Quark.Tests.Unit/Journaling/JournaledGrainSnapshotTests.cs` + +**Modified** +- `src/Quark.Persistence.Abstractions/Journaling/JournaledGrain.cs` (ctor param, cadence property, write path, activation path, `WriteSnapshotAsync`) +- `src/Quark.Persistence.Abstractions/Journaling/JournaledGrainState.cs` (`LastSnapshotVersion`) +- `src/Quark.Persistence.InMemory/InMemorySnapshotStoreServiceCollectionExtensions.cs` (`AddInMemorySnapshotStore`) — new, sibling to `InMemoryGrainStorageServiceCollectionExtensions` +- `samples/Persistence/Bank.Grains/LedgerBehavior.cs` + `LedgerState` (`[GenerateSerializer]`, forward `ISnapshotStore`) +- `samples/Persistence/Bank.Server/Program.cs` (`AddInMemorySnapshotStore()`) diff --git a/samples/Persistence/Bank.Grains/BankStateCopiers.cs b/samples/Persistence/Bank.Grains/BankStateCopiers.cs index 700b3c4..1164314 100644 --- a/samples/Persistence/Bank.Grains/BankStateCopiers.cs +++ b/samples/Persistence/Bank.Grains/BankStateCopiers.cs @@ -4,24 +4,27 @@ namespace Bank.Grains; /// -/// Registers the deep copiers that the storage providers use to snapshot durable state. +/// Registers the deep copiers that storage providers and the ISnapshotStore use to +/// snapshot durable/journaled state. /// /// The code generator emits an internal {StateType}Copier for every /// [GenerateSerializer] type. They are internal to the assembly that declares -/// the state, so this helper — living in the same assembly — wires them into DI. State that -/// is never written through IGrainStorage (e.g. the event-sourced -/// ) does not need a copier. +/// the state, so this helper — living in the same assembly — wires them into DI. This +/// includes , whose copier lets the in-memory +/// ISnapshotStore snapshot the event-sourced ledger projection. /// /// public static class BankStateCopiers { - /// Registers IDeepCopier<T> for every storage-backed Bank state type. + /// Registers IDeepCopier<T> for every storage-backed or snapshotted Bank state type. public static IServiceCollection AddBankStateCopiers(this IServiceCollection services) { services.AddSingleton>( sp => new AccountStateCopier(sp.GetRequiredService())); services.AddSingleton>( sp => new ProfileStateCopier(sp.GetRequiredService())); + services.AddSingleton>( + sp => new LedgerStateCopier(sp.GetRequiredService())); return services; } } diff --git a/samples/Persistence/Bank.Grains/LedgerBehavior.cs b/samples/Persistence/Bank.Grains/LedgerBehavior.cs index 533ec47..1b271ae 100644 --- a/samples/Persistence/Bank.Grains/LedgerBehavior.cs +++ b/samples/Persistence/Bank.Grains/LedgerBehavior.cs @@ -17,12 +17,16 @@ namespace Bank.Grains; public sealed class LedgerBehavior : JournaledGrain, ILedgerGrain { // The code generator registers IActivationMemory> - // for this constructor; ICallContext and ILogStorage come from the runtime / DI. + // for this constructor; ICallContext, ILogStorage, and ISnapshotStore come from the runtime / DI. public LedgerBehavior( IActivationMemory> memory, ICallContext ctx, - ILogStorage? log = null) - : base(memory, ctx, log) { } + ILogStorage? log = null, + ISnapshotStore? snapshot = null) + : base(memory, ctx, log, snapshot) { } + + // Snapshot every 5 confirmed events so long-lived ledgers don't replay their whole history. + protected override int SnapshotInterval => 5; // Pure function: how each event mutates the projection. Used for both live updates and replay. protected override void TransitionState(LedgerState state, LedgerEvent @event) diff --git a/samples/Persistence/Bank.Grains/LedgerState.cs b/samples/Persistence/Bank.Grains/LedgerState.cs index 90ec8f4..0a6c5d2 100644 --- a/samples/Persistence/Bank.Grains/LedgerState.cs +++ b/samples/Persistence/Bank.Grains/LedgerState.cs @@ -1,14 +1,17 @@ +using Quark.Serialization.Abstractions.Attributes; + namespace Bank.Grains; /// -/// In-memory projection for . This is never persisted directly — -/// it is rebuilt by replaying s from the log. No serializer is needed -/// because the projection lives only in the activation shell. +/// Projection for , rebuilt by replaying s. +/// [GenerateSerializer] lets the in-memory ISnapshotStore deep-copy it so activation +/// can replay only post-snapshot events instead of the whole log. /// +[GenerateSerializer] public sealed class LedgerState { - public decimal Balance { get; set; } - public List History { get; } = []; + [Id(0)] public decimal Balance { get; set; } + [Id(1)] public List History { get; set; } = []; } /// Base type for ledger events. Events are the source of truth, persisted to the log. diff --git a/samples/Persistence/Bank.Server/Program.cs b/samples/Persistence/Bank.Server/Program.cs index 76de09d..3c37444 100644 --- a/samples/Persistence/Bank.Server/Program.cs +++ b/samples/Persistence/Bank.Server/Program.cs @@ -25,6 +25,9 @@ // Event log — backs the JournaledGrain ledger. silo.Services.AddSingleton(); + // Snapshot store — lets the JournaledGrain ledger replay only post-snapshot events. + silo.Services.AddInMemorySnapshotStore(); + // Deep copiers for the storage-backed state types ([GenerateSerializer]). silo.Services.AddBankStateCopiers(); diff --git a/src/Quark.Persistence.Abstractions/Journaling/ISnapshotStore.cs b/src/Quark.Persistence.Abstractions/Journaling/ISnapshotStore.cs new file mode 100644 index 0000000..7218be2 --- /dev/null +++ b/src/Quark.Persistence.Abstractions/Journaling/ISnapshotStore.cs @@ -0,0 +1,68 @@ +using Quark.Core.Abstractions.Identity; + +namespace Quark.Persistence.Abstractions.Journaling; + +/// +/// Optional snapshot store for . A snapshot is a +/// replay-shortcut only; the event log remains the source of truth. A missing snapshot is +/// normal (activation full-replays). A present-but-corrupt snapshot must surface as a +/// rather than silently producing wrong state. +/// +public interface ISnapshotStore +{ + /// + /// Reads the latest snapshot for , or null if none exists. + /// Durable providers throw when a stored snapshot + /// cannot be deserialized into . + /// Implementations MUST return a deep/isolated copy of the state: the caller assigns the + /// returned directly into activation state and + /// mutates it in place while replaying post-snapshot events. + /// + Task?> ReadSnapshotAsync( + GrainId grainId, CancellationToken ct = default) where TState : class; + + /// + /// Writes (replaces) the snapshot for . Implementations MUST store + /// an isolated copy of : the caller may continue + /// mutating its own state object after this call returns. + /// + Task WriteSnapshotAsync( + GrainId grainId, SnapshotEnvelope snapshot, CancellationToken ct = default) + where TState : class; + + /// Deletes any stored snapshot for (recovery path). + Task ClearSnapshotAsync(GrainId grainId, CancellationToken ct = default); +} + +/// A point-in-time projection of grain state and the log version it folds up to. +public sealed class SnapshotEnvelope +{ + public SnapshotEnvelope(int version, TState state) + { + Version = version; + State = state; + } + + /// Number of events folded into — i.e. the index of the next event. + public int Version { get; } + + /// State after applying events [0, Version). + public TState State { get; } +} + +/// Thrown when a present snapshot is unusable (undeserializable or inconsistent with the log). +public sealed class CorruptSnapshotException : Exception +{ + public CorruptSnapshotException(GrainId grainId, int snapshotVersion, string message, Exception? inner = null) + : base(message, inner) + { + GrainId = grainId; + SnapshotVersion = snapshotVersion; + } + + /// The grain whose snapshot is corrupt. + public GrainId GrainId { get; } + + /// The version stamped on the offending snapshot. + public int SnapshotVersion { get; } +} diff --git a/src/Quark.Persistence.Abstractions/Journaling/JournaledGrain.cs b/src/Quark.Persistence.Abstractions/Journaling/JournaledGrain.cs index 393f288..8f38aa5 100644 --- a/src/Quark.Persistence.Abstractions/Journaling/JournaledGrain.cs +++ b/src/Quark.Persistence.Abstractions/Journaling/JournaledGrain.cs @@ -16,15 +16,18 @@ public abstract class JournaledGrain : IGrainBehavior, IActivati private readonly IActivationMemory> _memory; private readonly ICallContext _ctx; private ILogStorage? _logStorage; + private ISnapshotStore? _snapshotStore; protected JournaledGrain( IActivationMemory> memory, ICallContext ctx, - ILogStorage? logStorage = null) + ILogStorage? logStorage = null, + ISnapshotStore? snapshotStore = null) { _memory = memory; _ctx = ctx; _logStorage = logStorage; + _snapshotStore = snapshotStore; } /// The grain identity for this call. @@ -36,6 +39,13 @@ protected JournaledGrain( /// The current in-memory state (includes staged but not-yet-confirmed events). protected TState State => _memory.Value.State; + /// + /// Number of confirmed events between automatic snapshots. Override per grain type. + /// 0 disables automatic snapshotting for this grain type. Default: 100. + /// Automatic snapshots require a registered . + /// + protected virtual int SnapshotInterval => 100; + /// public async Task OnActivateAsync(CancellationToken ct) { @@ -80,6 +90,28 @@ await _logStorage.AppendEntriesAsync(GrainId, st.ConfirmedVersion, entries, canc .ConfigureAwait(false); st.ConfirmedVersion += st.StagedEvents.Count; st.StagedEvents.Clear(); + + if (_snapshotStore is not null && SnapshotInterval > 0 && + st.ConfirmedVersion - st.LastSnapshotVersion >= SnapshotInterval) + { + await WriteSnapshotCoreAsync(cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Writes a snapshot of the current confirmed state to the registered + /// . No-op when no snapshot store is registered. + /// + protected Task WriteSnapshotAsync(CancellationToken cancellationToken = default) => + _snapshotStore is null ? Task.CompletedTask : WriteSnapshotCoreAsync(cancellationToken); + + private async Task WriteSnapshotCoreAsync(CancellationToken ct) + { + JournaledGrainState st = _memory.Value; + await _snapshotStore! + .WriteSnapshotAsync(GrainId, new SnapshotEnvelope(st.ConfirmedVersion, st.State), ct) + .ConfigureAwait(false); + st.LastSnapshotVersion = st.ConfirmedVersion; } /// Retrieves confirmed events in the range [, ). @@ -96,9 +128,44 @@ await _logStorage.ReadEntriesAsync(GrainId, fromVersion, toVersion, cancellation private async Task ReloadFromLogAsync(CancellationToken ct) { JournaledGrainState st = _memory.Value; + st.ConfirmedVersion = 0; + st.LastSnapshotVersion = 0; + + if (_snapshotStore is not null) + { + SnapshotEnvelope? snap = + await _snapshotStore.ReadSnapshotAsync(GrainId, ct).ConfigureAwait(false); + + if (snap is not null && snap.Version > 0) + { + // Boundary probe: read from snap.Version - 1 so we can confirm the log actually + // contains >= snap.Version contiguous entries WITHOUT a length API on ILogStorage + // (AppendEntriesAsync guarantees version == index, so entry[V-1] existing => 0..V-1 exist). + IReadOnlyList tail = await _logStorage! + .ReadEntriesAsync(GrainId, snap.Version - 1, int.MaxValue, ct).ConfigureAwait(false); + + if (tail.Count == 0 || tail[0].Version != snap.Version - 1) + throw new CorruptSnapshotException(GrainId, snap.Version, + $"Snapshot version {snap.Version} is ahead of the event log for grain {GrainId}."); + + st.State = snap.State; // store returned an isolated copy + st.ConfirmedVersion = snap.Version; + st.LastSnapshotVersion = snap.Version; + + for (int i = 1; i < tail.Count; i++) // skip the boundary entry (already in the snapshot) + { + TransitionState(st.State, (TEvent)tail[i].Event); + st.ConfirmedVersion = tail[i].Version + 1; + } + + return; + } + } + + // No usable snapshot: full replay from 0 (original behavior). + st.State = new TState(); IReadOnlyList all = await _logStorage!.ReadEntriesAsync(GrainId, 0, int.MaxValue, ct).ConfigureAwait(false); - st.State = new TState(); foreach (LogEntry entry in all) { TransitionState(st.State, (TEvent)entry.Event); diff --git a/src/Quark.Persistence.Abstractions/Journaling/JournaledGrainState.cs b/src/Quark.Persistence.Abstractions/Journaling/JournaledGrainState.cs index 378ccd5..2dbf15d 100644 --- a/src/Quark.Persistence.Abstractions/Journaling/JournaledGrainState.cs +++ b/src/Quark.Persistence.Abstractions/Journaling/JournaledGrainState.cs @@ -10,4 +10,7 @@ public sealed class JournaledGrainState public TState State { get; set; } = new(); public List StagedEvents { get; } = []; public int ConfirmedVersion { get; set; } + + /// The captured by the most recent snapshot write. + public int LastSnapshotVersion { get; set; } } diff --git a/src/Quark.Persistence.InMemory/InMemorySnapshotStore.cs b/src/Quark.Persistence.InMemory/InMemorySnapshotStore.cs new file mode 100644 index 0000000..ca02f5a --- /dev/null +++ b/src/Quark.Persistence.InMemory/InMemorySnapshotStore.cs @@ -0,0 +1,53 @@ +using System.Collections.Concurrent; +using Quark.Core.Abstractions.Identity; +using Quark.Persistence.Abstractions.Journaling; +using Quark.Serialization.Abstractions.Abstractions; + +namespace Quark.Persistence.InMemory; + +/// +/// In-memory for development and tests. State is deep-copied on +/// both write and read to isolate the stored snapshot from the grain's live, still-mutating +/// state (the same isolation applies). Not durable across +/// process restarts, so it never produces the undeserializable-snapshot failure mode. +/// +public sealed class InMemorySnapshotStore : ISnapshotStore +{ + private readonly ConcurrentDictionary _snapshots = new(); + private readonly ICopierProvider _copiers; + + /// Initializes the in-memory snapshot store. + public InMemorySnapshotStore(ICopierProvider copiers) => _copiers = copiers; + + /// + public Task WriteSnapshotAsync( + GrainId grainId, SnapshotEnvelope snapshot, CancellationToken ct = default) + where TState : class + { + ct.ThrowIfCancellationRequested(); + TState isolated = _copiers.GetRequiredCopier().DeepCopy(snapshot.State, new CopyContext()); + _snapshots[grainId] = (snapshot.Version, isolated); + return Task.CompletedTask; + } + + /// + public Task?> ReadSnapshotAsync( + GrainId grainId, CancellationToken ct = default) + where TState : class + { + ct.ThrowIfCancellationRequested(); + if (!_snapshots.TryGetValue(grainId, out (int Version, object State) entry)) + return Task.FromResult?>(null); + + TState copy = _copiers.GetRequiredCopier().DeepCopy((TState)entry.State, new CopyContext()); + return Task.FromResult?>(new SnapshotEnvelope(entry.Version, copy)); + } + + /// + public Task ClearSnapshotAsync(GrainId grainId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + _snapshots.TryRemove(grainId, out _); + return Task.CompletedTask; + } +} diff --git a/src/Quark.Persistence.InMemory/InMemorySnapshotStoreServiceCollectionExtensions.cs b/src/Quark.Persistence.InMemory/InMemorySnapshotStoreServiceCollectionExtensions.cs new file mode 100644 index 0000000..7ad625f --- /dev/null +++ b/src/Quark.Persistence.InMemory/InMemorySnapshotStoreServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Quark.Persistence.Abstractions.Journaling; + +namespace Quark.Persistence.InMemory; + +/// Service registration helpers for the in-memory snapshot store. +public static class InMemorySnapshotStoreServiceCollectionExtensions +{ + /// + /// Registers the in-memory . Once registered, every + /// with a positive SnapshotInterval + /// writes snapshots and replays only post-snapshot events on activation. + /// + public static IServiceCollection AddInMemorySnapshotStore(this IServiceCollection services) + { + services.TryAddSingleton(); + return services; + } +} diff --git a/tests/Quark.Tests.Unit/Journaling/InMemorySnapshotStoreTests.cs b/tests/Quark.Tests.Unit/Journaling/InMemorySnapshotStoreTests.cs new file mode 100644 index 0000000..fc92d0b --- /dev/null +++ b/tests/Quark.Tests.Unit/Journaling/InMemorySnapshotStoreTests.cs @@ -0,0 +1,94 @@ +using Microsoft.Extensions.DependencyInjection; +using Quark.Core.Abstractions.Identity; +using Quark.Persistence.Abstractions.Journaling; +using Quark.Persistence.InMemory; +using Quark.Serialization; +using Quark.Serialization.Abstractions.Abstractions; +using Xunit; + +namespace Quark.Tests.Unit.Journaling; + +public sealed class InMemorySnapshotStoreTests +{ + // Snapshotted state needs a deep copier. Generators don't run in test projects, so hand-write one. + public sealed class Bag + { + public int N { get; set; } + public List Items { get; set; } = []; + } + + private sealed class BagCopier : IDeepCopier + { + public Bag DeepCopy(Bag original, CopyContext context) => + new() { N = original.N, Items = [.. original.Items] }; + } + + private static (InMemorySnapshotStore Store, GrainId Id) NewStore() + { + var services = new ServiceCollection(); + services.AddQuarkSerialization(); + services.AddSingleton>(new BagCopier()); + var sp = services.BuildServiceProvider(); + var store = new InMemorySnapshotStore(sp.GetRequiredService()); + return (store, new GrainId(new GrainType("G"), "k")); + } + + [Fact] + public async Task ReadSnapshotAsync_ReturnsNull_WhenMissing() + { + (InMemorySnapshotStore store, GrainId id) = NewStore(); + Assert.Null(await store.ReadSnapshotAsync(id)); + } + + [Fact] + public async Task WriteThenRead_RoundTripsVersionAndState() + { + (InMemorySnapshotStore store, GrainId id) = NewStore(); + await store.WriteSnapshotAsync(id, new SnapshotEnvelope(5, new Bag { N = 9, Items = ["a"] })); + + SnapshotEnvelope? read = await store.ReadSnapshotAsync(id); + Assert.NotNull(read); + Assert.Equal(5, read!.Version); + Assert.Equal(9, read.State.N); + Assert.Equal(new[] { "a" }, read.State.Items); + } + + [Fact] + public async Task Write_IsolatesFromLaterMutationOfOriginal() + { + (InMemorySnapshotStore store, GrainId id) = NewStore(); + var live = new Bag { N = 1, Items = ["x"] }; + await store.WriteSnapshotAsync(id, new SnapshotEnvelope(1, live)); + + live.N = 99; // mutate the live state after the snapshot was taken + live.Items.Add("y"); + + SnapshotEnvelope? read = await store.ReadSnapshotAsync(id); + Assert.Equal(1, read!.State.N); + Assert.Equal(new[] { "x" }, read.State.Items); + } + + [Fact] + public async Task Read_IsolatesStoredCopyFromCallerMutation() + { + (InMemorySnapshotStore store, GrainId id) = NewStore(); + await store.WriteSnapshotAsync(id, new SnapshotEnvelope(1, new Bag { N = 1, Items = ["x"] })); + + SnapshotEnvelope? first = await store.ReadSnapshotAsync(id); + first!.State.N = 42; // caller mutates the returned copy + first.State.Items.Add("z"); + + SnapshotEnvelope? second = await store.ReadSnapshotAsync(id); + Assert.Equal(1, second!.State.N); + Assert.Equal(new[] { "x" }, second.State.Items); + } + + [Fact] + public async Task ClearSnapshotAsync_RemovesSnapshot() + { + (InMemorySnapshotStore store, GrainId id) = NewStore(); + await store.WriteSnapshotAsync(id, new SnapshotEnvelope(1, new Bag { N = 1 })); + await store.ClearSnapshotAsync(id); + Assert.Null(await store.ReadSnapshotAsync(id)); + } +} diff --git a/tests/Quark.Tests.Unit/Journaling/JournaledGrainSnapshotTests.cs b/tests/Quark.Tests.Unit/Journaling/JournaledGrainSnapshotTests.cs new file mode 100644 index 0000000..f378ed4 --- /dev/null +++ b/tests/Quark.Tests.Unit/Journaling/JournaledGrainSnapshotTests.cs @@ -0,0 +1,327 @@ +using Microsoft.Extensions.DependencyInjection; +using Quark.Core.Abstractions.Grains; +using Quark.Core.Abstractions.Hosting; +using Quark.Core.Abstractions.Identity; +using Quark.Persistence.Abstractions; +using Quark.Persistence.Abstractions.Journaling; +using Quark.Persistence.InMemory; +using Quark.Serialization; +using Quark.Serialization.Abstractions.Abstractions; +using Xunit; + +namespace Quark.Tests.Unit.Journaling; + +public sealed class JournaledGrainSnapshotTests +{ + // ---- Write-path tests (Task 3) ---- + + [Fact] + public async Task ConfirmEvents_WritesSnapshot_WhenIntervalReached() + { + var snap = new FakeSnapshotStore(); + CounterGrain g = await ActivateAsync(new InMemoryLogStorage(), snap, interval: 3, NewId()); + + g.Bump(); g.Bump(); g.Bump(); + await g.SaveAsync(); // ConfirmedVersion 0 -> 3 + + Assert.Single(snap.Writes); + Assert.Equal(3, snap.Writes[0].Version); + } + + [Fact] + public async Task ConfirmEvents_DoesNotSnapshot_BelowInterval() + { + var snap = new FakeSnapshotStore(); + CounterGrain g = await ActivateAsync(new InMemoryLogStorage(), snap, interval: 3, NewId()); + + g.Bump(); g.Bump(); + await g.SaveAsync(); // ConfirmedVersion 0 -> 2 + + Assert.Empty(snap.Writes); + } + + [Fact] + public async Task SnapshotInterval_Zero_DisablesAutoSnapshot() + { + var snap = new FakeSnapshotStore(); + CounterGrain g = await ActivateAsync(new InMemoryLogStorage(), snap, interval: 0, NewId()); + + for (int i = 0; i < 5; i++) g.Bump(); + await g.SaveAsync(); + + Assert.Empty(snap.Writes); + } + + [Fact] + public async Task WriteSnapshotAsync_Manual_WritesAtCurrentVersion() + { + var snap = new FakeSnapshotStore(); + CounterGrain g = await ActivateAsync(new InMemoryLogStorage(), snap, interval: 0, NewId()); + + g.Bump(); g.Bump(); + await g.SaveAsync(); // version 2, no auto snapshot (interval 0) + await g.SnapshotNowAsync(); + + Assert.Single(snap.Writes); + Assert.Equal(2, snap.Writes[0].Version); + } + + [Fact] + public async Task WriteSnapshotAsync_NoStore_IsNoOp() + { + CounterGrain g = await ActivateAsync(new InMemoryLogStorage(), snapshotStore: null, interval: 3, NewId()); + g.Bump(); + await g.SaveAsync(); + await g.SnapshotNowAsync(); // must not throw + Assert.Equal(1, g.Version); + } + + // ---- Activation-path tests (Task 4) ---- + + [Fact] + public async Task Activation_WithSnapshot_ReplaysOnlyTail() + { + var log = new InMemoryLogStorage(); + var snap = new FakeSnapshotStore(); + GrainId id = NewId(); + + // Seed 5 confirmed events (interval 0 → no auto snapshot to keep the log clean). + CounterGrain seed = await ActivateAsync(log, snap, interval: 0, id); + for (int i = 0; i < 5; i++) seed.Bump(); + await seed.SaveAsync(); + + // Hand-seed a snapshot at version 3 (Count == 3). + snap.Seed(id, new SnapshotEnvelope(3, new CounterState { Count = 3 })); + + var spy = new SpyLogStorage(log); + CounterGrain reactivated = await ActivateAsync(spy, snap, interval: 0, id); + + Assert.Equal(5, reactivated.Version); + Assert.Equal(5, reactivated.State.Count); + // Boundary probe reads from snapshot.Version - 1 (== 2), NOT from 0. + Assert.Equal(2, spy.Reads[0].From); + } + + [Fact] + public async Task Activation_NoSnapshot_FullReplayFromZero() + { + var log = new InMemoryLogStorage(); + var snap = new FakeSnapshotStore(); + GrainId id = NewId(); + + CounterGrain seed = await ActivateAsync(log, snap, interval: 0, id); + for (int i = 0; i < 4; i++) seed.Bump(); + await seed.SaveAsync(); + + var spy = new SpyLogStorage(log); + CounterGrain reactivated = await ActivateAsync(spy, snap, interval: 0, id); // no snapshot seeded + + Assert.Equal(4, reactivated.State.Count); + Assert.Equal(0, spy.Reads[0].From); // full replay from 0 + } + + [Fact] + public async Task Activation_SnapshotAheadOfLog_Throws() + { + var log = new InMemoryLogStorage(); + var snap = new FakeSnapshotStore(); + GrainId id = NewId(); + + CounterGrain seed = await ActivateAsync(log, snap, interval: 0, id); + seed.Bump(); seed.Bump(); seed.Bump(); + await seed.SaveAsync(); // log has 3 entries + + snap.Seed(id, new SnapshotEnvelope(5, new CounterState { Count = 5 })); // ahead of log + + CorruptSnapshotException ex = await Assert.ThrowsAsync( + () => ActivateAsync(log, snap, interval: 0, id)); + Assert.Equal(id, ex.GrainId); + Assert.Equal(5, ex.SnapshotVersion); + } + + [Fact] + public async Task Activation_StoreThrowsCorrupt_Propagates() + { + var log = new InMemoryLogStorage(); + var snap = new FakeSnapshotStore(); + GrainId id = NewId(); + snap.ReadThrows = gid => new CorruptSnapshotException(gid, 1, "undeserializable"); + + await Assert.ThrowsAsync( + () => ActivateAsync(log, snap, interval: 0, id)); + } + + [Fact] + public async Task Activation_AfterClear_RecoversViaFullReplay() + { + var log = new InMemoryLogStorage(); + var snap = new FakeSnapshotStore(); + GrainId id = NewId(); + + CounterGrain seed = await ActivateAsync(log, snap, interval: 0, id); + seed.Bump(); seed.Bump(); seed.Bump(); + await seed.SaveAsync(); // log has 3 entries + snap.Seed(id, new SnapshotEnvelope(5, new CounterState { Count = 5 })); + + await Assert.ThrowsAsync( + () => ActivateAsync(log, snap, interval: 0, id)); // bricked + + await snap.ClearSnapshotAsync(id); // recovery + + CounterGrain recovered = await ActivateAsync(log, snap, interval: 0, id); + Assert.Equal(3, recovered.Version); + Assert.Equal(3, recovered.State.Count); + } + + [Fact] + public async Task ConfirmEvents_WritesSnapshot_AtEachIntervalBoundary() + { + var snap = new FakeSnapshotStore(); + CounterGrain g = await ActivateAsync(new InMemoryLogStorage(), snap, interval: 2, NewId()); + + for (int i = 0; i < 6; i++) // confirm one event at a time → versions 1..6 + { + g.Bump(); + await g.SaveAsync(); + } + + // Auto-snapshot fires at every interval boundary, not just the first. + Assert.Equal(new[] { 2, 4, 6 }, snap.Writes.Select(w => w.Version).ToArray()); + } + + [Fact] + public async Task Activation_TwiceFromSameSnapshot_IsIsolated() + { + // Use the REAL InMemorySnapshotStore (deep-copies on read) to exercise read-isolation. + // Generators don't run in test projects, so hand-write the copier for CounterState. + var services = new ServiceCollection(); + services.AddQuarkSerialization(); + services.AddSingleton>(new CounterStateCopier()); + var sp = services.BuildServiceProvider(); + var store = new InMemorySnapshotStore(sp.GetRequiredService()); + + var log = new InMemoryLogStorage(); + GrainId id = NewId(); + + CounterGrain seed = await ActivateAsync(log, store, interval: 0, id); + for (int i = 0; i < 5; i++) seed.Bump(); + await seed.SaveAsync(); // log has 5 entries + + await store.WriteSnapshotAsync(id, new SnapshotEnvelope(3, new CounterState { Count = 3 })); + + CounterGrain first = await ActivateAsync(log, store, interval: 0, id); + Assert.Equal(5, first.State.Count); + + // If read were not isolated, the first reactivation's in-place tail replay would have + // mutated the stored snapshot (3 -> 5), so the second would over-count. + CounterGrain second = await ActivateAsync(log, store, interval: 0, id); + Assert.Equal(5, second.State.Count); + } + + // ---- Shared helpers ---- + + private static GrainId NewId() => new(new GrainType("CounterGrain"), Guid.NewGuid().ToString("N")); + + private static async Task ActivateAsync( + ILogStorage? log, ISnapshotStore? snapshotStore, int interval, GrainId id) + { + var holder = new StateHolder>(); + var memory = new ActivationMemoryAccessor>(holder); + var grain = new CounterGrain(memory, new FixedCallContext(id), log, snapshotStore, interval); + await grain.OnActivateAsync(CancellationToken.None); + return grain; + } + + public sealed class CounterState { public int Count { get; set; } } + + private sealed class CounterStateCopier : IDeepCopier + { + public CounterState DeepCopy(CounterState original, CopyContext context) => new() { Count = original.Count }; + } + + public abstract record CounterEvent; + public sealed record Bumped : CounterEvent; + + public sealed class CounterGrain : JournaledGrain + { + private readonly int _interval; + + public CounterGrain( + IActivationMemory> memory, + ICallContext ctx, + ILogStorage? log, + ISnapshotStore? snapshotStore, + int interval) + : base(memory, ctx, log, snapshotStore) + => _interval = interval; + + protected override int SnapshotInterval => _interval; + + public new CounterState State => base.State; + public new int Version => base.Version; + + public void Bump() => RaiseEvent(new Bumped()); + public Task SaveAsync() => ConfirmEventsAsync(); + public Task SnapshotNowAsync() => WriteSnapshotAsync(); + + protected override void TransitionState(CounterState state, CounterEvent @event) => state.Count++; + } + + private sealed class FixedCallContext(GrainId grainId) : ICallContext + { + public GrainId GrainId => grainId; + } + + private sealed class SpyLogStorage(ILogStorage inner) : ILogStorage + { + public List<(int From, int To)> Reads { get; } = []; + + public Task> ReadEntriesAsync( + GrainId grainId, int fromVersion, int toVersion, CancellationToken ct = default) + { + Reads.Add((fromVersion, toVersion)); + return inner.ReadEntriesAsync(grainId, fromVersion, toVersion, ct); + } + + public Task AppendEntriesAsync( + GrainId grainId, int expectedVersion, IReadOnlyList entries, CancellationToken ct = default) + => inner.AppendEntriesAsync(grainId, expectedVersion, entries, ct); + } + + private sealed class FakeSnapshotStore : ISnapshotStore + { + private readonly Dictionary _snaps = []; + public List<(GrainId Id, int Version)> Writes { get; } = []; + public Func? ReadThrows { get; set; } + + public void Seed(GrainId id, SnapshotEnvelope snap) where TState : class + => _snaps[id] = snap; + + // Non-isolating double: returns the same stored envelope/state instance on every read. + // Only safe for tests that read a snapshot once; use InMemorySnapshotStore where + // read-isolation across repeated activations matters. + public Task?> ReadSnapshotAsync( + GrainId grainId, CancellationToken ct = default) where TState : class + { + if (ReadThrows?.Invoke(grainId) is { } ex) throw ex; + return Task.FromResult(_snaps.TryGetValue(grainId, out object? s) + ? (SnapshotEnvelope?)s + : null); + } + + public Task WriteSnapshotAsync( + GrainId grainId, SnapshotEnvelope snapshot, CancellationToken ct = default) + where TState : class + { + Writes.Add((grainId, snapshot.Version)); + _snaps[grainId] = snapshot; + return Task.CompletedTask; + } + + public Task ClearSnapshotAsync(GrainId grainId, CancellationToken ct = default) + { + _snaps.Remove(grainId); + return Task.CompletedTask; + } + } +} diff --git a/tests/Quark.Tests.Unit/Journaling/SnapshotEnvelopeTests.cs b/tests/Quark.Tests.Unit/Journaling/SnapshotEnvelopeTests.cs new file mode 100644 index 0000000..9a602f5 --- /dev/null +++ b/tests/Quark.Tests.Unit/Journaling/SnapshotEnvelopeTests.cs @@ -0,0 +1,29 @@ +using Quark.Core.Abstractions.Identity; +using Quark.Persistence.Abstractions.Journaling; +using Xunit; + +namespace Quark.Tests.Unit.Journaling; + +public sealed class SnapshotEnvelopeTests +{ + private sealed class State { public int N { get; set; } } + + [Fact] + public void SnapshotEnvelope_ExposesVersionAndState() + { + var s = new State { N = 7 }; + var env = new SnapshotEnvelope(3, s); + Assert.Equal(3, env.Version); + Assert.Same(s, env.State); + } + + [Fact] + public void CorruptSnapshotException_CarriesGrainIdAndVersion() + { + var id = new GrainId(new GrainType("G"), "k"); + var ex = new CorruptSnapshotException(id, 42, "boom"); + Assert.Equal(id, ex.GrainId); + Assert.Equal(42, ex.SnapshotVersion); + Assert.Contains("boom", ex.Message); + } +}